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