diff --git a/.gitignore b/.gitignore
index 9cbc3ac..859033d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,7 @@ __pycache__/
*$py.class
/src/lib
+/src/workflow
+/venv
.python-version
diff --git a/.python-version b/.python-version
deleted file mode 100644
index f24054f..0000000
--- a/.python-version
+++ /dev/null
@@ -1 +0,0 @@
-2.7.15
diff --git a/LICENSE b/LICENSE
index e19878a..3fff99d 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2019 Clarence Castillo
+Copyright (c) 2020 Clarence Castillo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/Powerthesaurus-2.0.alfredworkflow b/Powerthesaurus-2.0.alfredworkflow
deleted file mode 100644
index bc87297..0000000
Binary files a/Powerthesaurus-2.0.alfredworkflow and /dev/null differ
diff --git a/Powerthesaurus-2.1.0.alfredworkflow b/Powerthesaurus-2.1.0.alfredworkflow
new file mode 100644
index 0000000..aa5440f
Binary files /dev/null and b/Powerthesaurus-2.1.0.alfredworkflow differ
diff --git a/README.md b/README.md
index 8f3c96c..7105b17 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Powerthesaurus Search for Alfred #
-Search for synonyms and antonyms on [Powerthesaurus.org](https://www.powerthesaurus.org) from [Alfred 3 & 4](https://www.alfredapp.com/).
+Search for synonyms and antonyms on [Powerthesaurus.org](https://www.powerthesaurus.org) from [Alfred 4](https://www.alfredapp.com/).
![](demo.gif "")
diff --git a/init.sh b/init.sh
new file mode 100755
index 0000000..e73e667
--- /dev/null
+++ b/init.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+pip install --target=src Alfred-Workflow --upgrade
+rm -r src/*.dist-info
+mkdir src/lib
+pip install -r src/requirements.txt --target=src/lib --upgrade
diff --git a/src/api.py b/src/api.py
index 7378df8..b63fee4 100644
--- a/src/api.py
+++ b/src/api.py
@@ -1,38 +1,120 @@
-from bs4 import BeautifulSoup
import requests
import logging
import json
+import os
class PowerThesaurus:
- def __init__(self, api_url, logger=logging):
+ USER_AGENT = "Alfred-Powerthesaurus/2.1.0"
+ GQL_THESAURUS_QUERY = "thesaurus_query"
+ GQL_SEARCH_QUERY = "search_query"
+
+ def __init__(self, api_url, web_url, gql_queries_dir="./gql_queries/", pos_file_path="./pos.json", logger=logging):
self.api_url = api_url
+ self.web_url = web_url
self.logger = logger
+ self.gql_queries = self.read_gql_queries(gql_queries_dir)
+ self.pos_mapping = self.read_pos_mapping(pos_file_path)
+ self.request_headers = self.build_request_headers()
+
+ def build_url(self, slug, query_type):
+ return '{}/{}/{}'.format(self.web_url, slug, query_type)
- def parse_term(self, term_data):
+ def build_request_headers(self):
return {
- 'term': term_data['term'],
- 'topics': [t['topic'] for t in term_data['topics']],
- 'rating': int(term_data['rating']),
- 'parts': [p['short_name'] for p in term_data['parts']]
+ "user-agent": PowerThesaurus.USER_AGENT,
+ "content-type": "application/json"
}
- def extract_terms(self, page_text):
- soup = BeautifulSoup(page_text, 'html.parser')
- script = soup.find('script', src='').getText().strip().split('\n')[0]
- data = json.loads(script[script.find('{'):-1])
+ def read_pos_mapping(self, file_path):
+ pos_mapping = {}
+ with open(file_path, 'r') as file:
+ pos_list = json.loads(file.read())
+ for pos in pos_list:
+ pos_mapping[pos['id']] = pos
+ return pos_mapping
- if 'list' not in data:
- return []
+ def read_gql_queries(self, dir):
+ gql_queries = {}
+ files = os.listdir(dir)
+ for filename in files:
+ file_path = os.path.join(dir, filename)
+ with open(file_path, 'r') as file:
+ # get filename without ext
+ key = os.path.splitext(filename)[0]
+ gql_queries[key] = file.read()
+ return gql_queries
- return data['list']['pages'][0]['terms']
+ def build_search_query_params(self, query):
+ return {
+ "operationName": "SEARCH_QUERY",
+ "variables": {
+ "query": query
+ },
+ "query": self.gql_queries[PowerThesaurus.GQL_SEARCH_QUERY]
+ }
- def build_search_url(self, word, query_type):
- return '{}/{}/{}'.format(self.api_url, word.replace(' ', '_'), query_type)
+ def build_thesaurus_query_params(self, term_id, query_type):
+ return {
+ "operationName": "THESAURUSES_QUERY",
+ "variables": {
+ "list": query_type.upper(),
+ "termID": term_id,
+ "sort": {
+ "field": "RATING",
+ "direction": "DESC"
+ },
+ "limit": 50,
+ "syllables": None,
+ "query": None,
+ "posID": None,
+ "first": 50,
+ "after": ""
+ },
+ "query": self.gql_queries[PowerThesaurus.GQL_THESAURUS_QUERY]
+ }
- def search(self, word, query_type):
- r = requests.get(self.build_search_url(word, query_type), headers={'user-agent': 'alfred-powerthesaurus/2.0'})
- self.logger.debug('response : {} {}'.format(r.status_code, r.url))
+ def parse_thesaurus_query_response(self, response):
+ edges = response['data']['thesauruses']['edges']
+ results = map(lambda e : e['node'], edges)
+ return map(lambda r : {
+ 'id': r['targetTerm']['id'],
+ 'word': r['targetTerm']['name'],
+ 'slug': r['targetTerm']['slug'],
+ 'parts_of_speech': map(lambda p : self.pos_mapping[p]['shorter'], r['relations']['parts_of_speech']),
+ 'tags': r['relations']['tags'],
+ 'synonyms_count': r['targetTerm']['counters']['synonyms'],
+ 'antonyms_count': r['targetTerm']['counters']['antonyms'],
+ 'rating': r['rating'],
+ 'url_synonyms': self.build_url(r['targetTerm']['slug'], 'synonyms'),
+ 'url_antonyms': self.build_url(r['targetTerm']['slug'], 'antonyms')
+ }, results)
+
+ def thesaurus_query(self, term_id, query_type):
+ if not term_id:
+ return []
+ params = self.build_thesaurus_query_params(term_id, query_type)
+ r = requests.post(self.api_url, json=params, headers=self.request_headers)
+ self.logger.debug('thesaurus_query: {} {}'.format(r.status_code, r.url))
+ r.raise_for_status()
+ return self.parse_thesaurus_query_response(r.json())
+
+ def parse_search_query_response(self, response):
+ terms = response['data']['search']['terms']
+ return map(lambda t : {
+ 'id': t['id'],
+ 'word': t['name'],
+ }, terms)
+
+ def search_query(self, query):
+ params = self.build_search_query_params(query)
+ r = requests.post(self.api_url, json=params, headers=self.request_headers)
+ self.logger.debug('search_query: {} {}'.format(r.status_code, r.url))
r.raise_for_status()
- data = self.extract_terms(r.text)
- return [self.parse_term(t) for t in data]
+ return self.parse_search_query_response(r.json())
+
+ def search_query_match(self, query):
+ terms = self.search_query(query)
+ if not terms or terms[0]['word'] != query:
+ return None
+ return terms[0]
diff --git a/src/gql_queries/search_query.txt b/src/gql_queries/search_query.txt
new file mode 100644
index 0000000..7bd7cc0
--- /dev/null
+++ b/src/gql_queries/search_query.txt
@@ -0,0 +1,8 @@
+query SEARCH_QUERY($query: String!) {
+ search(query: $query) {
+ terms {
+ id
+ name
+ }
+ }
+}
diff --git a/src/gql_queries/thesaurus_query.txt b/src/gql_queries/thesaurus_query.txt
new file mode 100644
index 0000000..052b362
--- /dev/null
+++ b/src/gql_queries/thesaurus_query.txt
@@ -0,0 +1,39 @@
+query THESAURUSES_QUERY($after: String, $first: Int, $before: String, $last: Int, $termID: ID!, $list: List!, $sort: ThesaurusSorting!, $tagID: Int, $posID: Int, $syllables: Int) {
+ thesauruses(termId: $termID, sort: $sort, list: $list, after: $after, first: $first, before: $before, last: $last, tagId: $tagID, partOfSpeechId: $posID, syllables: $syllables) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ __typename
+ }
+ edges {
+ node {
+ _type
+ id
+ isPinned
+ targetTerm {
+ id
+ name
+ slug
+ counters
+ isFavorite
+ __typename
+ }
+ relations
+ rating
+ vote {
+ voteType
+ id
+ __typename
+ }
+ votes
+ __typename
+ }
+ cursor
+ __typename
+ }
+ __typename
+ }
+}
diff --git a/src/info.plist b/src/info.plist
index 9673d90..89814de 100644
--- a/src/info.plist
+++ b/src/info.plist
@@ -4,6 +4,8 @@
bundleid
me.clarencecastillo.alfred-powerthesaurus
+ category
+ Productivity
connections
3BDFA2EA-F8AA-4E1D-BACD-9578AA20FCF0
@@ -18,6 +20,16 @@
vitoclose
+
+ destinationuid
+ DD4E372B-0316-46D9-B59B-884272644163
+ modifiers
+ 1048576
+ modifiersubtext
+
+ vitoclose
+
+
819E593B-43C3-4EBF-9000-3D3C0F5AB6D2
@@ -31,6 +43,16 @@
vitoclose
+
+ destinationuid
+ DD4E372B-0316-46D9-B59B-884272644163
+ modifiers
+ 1048576
+ modifiersubtext
+
+ vitoclose
+
+
createdby
@@ -58,13 +80,19 @@
uid
EA0FCBB9-3119-4821-A1D9-58B6780D5C37
version
- 2
+ 3
config
alfredfiltersresults
+ alfredfiltersresultsmatchmode
+ 0
+ argumenttreatemptyqueryasnil
+
+ argumenttrimmode
+ 0
argumenttype
0
escaping
@@ -78,11 +106,11 @@
queuedelaymode
1
queuemode
- 1
+ 2
runningsubtext
Searching synonyms for '{query}'
script
- /usr/bin/python powerthesaurus.py "syn {query}"
+ /usr/bin/python powerthesaurus.py "synonym {query}"
scriptargtype
0
scriptfile
@@ -101,13 +129,38 @@
uid
819E593B-43C3-4EBF-9000-3D3C0F5AB6D2
version
- 2
+ 3
+
+
+ config
+
+ browser
+
+ spaces
+
+ url
+ {var:url}
+ utf8
+
+
+ type
+ alfred.workflow.action.openurl
+ uid
+ DD4E372B-0316-46D9-B59B-884272644163
+ version
+ 1
config
alfredfiltersresults
+ alfredfiltersresultsmatchmode
+ 0
+ argumenttreatemptyqueryasnil
+
+ argumenttrimmode
+ 0
argumenttype
0
escaping
@@ -121,11 +174,11 @@
queuedelaymode
1
queuemode
- 1
+ 2
runningsubtext
Searching antonyms for '{query}'
script
- /usr/bin/python powerthesaurus.py "ant {query}"
+ /usr/bin/python powerthesaurus.py "antonym {query}"
scriptargtype
0
scriptfile
@@ -144,7 +197,7 @@
uid
3BDFA2EA-F8AA-4E1D-BACD-9578AA20FCF0
version
- 2
+ 3
readme
@@ -154,23 +207,30 @@
3BDFA2EA-F8AA-4E1D-BACD-9578AA20FCF0
xpos
- 120
+ 105
ypos
- 210
+ 355
819E593B-43C3-4EBF-9000-3D3C0F5AB6D2
xpos
- 120
+ 110
+ ypos
+ 115
+
+ DD4E372B-0316-46D9-B59B-884272644163
+
+ xpos
+ 685
ypos
- 70
+ 350
EA0FCBB9-3119-4821-A1D9-58B6780D5C37
xpos
- 530
+ 685
ypos
- 70
+ 115
version
diff --git a/src/pos.json b/src/pos.json
new file mode 100644
index 0000000..b6520bd
--- /dev/null
+++ b/src/pos.json
@@ -0,0 +1,77 @@
+[
+ {
+ "id":1,
+ "slug":"adjective",
+ "singular":"adjective",
+ "plural":"adjectives",
+ "shorter":"adj."
+ },
+ {
+ "id":2,
+ "slug":"noun",
+ "singular":"noun",
+ "plural":"nouns",
+ "shorter":"n."
+ },
+ {
+ "id":3,
+ "slug":"pronoun",
+ "singular":"pronoun",
+ "plural":"pronouns",
+ "shorter":"pr."
+ },
+ {
+ "id":4,
+ "slug":"adverb",
+ "singular":"adverb",
+ "plural":"adverbs",
+ "shorter":"adv."
+ },
+ {"id":5,
+ "slug":"idiom",
+ "singular":"idiom",
+ "plural":"idioms",
+ "shorter":"idi."
+ },
+ {"id":6,
+ "slug":"verb",
+ "singular":"verb",
+ "plural":"verbs",
+ "shorter":"v."
+ },
+ {
+ "id":7,
+ "slug":"interjection",
+ "singular":"interjection",
+ "plural":"interjections",
+ "shorter":"int."
+ },
+ {
+ "id":8,
+ "slug":"phrase",
+ "singular":"phrase",
+ "plural":"phrases",
+ "shorter":"phr."
+ },
+ {
+ "id":9,
+ "slug":"conjunction",
+ "singular":"conjunction",
+ "plural":"conjunctions",
+ "shorter":"conj."
+ },
+ {
+ "id":10,
+ "slug":"preposition",
+ "singular":"preposition",
+ "plural":"prepositions",
+ "shorter":"prep."
+ },
+ {
+ "id":11,
+ "slug":"phrasal_verb",
+ "singular":"phrasal verb",
+ "plural":"phrasal verbs",
+ "shorter":"phr. v."
+ }
+]
diff --git a/src/powerthesaurus.py b/src/powerthesaurus.py
index fc6358c..454c517 100644
--- a/src/powerthesaurus.py
+++ b/src/powerthesaurus.py
@@ -1,7 +1,7 @@
#!/usr/bin/python
# encoding: utf-8
#
-# Copyright © 2019 hello@clarencecastillo.me
+# Copyright © 2020 hello@clarencecastillo.me
#
# MIT Licence. See http://opensource.org/licenses/MIT
#
@@ -18,82 +18,96 @@
import sys
import functools
+UPDATE_SETTINGS = {
+ 'github_slug': 'clarencecastillo/alfred-powerthesaurus'
+}
+
ICON = 'icon.png'
HELP_URL = 'https://github.com/clarencecastillo/alfred-powerthesaurus'
-API_URL = 'https://www.powerthesaurus.org'
+API_URL = 'https://api.powerthesaurus.org'
+WEB_URL = 'https://www.powerthesaurus.org'
# How long to cache results for
-CACHE_MAX_AGE = 180 # seconds
-
-def build_cache_key(query, tags):
- """Make filesystem-friendly cache key"""
+CACHE_MAX_AGE = 7200 # seconds
+def build_cache_key(query, *tags):
key = query + '_' + ';'.join(tags)
key = key.lower()
key = re.sub(r'[^a-z0-9-_;\.]', '-', key)
key = re.sub(r'-+', '-', key)
return key
-def parse_query(raw_input):
- query_type, _, query = raw_input.strip().partition(" ")
- return query, query_type
-
-def format_term_result(term):
- parts = ' & '.join(term['parts'])
- topics = ' '.join('#{}'.format(t) for t in term['topics'])
- return ' | '.join([str(term['rating']), term['term'], parts, topics])
-
-def build_item_args(term, term_url):
-
- term_word = term['term']
+def build_thesaurus_item_subtitle(term):
+ pos = ' & '.join(term['parts_of_speech'])
+ tags = ' '.join('#{}'.format(t) for t in term['tags'])
+ return ' | '.join([str(term['rating']), term['word']] + filter(lambda s : len(s.strip()), [pos, tags]))
+def build_thesaurus_item(term, query_type):
return {
- 'title': term_word,
- 'subtitle': format_term_result(term),
- 'autocomplete': term_word,
- 'largetext': term_word,
- 'copytext': term_word,
+ 'title': term['word'],
+ 'subtitle': build_thesaurus_item_subtitle(term),
+ 'autocomplete': term['word'],
+ 'largetext': term['word'],
+ 'copytext': term['word'],
'valid': True,
- 'quicklookurl': term_url,
+ 'quicklookurl': {
+ 'synonym': term['url_synonyms'],
+ 'antonym': term['url_antonyms']
+ }[query_type],
'icon': ICON,
- 'arg': term_word
+ 'arg': term['word']
}
def main(wf):
- wf.logger.debug(wf.args)
-
from api import PowerThesaurus
- query, query_type = parse_query(wf.args[0])
- wf.logger.debug('query : {!r} {!r}'.format(query, query_type))
+ input_text = wf.args[0]
+ wf.logger.debug('input: {!r}'.format(input_text))
+
+ query_type, query = input_text.split(' ', 1)
if not query:
wf.add_item('Search Powerthesaurus')
wf.send_feedback()
return 0
- key = build_cache_key(query, [query_type])
- wf.logger.debug('cache key : {!r} {!r} -> {!r}'.format(query, query_type, key))
+ key = build_cache_key(query, query_type)
+ wf.logger.debug('cache key: {!r} -> {!r}'.format(input_text, key))
- pt = PowerThesaurus(API_URL, wf.logger)
+ pt = PowerThesaurus(API_URL, WEB_URL, logger=wf.logger)
- # Fetch terms from cache or from API
- terms = wf.cached_data(key, functools.partial(pt.search, query, query_type), max_age=CACHE_MAX_AGE)
- wf.logger.debug('count : {} terms for {!r}'.format(len(terms), query))
+ thesaurus_terms = wf.cached_data(key, max_age=CACHE_MAX_AGE)
+
+ if thesaurus_terms is not None:
+ wf.logger.debug('cache: found {} {}s for {!r}'.format(len(thesaurus_terms), query_type, query))
+
+ if thesaurus_terms is None:
+
+ # Search term from cache or from API
+ term = pt.search_query_match(query)
+ wf.logger.debug('search: found matching term for {!r}'.format(query))
+
+ # Fetch thesaurus terms from cache or from API
+ thesaurus_terms = wf.cached_data(key, functools.partial(pt.thesaurus_query, (term or {}).get('id'), query_type), max_age=CACHE_MAX_AGE)
+ wf.logger.debug('thesaurus: found {} {}s for {!r}'.format(len(thesaurus_terms), query_type, query))
# Show results
- if not terms:
- wf.add_item('No ' + query_type + ' found', 'Try a different word...', icon=ICON)
+ items = [build_thesaurus_item(t, query_type) for t in (thesaurus_terms or [])]
+ for item in items:
+ wf_item = wf.add_item(**item)
+ cmd_modifier = wf_item.add_modifier('cmd', 'Open this term in your browser')
+ cmd_modifier.setvar('url', item['quicklookurl'])
- for term in terms:
- term_url = pt.build_search_url(term['term'], query_type)
- item = wf.add_item(**build_item_args(term, term_url))
- cmd_modifier = item.add_modifier('cmd', 'Open this term in your browser')
- cmd_modifier.setvar('url', term_url)
+ if not items:
+ wf.add_item('No {}s found for \"{}\"'.format(query_type, query), 'Try searching for another word...', icon=ICON)
wf.send_feedback()
if __name__ == '__main__':
- wf = Workflow3(help_url=HELP_URL, libraries=['./lib'])
+ wf = Workflow3(help_url=HELP_URL, libraries=['./lib'], update_settings=UPDATE_SETTINGS)
+
+ if wf.update_available:
+ wf.start_update()
+
sys.exit(wf.run(main))
diff --git a/src/requirements.txt b/src/requirements.txt
index 8b6e45b..b450057 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -1,2 +1 @@
-beautifulsoup4==4.8.1
-requests==2.22.0
+requests==2.23.0
diff --git a/src/version b/src/version
index cd5ac03..7ec1d6d 100644
--- a/src/version
+++ b/src/version
@@ -1 +1 @@
-2.0
+2.1.0