Table of Contents
Card Lookup
A script to look up cards hiding behind a numbered reference (such as kanan4
or you5
) or by attribute/type (such as ssk kanan
or avo you
).
Please note that this script is not meant to be a search engine or filtering tool, and as such does not support looking up several attributes/roles at once or looking up a card without a character name. It is mainly supposed to be useful as a reference when a given card comes up in discussion - either to quickly look up details, or to find which card is being referred to, since often only a numbered reference or the card's role is mentioned.
Discord Command
Lookup implemented as a Discord slash command which can be added to any server to be used in chat. It uses a local lookup database instead of API requests for faster replies, which are sent as private replies to only the user writing the command. It is currently used on the Wiki Discord and SIFcord.
Locally
A Python3 version that can be run in a terminal, using requests to the API of the Kirara database. The result (or error message) can also be opened directly in a web browser tab, if this script is run outside of a terminal using something like dmenu.
Usage
Download the script below (either by copypasting or by clicking the file name above the code block), make sure Python3 is installed, then run a lookup by passing the search keywords as arguments (see Lookup Keywords and Examples below). If you want to run the script via dmenu, make sure the script or a link to it is placed in your PATH.
Optionally the python-Levenshtein module can be used to deal with typos in the idol name. If it is not installed, only exact name matches can be looked up.
The script is made for UNIX-like OSes, Windows users will have to change the paths for the temporary files in lines 8 and 9. As for running the script as a shortcut, Windows users could probably do a thing with AutoHotkey or something? Open a text input when hitting a hotkey, then put that input into the script as argument?
Lookup Keywords
All keywords are case-insenstive. Order does not matter.
- Rarity: One of
r
,sr
,ur
,fes
,party
. If none are specified, the script will default to URs. - Role: One of
vo
,sp
,gd
orsk
. - Attribute: One of
smile
,pure
,cool
,active
,natural
orelegant
. Can also be shortened with just the first letter:s
,p
,c
,a
,n
ore
. - Combined Role/Attribute: The first letter of the attribute plus the role, without a space between.
- Character Name: Can also be certain alternatives. Check the
namemap
dictionary in the script to see them. - Number: Any number X, will return only the Xth matching card after all other filters are applied.
- Combined Name/Number: A character's name and a number, without a space between, resembling the way cards are often referred to.
Examples
./sifas-lookup.py maki3 # Look up Maki's third UR ./sifas-lookup.py nozo # Look up all of Nozomi's URs ./sifas-lookup.py pure vo chika # Look up Chika's Pure Vo-type URs ./sifas-lookup.py p vo chika ./sifas-lookup.py pvo chika ./sifas-lookup.py sk you # Look up You's Sk-type URs ./sifas-lookup.py you sr sk # Look up You's Sk-type SRs ./sifas-lookup.py r you sk # Look up You's Sk-type Rs ./sifas-lookup.py e pomu # Look up Ayumu's Elegant URs ./sifas-lookup.py elegant ayumu ./sifas-lookup.py fes kasu 2 # Look up Kasumi's second Fes UR ./sifas-lookup.py kasumi2 fes ./sifas-lookup.py party ai # Look up Ai's Party URs
Script
- sifas-lookup.py
- #!/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("<html>==========<br>MULTIPLE RESULTS<br>==========<br><br>")
- for card in cards:
- tfile.write("<a href='" + card_url(card) + "'>" + card_summary(card) + "</a><br>")
- tfile.write("</html>")
- 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("<html>==========<br>ERROR<br>==========<br><br>")
- tfile.write(str(e))
- tfile.write("</html>")
- webbrowser.open("file://" + TMPFILE)
- if download_all == True:
- print()
- print("Downloading all card lists and caching them for future use...")
- download_all_data(False)
Version History
- v12 - Remove character name from leftover keywords in case of error — Suyooo 2021/02/24 13:24 UTC
- v11 - Put data reload into seperate method (for use as module) — Suyooo 2021/02/17 18:03 UTC
- v10 - Add Rarity to card summary string — Suyooo 2021/02/17 15:18 UTC
- v9 - Added option to show translated card names if available (defaults to On) — Suyooo 2021/02/16 14:58 UTC
- v8 - Made the filtering a little more sane, and extendible. Added Fes/Party filters — Suyooo 2021/02/15 20:17 UTC
- v7 - Added Attribute/Type lookup, also a bunch of general “make it nicer” — Suyooo 2021/02/15 16:11 UTC
- v6 - Fix cache not being filled on first run if lookup fails — Suyooo 2020/12/05 14:48 UTC
- v5 - Output in terminal if running in one — Suyooo 2020/12/04 23:13 UTC
- v4 - Caches of Card lists from API do not expire now — Suyooo 2020/12/04 22:51 UTC
- v3 - Added caching of API response — Suyooo 2020/10/08 11:12 UTC
- v2 - Remove unused function — Suyooo 2020/10/07 12:28 UTC
- v1 - Initial Release — Suyooo 2020/10/07 11:30 UTC
Possible Future Ideas
- Make it even more resilent to typos: Typo fixing is only active on the member name - could be applied to full attribute name as well, and for most roles one matching character is enough as well
- Make the HTML page for errors/multiple results nicer by some (very little!) styling
Contributors to this page: