tools:card-lookup

This is an old revision of the document!


Card Lookup

A Python3 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) using the Kirara database. The result (or error message) can also be opened directly in a web browser tab, if this script is run as a shortcut 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 as an argument (see 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.

The script optionally uses the python-Levenshtein module 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 (Linux/Mac/etc), 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?

Examples

# Requests are *not* case-sensitive
./sifas-lookup.py maki3          # Look up Maki's third UR
./sifas-lookup.py nozomi         # Look up all of Nozomi's URs
 
./sifas-lookup.py pure vo chika  # Look up Chika's [Pure] [Vo]-type URs
./sifas-lookup.py purevo chika
./sifas-lookup.py p vo chika
./sifas-lookup.py pvo chika
 
./sifas-lookup.py e pomu         # Look up Ayumu's [E]legant URs
./sifas-lookup.py elegant ayumu
 
./sifas-lookup.py sp ai          # Look up Ai's [Sp]-type URs

Script

sifas-lookup.py
  1. #!/usr/bin/env python3
  2. import json, os, re, stat, sys, time, urllib.request, webbrowser
  3.  
  4. ###
  5. # FEEL FREE TO TOUCH THIS
  6. ###
  7.  
  8. CACHEDIR = os.path.expanduser("~/.cache/sifas-lookup")
  9. TMPFILE = "/tmp/sifas-lookup-response"
  10.  
  11. namemap = {
  12. 1: ["honoka", "honk"],
  13. 2: ["eli"],
  14. 3: ["kotori", "koto", "bird"],
  15. 4: ["umi"],
  16. 5: ["rin"],
  17. 6: ["maki"],
  18. 7: ["nozomi", "nozo"],
  19. 8: ["hanayo", "pana"],
  20. 9: ["nico"],
  21. 101: ["chika"],
  22. 102: ["riko"],
  23. 103: ["kanan", "hagu"],
  24. 104: ["dia"],
  25. 105: ["you"],
  26. 106: ["yoshiko", "yoshi", "yohane", "yoha"],
  27. 107: ["hanamaru", "maru", "zuramaru", "zura"],
  28. 108: ["mari"],
  29. 109: ["ruby"],
  30. 201: ["ayumu", "pomu"],
  31. 202: ["kasumi", "kasumin", "kasu", "kasukasu"],
  32. 203: ["shizuku", "shizu"],
  33. 204: ["karin"],
  34. 205: ["ai"],
  35. 206: ["kanata"],
  36. 207: ["setsuna", "setsu", "nana"],
  37. 208: ["emma"],
  38. 209: ["rina"],
  39. 210: ["shioriko", "shio"]
  40. }
  41.  
  42. ###
  43. # ENTERING "PROBABLY DON'T WANT TO TOUCH" ZONE
  44. ###
  45.  
  46. debug_in_terminal = False
  47. member_ids = namemap.keys()
  48. namemap = {n:i for (i,nl) in namemap.items() for n in nl}
  49. number_regex = re.compile("^([A-Za-z]+) ?(\d+)$")
  50.  
  51. class RequestError(Exception):
  52. pass
  53. class CardLookupError(Exception):
  54. pass
  55.  
  56.  
  57. # Functions to convert string to IDs
  58.  
  59. def short_attr_to_id(short_attr):
  60. if short_attr == "s": return 1
  61. if short_attr == "p": return 2
  62. if short_attr == "c": return 3
  63. if short_attr == "a": return 4
  64. if short_attr == "n": return 5
  65. if short_attr == "e": return 6
  66. raise RequestError("Unknown attribute name: " + str(short_attr))
  67.  
  68. def role_to_id(role):
  69. if role == "vo": return 1
  70. if role == "sp": return 2
  71. if role == "gd": return 3
  72. if role == "sk": return 4
  73. raise RequestError("Unknown role name: " + str(role))
  74.  
  75.  
  76. # Functions for user-readable strings
  77.  
  78. def card_summary(card):
  79. if card["attribute"] == 1: attr = "Smile"
  80. elif card["attribute"] == 2: attr = "Pure"
  81. elif card["attribute"] == 3: attr = "Cool"
  82. elif card["attribute"] == 4: attr = "Active"
  83. elif card["attribute"] == 5: attr = "Natural"
  84. elif card["attribute"] == 6: attr = "Elegant"
  85.  
  86. if card["role"] == 1: role = "Vo"
  87. elif card["role"] == 2: role = "Sp"
  88. elif card["role"] == 3: role = "Gd"
  89. elif card["role"] == 4: role = "Sk"
  90.  
  91. return "#" + str(card["ordinal"]) + " " + attr + " " + role + " (" + card["normal_appearance"]["name"] + " / " + card["idolized_appearance"]["name"] + ")"
  92.  
  93. def card_url(card):
  94. return "https://allstars.kirara.ca/card/" + str(card["ordinal"])
  95.  
  96.  
  97. # Functions to find matching cards in an API response
  98.  
  99. def get_card_by_number(data, number):
  100. for card in reversed(data["result"]):
  101. if card["rarity"] != 30: continue
  102. number -= 1
  103. if number <= 0: return [card]
  104. return []
  105.  
  106. def get_cards_by_attribute_and_role(data, attribute, role):
  107. cards = []
  108. for card in reversed(data["result"]):
  109. if card["rarity"] != 30 or (attribute != None and card["attribute"] != attribute) or (role != None and card["role"] != role): continue
  110. cards.append(card)
  111. return cards
  112.  
  113.  
  114. # Dealing with user input
  115.  
  116. def do_lookup(request):
  117. global namemap, number_regex
  118.  
  119. membername = None
  120. number = None
  121. attribute = None
  122. role = None
  123.  
  124. # Parse arguments
  125. m = number_regex.match(" ".join(request))
  126. if m != None:
  127. # Lookup by Number
  128. membername = m.group(1).lower()
  129. number = int(m.group(2))
  130. else:
  131. # Lookup by Attribute/Type/both/neither
  132. membername = request[-1].lower()
  133. if len(request) == 3:
  134. # [attr] [role] [name]
  135. attribute = short_attr_to_id(request[0][0].lower())
  136. role = role_to_id(request[1].lower())
  137. elif len(request) == 2:
  138. if len(request[0]) == 3:
  139. # [short_attr][role] [name]
  140. attribute = short_attr_to_id(request[0][0].lower())
  141. role = role_to_id(request[0][1:].lower())
  142. elif len(request[0]) == 2:
  143. # [role] [name]
  144. role = role_to_id(request[0].lower())
  145. else:
  146. # [attr][role?] [name]
  147. try:
  148. role = role_to_id(request[0][-2:])
  149. except RequestError:
  150. role = None
  151. attribute = short_attr_to_id(request[0][0].lower())
  152. elif len(request) != 1:
  153. # No or too many arguments
  154. raise RequestError("Unable to interpret request: " + " ".join(request))
  155. # If there's exactly one argument, it's a name-only request
  156.  
  157. # get member ID from requested name
  158. if membername not in namemap:
  159. try:
  160. from Levenshtein import jaro_winkler
  161. bestdist = 0.9 # minimum similarity to meet
  162. bestname = None
  163. for name in namemap.keys():
  164. dist = jaro_winkler(membername, name)
  165. if dist > bestdist:
  166. bestdist = dist
  167. bestname = name
  168.  
  169. if bestname == None:
  170. raise RequestError("Unknown idol name: " + membername)
  171. membername = bestname
  172. except ImportError:
  173. raise RequestError("Unknown idol name: " + membername + " (Misspelled? Try installing the Levenshtein module to allow this script to correct small typos. pip3 install python-Levenshtein)")
  174. member_id = namemap[membername]
  175.  
  176. try:
  177. cards = get_cards(member_id, number, attribute, role)
  178. except CardLookupError:
  179. raise CardLookupError("No matching cards for this request: " + " ".join(request))
  180.  
  181. if len(cards) == 1:
  182. if sys.stdin.isatty() and not debug_in_terminal:
  183. print(card_summary(cards[0]))
  184. print(" " + card_url(cards[0]))
  185. else:
  186. webbrowser.open(card_url(cards[0]))
  187. else:
  188. if sys.stdin.isatty() and not debug_in_terminal:
  189. print("Multiple cards were found:")
  190. print()
  191. for card in cards:
  192. print(card_summary(card))
  193. print(" " + card_url(card))
  194. else:
  195. with open(TMPFILE, "w") as tfile:
  196. tfile.write("<html>==========<br>MULTIPLE RESULTS<br>==========<br><br>")
  197. for card in cards:
  198. tfile.write("<a href='" + card_url(card) + "'>" + card_summary(card) + "</a><br>")
  199. tfile.write("</html>")
  200. webbrowser.open("file://" + TMPFILE)
  201.  
  202.  
  203. # Actual card lookup
  204.  
  205. def get_cards(member_id, number, attribute, role):
  206. global CACHEDIR
  207. force_cache_refresh = False
  208.  
  209. while True:
  210. # get data from cache or API
  211. cfilename = os.path.join(CACHEDIR, str(member_id))
  212. if not os.path.isfile(cfilename) or force_cache_refresh:
  213. with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json") as url:
  214. with open(cfilename, "w") as jfile:
  215. jfile.write(url.read().decode())
  216.  
  217. cards = []
  218. with open(cfilename, "r") as jfile:
  219. data = json.loads(jfile.read())
  220. if number != None: cards = get_card_by_number(data, number)
  221. else: cards = get_cards_by_attribute_and_role(data, attribute, role)
  222.  
  223. if len(cards) == 0:
  224. if not force_cache_refresh:
  225. # No matching card - reload cache and try again to see whether it's a new card
  226. force_cache_refresh = True
  227. continue
  228. else:
  229. # No matching card, already tried reloading cache - this card does not exist
  230. raise CardLookupError()
  231. return cards
  232.  
  233.  
  234. # If run directly instead of being imported as a module, take command line arguments as input
  235.  
  236. if __name__ == '__main__':
  237. download_all_caches = False
  238. if not os.path.isdir(CACHEDIR):
  239. # first launch! - download all member lists for initial caching
  240. # but only after getting the requested card... response time is important
  241. os.makedirs(CACHEDIR)
  242. download_all_caches = True
  243.  
  244. try:
  245. do_lookup(sys.argv[1:])
  246. except (RequestError,CardLookupError) as e:
  247. if sys.stdin.isatty() and not debug_in_terminal:
  248. print("ERROR:", str(e), file=sys.stderr)
  249. else:
  250. with open(TMPFILE, "w") as tfile:
  251. tfile.write("==========\nERROR\n==========\n\n")
  252. tfile.write(str(e))
  253. webbrowser.open("file://" + TMPFILE)
  254.  
  255. if download_all_caches == True:
  256. for member_id in member_ids:
  257. cfilename = os.path.join(CACHEDIR, str(member_id))
  258. if not os.path.isfile(cfilename):
  259. with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json") as url:
  260. with open(cfilename, "w") as jfile:
  261. jfile.write(url.read().decode())

Version History

  • 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 web page for errors/multiple results nicer by some (very little) styling
  • Option for translated names if available?

Contributors to this page:
tools/card-lookup.1613407554.txt.gz · Last modified: 2021/02/15 16:45 by Suyooo