#!/usr/bin/env python3 import json, os, re, stat, sys, time, urllib.request, webbrowser ### # FEEL FREE TO TOUCH THIS ### CACHEDIR = os.path.expanduser("~/.cache/sifas-lookup") TMPFILE = "/tmp/sifas-lookup-response" use_translated_names = True # if available namemap = { 1: ["honoka", "honk"], 2: ["eli"], 3: ["kotori", "koto", "bird"], 4: ["umi"], 5: ["rin"], 6: ["maki"], 7: ["nozomi", "nozo"], 8: ["hanayo", "pana"], 9: ["nico"], 101: ["chika"], 102: ["riko"], 103: ["kanan", "hagu"], 104: ["dia"], 105: ["you"], 106: ["yoshiko", "yohane", "yoha"], 107: ["hanamaru", "maru", "zuramaru", "zura"], 108: ["mari"], 109: ["ruby"], 201: ["ayumu", "pomu"], 202: ["kasumi", "kasumin", "kasu", "kasukasu"], 203: ["shizuku", "shizu"], 204: ["karin"], 205: ["ai"], 206: ["kanata"], 207: ["setsuna", "setsu", "nana"], 208: ["emma"], 209: ["rina"], 210: ["shioriko", "shio"], 211: ["mia"], 212: ["lanzhu"] } ### # ENTERING "PROBABLY DON'T WANT TO TOUCH" ZONE ### debug_in_terminal = False use_translated_names = "?with_accept_language=1" if use_translated_names else "" member_ids = namemap.keys() namemap = {n:i for (i,nl) in namemap.items() for n in nl} number_regex = re.compile("^([A-Za-z]+) ?(\d+)$") class RequestError(Exception): pass class CardLookupError(Exception): pass # Functions to convert string to IDs def short_attr_to_id(short_attr): if short_attr == "s": return 1 if short_attr == "p": return 2 if short_attr == "c": return 3 if short_attr == "a": return 4 if short_attr == "n": return 5 if short_attr == "e": return 6 raise RequestError("Unknown attribute name: " + str(short_attr)) def role_to_id(role): if role == "vo": return 1 if role == "sp": return 2 if role == "gd": return 3 if role == "sk": return 4 raise RequestError("Unknown role name: " + str(role)) # Functions for user-readable strings def card_summary(card): if card["sp_point"] == 4: if len(card["passive_skills"][0]["levels"]) == 5: rarity = "Fes" elif len(card["passive_skills"][0]["levels"]) == 7: rarity = "Party" elif card["rarity"] == 10: rarity = "R" elif card["rarity"] == 20: rarity = "SR" elif card["rarity"] == 30: rarity = "UR" if card["attribute"] == 1: attr = "Smile" elif card["attribute"] == 2: attr = "Pure" elif card["attribute"] == 3: attr = "Cool" elif card["attribute"] == 4: attr = "Active" elif card["attribute"] == 5: attr = "Natural" elif card["attribute"] == 6: attr = "Elegant" if card["role"] == 1: role = "Vo" elif card["role"] == 2: role = "Sp" elif card["role"] == 3: role = "Gd" elif card["role"] == 4: role = "Sk" return "#" + str(card["ordinal"]) + " " + rarity + " " + attr + " " + role + " (" + card["normal_appearance"]["name"] + " / " + card["idolized_appearance"]["name"] + ")" def card_url(card): return "https://allstars.kirara.ca/card/" + str(card["ordinal"]) # Functions to find matching cards in an API response def make_property_equals_filter(prop, value): def filter(c): return c[prop] == value return filter def make_rarity_filter(rarity): return make_property_equals_filter("rarity", rarity) def make_attribute_filter(attribute): return make_property_equals_filter("attribute", attribute) def make_role_filter(role): return make_property_equals_filter("role", role) def make_is_fes_filter(): def filter(c): return c["sp_point"] == 4 and len(c["passive_skills"][0]["levels"]) == 5 return filter def make_is_party_filter(): def filter(c): return c["sp_point"] == 4 and len(c["passive_skills"][0]["levels"]) == 7 return filter # Dealing with user input def do_lookup(request): global namemap, number_regex membername = None number = None filters = [] filtered_rarity = False filtered_attribute = False filtered_role = False filtered_number = False # Parse arguments def parse_keywords(k): nonlocal filtered_rarity, filtered_attribute, filtered_role, filtered_number, filters, number if k.isnumeric(): if filtered_number: raise RequestError("Multiple Number filters specified") filtered_number = True number = int(k) return False k = k.lower() if k == "r": if filtered_rarity: raise RequestError("Multiple Rarity filters specified") filtered_rarity = True filters.append(make_rarity_filter(10)) return False if k == "sr": if filtered_rarity: raise RequestError("Multiple Rarity filters specified") filtered_rarity = True filters.append(make_rarity_filter(20)) return False if k == "ur": if filtered_rarity: raise RequestError("Multiple Rarity filters specified") filtered_rarity = True filters.append(make_rarity_filter(30)) return False if k == "fes": if filtered_rarity: raise RequestError("Multiple Rarity filters specified") filtered_rarity = True filters.append(make_is_fes_filter()) return False if k == "party": if filtered_rarity: raise RequestError("Multiple Rarity filters specified") filtered_rarity = True filters.append(make_is_party_filter()) return False if k == "vo": if filtered_role: raise RequestError("Multiple Role filters specified") filtered_role = True filters.append(make_role_filter(1)) return False if k == "sp": if filtered_role: raise RequestError("Multiple Role filters specified") filtered_role = True filters.append(make_role_filter(2)) return False if k == "gd": if filtered_role: raise RequestError("Multiple Role filters specified") filtered_role = True filters.append(make_role_filter(3)) return False if k == "sk": if filtered_role: raise RequestError("Multiple Role filters specified") filtered_role = True filters.append(make_role_filter(4)) return False if k == "s" or k == "smile": if filtered_attribute: raise RequestError("Multiple Attribute filters specified") filtered_attribute = True filters.append(make_attribute_filter(1)) return False if k == "p" or k == "pure": if filtered_attribute: raise RequestError("Multiple Attribute filters specified") filtered_attribute = True filters.append(make_attribute_filter(2)) return False if k == "c" or k == "cool": if filtered_attribute: raise RequestError("Multiple Attribute filters specified") filtered_attribute = True filters.append(make_attribute_filter(3)) return False if k == "a" or k == "active": if filtered_attribute: raise RequestError("Multiple Attribute filters specified") filtered_attribute = True filters.append(make_attribute_filter(4)) return False if k == "n" or k == "natural": if filtered_attribute: raise RequestError("Multiple Attribute filters specified") filtered_attribute = True filters.append(make_attribute_filter(5)) return False if k == "e" or k == "elegant": if filtered_attribute: raise RequestError("Multiple Attribute filters specified") filtered_attribute = True filters.append(make_attribute_filter(6)) return False # Test whether it's a combined short attribute + type keyword if len(k) == 3: combined_keyword = False try: r = role_to_id(k[-2:]) a = short_attr_to_id(k[0]) combined_keyword = True except RequestError: pass if combined_keyword: if filtered_role: raise RequestError("Multiple Role filters specified") if filtered_attribute: raise RequestError("Multiple Attribute filters specified") filtered_role = True filters.append(make_role_filter(r)) filtered_attribute = True filters.append(make_attribute_filter(a)) return False return True leftover_keywords = [x for x in filter(parse_keywords, request)] # There should be only the character's name left now. Might be a numbered reference? if len(leftover_keywords) == 0: raise RequestError("No character name defined") if len(leftover_keywords) > 1: leftover_keywords = [x for x in leftover_keywords if x not in namemap] raise RequestError("Unrecognized keywords: " + ", ".join(leftover_keywords)) m = number_regex.match(leftover_keywords[0]) if m != None: if filtered_number: raise RequestError("Multiple Number filters specified") filtered_number = True membername = m.group(1).lower() number = int(m.group(2)) else: membername = leftover_keywords[0].lower() if not filtered_rarity: filters.insert(0, make_rarity_filter(30)) # get member ID from requested name if membername not in namemap: try: from Levenshtein import jaro_winkler bestdist = 0 bestname = None for name in namemap.keys(): dist = jaro_winkler(membername, name) if dist > bestdist: bestdist = dist bestname = name if bestdist < 0.9: # minimum similarity to meet raise RequestError("Unknown idol name: " + membername + " (best match: " + bestname + " with similarity " + str(bestdist) + ")") membername = bestname except ImportError: raise RequestError("Unknown idol name: " + membername + " (Misspelled? Try installing the Levenshtein module to allow this script to correct small typos. pip3 install python-Levenshtein)") member_id = namemap[membername] try: cards = get_cards(member_id, number, filters) except CardLookupError as e: raise CardLookupError(str(e) + ": " + " ".join(request)) if len(cards) == 1: if sys.stdin.isatty() and not debug_in_terminal: print(card_summary(cards[0])) print(" " + card_url(cards[0])) else: webbrowser.open(card_url(cards[0])) else: if sys.stdin.isatty() and not debug_in_terminal: print("Multiple cards were found:") print() for card in cards: print(card_summary(card)) print(" " + card_url(card)) else: with open(TMPFILE, "w") as tfile: tfile.write("==========
MULTIPLE RESULTS
==========

") for card in cards: tfile.write("" + card_summary(card) + "
") tfile.write("") webbrowser.open("file://" + TMPFILE) # Actual card lookup def get_cards(member_id, number, filters): global CACHEDIR force_cache_refresh = False while True: # get data from cache or API cfilename = os.path.join(CACHEDIR, str(member_id)) if not os.path.isfile(cfilename) or force_cache_refresh: with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json" + use_translated_names) as url: with open(cfilename, "w") as jfile: jfile.write(url.read().decode()) with open(cfilename, "r") as jfile: data = json.loads(jfile.read())["result"] cards = reversed(data) for f in filters: cards = filter(f, cards) cards = [x for x in cards] result_count = len(cards) if number != None: cards = cards[number-1:number] if len(cards) == 0: if not force_cache_refresh: # No matching card - reload cache and try again to see whether it's a new card force_cache_refresh = True continue else: # No matching card, already tried reloading cache - this card does not exist if result_count == 0: raise CardLookupError("No matching cards for this request") else: raise CardLookupError("Result #" + str(number) + " was requested, but there's only " + str(result_count) + " matching card(s) for this request") return cards # Download all API data - if the parameter is set to true, all caches will be reloaded, whether they exist or not def download_all_data(reload_all): for member_id in member_ids: cfilename = os.path.join(CACHEDIR, str(member_id)) if reload_all or not os.path.isfile(cfilename): with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json" + use_translated_names) as url: with open(cfilename, "w") as jfile: jfile.write(url.read().decode()) # If run directly instead of being imported as a module, take command line arguments as input if __name__ == '__main__': download_all = False if not os.path.isdir(CACHEDIR): # First launch! - download all member lists for initial caching # But only after getting the requested card... response time is important os.makedirs(CACHEDIR) download_all = True try: do_lookup(sys.argv[1:]) except (RequestError,CardLookupError) as e: if sys.stdin.isatty() and not debug_in_terminal: print("ERROR:", str(e), file=sys.stderr) else: with open(TMPFILE, "w") as tfile: tfile.write("==========
ERROR
==========

") tfile.write(str(e)) tfile.write("") webbrowser.open("file://" + TMPFILE) if download_all == True: print() print("Downloading all card lists and caching them for future use...") download_all_data(False)