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 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 (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?

Lookup Keywords

All keywords are case-insenstive. Order does not matter.

  • Rarity: One of r, sr, ur, lim/limited, fes, party. If none are specified, the script will default to URs.
  • Role: One of vo, sp, gd or sk.
  • Attribute: One of smile, pure, cool, active, natural or elegant. Can also be shortened with just the first letter: s, p, c, a, n or e.
  • 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 sp you         # Look up You's Sp-type URs
./sifas-lookup.py you sp sr      # Look up You's Sp-type SRs
./sifas-lookup.py r sp you       # Look up You's Sp-type Rs (as of right now, there are none)
 
./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 party ai       # Look up Ai's Party 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 make_property_equals_filter(prop, value):
  100. def filter(c):
  101. return c[prop] == value
  102. return filter
  103.  
  104. def make_rarity_filter(rarity):
  105. return make_property_equals_filter("rarity", rarity)
  106.  
  107. def make_attribute_filter(attribute):
  108. return make_property_equals_filter("attribute", attribute)
  109.  
  110. def make_role_filter(role):
  111. return make_property_equals_filter("role", role)
  112.  
  113. def make_is_fes_filter():
  114. def filter(c):
  115. return c["sp_point"] == 4 and len(c["passive_skills"][0]["levels"]) == 5
  116. return filter
  117.  
  118. def make_is_party_filter():
  119. def filter(c):
  120. return c["sp_point"] == 4 and len(c["passive_skills"][0]["levels"]) == 7
  121. return filter
  122.  
  123.  
  124. # Dealing with user input
  125.  
  126. def do_lookup(request):
  127. global namemap, number_regex
  128.  
  129. membername = None
  130. number = None
  131. filters = []
  132. filtered_rarity = False
  133. filtered_attribute = False
  134. filtered_role = False
  135. filtered_number = False
  136.  
  137. # Parse arguments
  138. def parse_keywords(k):
  139. nonlocal filtered_rarity, filtered_attribute, filtered_role, filtered_number, filters, number
  140.  
  141. if k.isnumeric():
  142. if filtered_number: raise RequestError("Multiple Number filters specified")
  143. filtered_number = True
  144. number = int(k)
  145. return False
  146.  
  147. k = k.lower()
  148. if k == "r":
  149. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  150. filtered_rarity = True
  151. filters.append(make_rarity_filter(10))
  152. return False
  153. if k == "sr":
  154. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  155. filtered_rarity = True
  156. filters.append(make_rarity_filter(20))
  157. return False
  158. if k == "ur":
  159. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  160. filtered_rarity = True
  161. filters.append(make_rarity_filter(30))
  162. return False
  163. if k == "fes":
  164. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  165. filtered_rarity = True
  166. filters.append(make_is_fes_filter())
  167. return False
  168. if k == "party":
  169. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  170. filtered_rarity = True
  171. filters.append(make_is_party_filter())
  172. return False
  173.  
  174. if k == "vo":
  175. if filtered_role: raise RequestError("Multiple Role filters specified")
  176. filtered_role = True
  177. filters.append(make_role_filter(1))
  178. return False
  179. if k == "sp":
  180. if filtered_role: raise RequestError("Multiple Role filters specified")
  181. filtered_role = True
  182. filters.append(make_role_filter(2))
  183. return False
  184. if k == "gd":
  185. if filtered_role: raise RequestError("Multiple Role filters specified")
  186. filtered_role = True
  187. filters.append(make_role_filter(3))
  188. return False
  189. if k == "sk":
  190. if filtered_role: raise RequestError("Multiple Role filters specified")
  191. filtered_role = True
  192. filters.append(make_role_filter(4))
  193. return False
  194.  
  195. if k == "s" or k == "smile":
  196. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  197. filtered_attribute = True
  198. filters.append(make_attribute_filter(1))
  199. return False
  200. if k == "p" or k == "pure":
  201. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  202. filtered_attribute = True
  203. filters.append(make_attribute_filter(2))
  204. return False
  205. if k == "c" or k == "cool":
  206. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  207. filtered_attribute = True
  208. filters.append(make_attribute_filter(3))
  209. return False
  210. if k == "a" or k == "active":
  211. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  212. filtered_attribute = True
  213. filters.append(make_attribute_filter(4))
  214. return False
  215. if k == "n" or k == "natural":
  216. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  217. filtered_attribute = True
  218. filters.append(make_attribute_filter(5))
  219. return False
  220. if k == "e" or k == "elegant":
  221. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  222. filtered_attribute = True
  223. filters.append(make_attribute_filter(6))
  224. return False
  225.  
  226. # Test whether it's a combined short attribute + type keyword
  227. if len(k) == 3:
  228. combined_keyword = False
  229. try:
  230. r = role_to_id(k[-2:])
  231. a = short_attr_to_id(k[0])
  232. combined_keyword = True
  233. except RequestError:
  234. pass
  235. if combined_keyword:
  236. if filtered_role: raise RequestError("Multiple Role filters specified")
  237. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  238. filtered_role = True
  239. filters.append(make_role_filter(r))
  240. filtered_attribute = True
  241. filters.append(make_attribute_filter(a))
  242. return False
  243.  
  244. return True
  245. leftover_keywords = [x for x in filter(parse_keywords, request)]
  246.  
  247. # There should be only the character's name left now. Might be a numbered reference?
  248. if len(leftover_keywords) == 0:
  249. raise RequestError("No character name defined")
  250. if len(leftover_keywords) > 1:
  251. raise RequestError("Unrecognized keywords (one of them is the character name, that one's fine, but fix the others): " + ", ".join(leftover_keywords))
  252.  
  253. m = number_regex.match(leftover_keywords[0])
  254. if m != None:
  255. if filtered_number: raise RequestError("Multiple Number filters specified")
  256. filtered_number = True
  257. membername = m.group(1).lower()
  258. number = int(m.group(2))
  259. else:
  260. membername = leftover_keywords[0].lower()
  261.  
  262. if not filtered_rarity:
  263. filters.insert(0, make_rarity_filter(30))
  264.  
  265. # get member ID from requested name
  266. if membername not in namemap:
  267. try:
  268. from Levenshtein import jaro_winkler
  269. bestdist = 0.9 # minimum similarity to meet
  270. bestname = None
  271. for name in namemap.keys():
  272. dist = jaro_winkler(membername, name)
  273. if dist > bestdist:
  274. bestdist = dist
  275. bestname = name
  276.  
  277. if bestname == None:
  278. raise RequestError("Unknown idol name: " + membername)
  279. membername = bestname
  280. except ImportError:
  281. raise RequestError("Unknown idol name: " + membername + " (Misspelled? Try installing the Levenshtein module to allow this script to correct small typos. pip3 install python-Levenshtein)")
  282. member_id = namemap[membername]
  283.  
  284. try:
  285. cards = get_cards(member_id, number, filters)
  286. except CardLookupError as e:
  287. raise CardLookupError(str(e) + ": " + " ".join(request))
  288.  
  289. if len(cards) == 1:
  290. if sys.stdin.isatty() and not debug_in_terminal:
  291. print(card_summary(cards[0]))
  292. print(" " + card_url(cards[0]))
  293. else:
  294. webbrowser.open(card_url(cards[0]))
  295. else:
  296. if sys.stdin.isatty() and not debug_in_terminal:
  297. print("Multiple cards were found:")
  298. print()
  299. for card in cards:
  300. print(card_summary(card))
  301. print(" " + card_url(card))
  302. else:
  303. with open(TMPFILE, "w") as tfile:
  304. tfile.write("<html>==========<br>MULTIPLE RESULTS<br>==========<br><br>")
  305. for card in cards:
  306. tfile.write("<a href='" + card_url(card) + "'>" + card_summary(card) + "</a><br>")
  307. tfile.write("</html>")
  308. webbrowser.open("file://" + TMPFILE)
  309.  
  310.  
  311. # Actual card lookup
  312.  
  313. def get_cards(member_id, number, filters):
  314. global CACHEDIR
  315. force_cache_refresh = False
  316.  
  317. while True:
  318. # get data from cache or API
  319. cfilename = os.path.join(CACHEDIR, str(member_id))
  320. if not os.path.isfile(cfilename) or force_cache_refresh:
  321. with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json") as url:
  322. with open(cfilename, "w") as jfile:
  323. jfile.write(url.read().decode())
  324.  
  325. with open(cfilename, "r") as jfile:
  326. data = json.loads(jfile.read())["result"]
  327. cards = reversed(data)
  328. for f in filters:
  329. cards = filter(f, cards)
  330. cards = [x for x in cards]
  331. result_count = len(cards)
  332.  
  333. if number != None:
  334. cards = cards[number-1:number]
  335.  
  336. if len(cards) == 0:
  337. if not force_cache_refresh:
  338. # No matching card - reload cache and try again to see whether it's a new card
  339. force_cache_refresh = True
  340. continue
  341. else:
  342. # No matching card, already tried reloading cache - this card does not exist
  343. if result_count == 0:
  344. raise CardLookupError("No matching cards for this request")
  345. else:
  346. raise CardLookupError("Result #" + str(number) + " was requested, but there's only " + str(result_count) + " matching card(s) for this request")
  347. return cards
  348.  
  349.  
  350. # If run directly instead of being imported as a module, take command line arguments as input
  351.  
  352. if __name__ == '__main__':
  353. download_all_caches = False
  354. if not os.path.isdir(CACHEDIR):
  355. # first launch! - download all member lists for initial caching
  356. # but only after getting the requested card... response time is important
  357. os.makedirs(CACHEDIR)
  358. download_all_caches = True
  359.  
  360. try:
  361. do_lookup(sys.argv[1:])
  362. except (RequestError,CardLookupError) as e:
  363. if sys.stdin.isatty() and not debug_in_terminal:
  364. print("ERROR:", str(e), file=sys.stderr)
  365. else:
  366. with open(TMPFILE, "w") as tfile:
  367. tfile.write("<html>==========<br>ERROR<br>==========<br><br>")
  368. tfile.write(str(e))
  369. tfile.write("</html>")
  370. webbrowser.open("file://" + TMPFILE)
  371.  
  372. if download_all_caches == True:
  373. for member_id in member_ids:
  374. cfilename = os.path.join(CACHEDIR, str(member_id))
  375. if not os.path.isfile(cfilename):
  376. with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json") as url:
  377. with open(cfilename, "w") as jfile:
  378. jfile.write(url.read().decode())

Version History

  • 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 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.1613420681.txt.gz · Last modified: 2021/02/15 20:24 by Suyooo