tools:card-lookup

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.

Add it to your own server

Source Code on GitHub

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 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 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
  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. use_translated_names = True # if available
  12.  
  13. namemap = {
  14. 1: ["honoka", "honk"],
  15. 2: ["eli"],
  16. 3: ["kotori", "koto", "bird"],
  17. 4: ["umi"],
  18. 5: ["rin"],
  19. 6: ["maki"],
  20. 7: ["nozomi", "nozo"],
  21. 8: ["hanayo", "pana"],
  22. 9: ["nico"],
  23. 101: ["chika"],
  24. 102: ["riko"],
  25. 103: ["kanan", "hagu"],
  26. 104: ["dia"],
  27. 105: ["you"],
  28. 106: ["yoshiko", "yohane", "yoha"],
  29. 107: ["hanamaru", "maru", "zuramaru", "zura"],
  30. 108: ["mari"],
  31. 109: ["ruby"],
  32. 201: ["ayumu", "pomu"],
  33. 202: ["kasumi", "kasumin", "kasu", "kasukasu"],
  34. 203: ["shizuku", "shizu"],
  35. 204: ["karin"],
  36. 205: ["ai"],
  37. 206: ["kanata"],
  38. 207: ["setsuna", "setsu", "nana"],
  39. 208: ["emma"],
  40. 209: ["rina"],
  41. 210: ["shioriko", "shio"],
  42. 211: ["mia"],
  43. 212: ["lanzhu"]
  44. }
  45.  
  46. ###
  47. # ENTERING "PROBABLY DON'T WANT TO TOUCH" ZONE
  48. ###
  49.  
  50. debug_in_terminal = False
  51. use_translated_names = "?with_accept_language=1" if use_translated_names else ""
  52. member_ids = namemap.keys()
  53. namemap = {n:i for (i,nl) in namemap.items() for n in nl}
  54. number_regex = re.compile("^([A-Za-z]+) ?(\d+)$")
  55.  
  56. class RequestError(Exception):
  57. pass
  58. class CardLookupError(Exception):
  59. pass
  60.  
  61.  
  62. # Functions to convert string to IDs
  63.  
  64. def short_attr_to_id(short_attr):
  65. if short_attr == "s": return 1
  66. if short_attr == "p": return 2
  67. if short_attr == "c": return 3
  68. if short_attr == "a": return 4
  69. if short_attr == "n": return 5
  70. if short_attr == "e": return 6
  71. raise RequestError("Unknown attribute name: " + str(short_attr))
  72.  
  73. def role_to_id(role):
  74. if role == "vo": return 1
  75. if role == "sp": return 2
  76. if role == "gd": return 3
  77. if role == "sk": return 4
  78. raise RequestError("Unknown role name: " + str(role))
  79.  
  80.  
  81. # Functions for user-readable strings
  82.  
  83. def card_summary(card):
  84. if card["sp_point"] == 4:
  85. if len(card["passive_skills"][0]["levels"]) == 5: rarity = "Fes"
  86. elif len(card["passive_skills"][0]["levels"]) == 7: rarity = "Party"
  87. elif card["rarity"] == 10: rarity = "R"
  88. elif card["rarity"] == 20: rarity = "SR"
  89. elif card["rarity"] == 30: rarity = "UR"
  90.  
  91. if card["attribute"] == 1: attr = "Smile"
  92. elif card["attribute"] == 2: attr = "Pure"
  93. elif card["attribute"] == 3: attr = "Cool"
  94. elif card["attribute"] == 4: attr = "Active"
  95. elif card["attribute"] == 5: attr = "Natural"
  96. elif card["attribute"] == 6: attr = "Elegant"
  97.  
  98. if card["role"] == 1: role = "Vo"
  99. elif card["role"] == 2: role = "Sp"
  100. elif card["role"] == 3: role = "Gd"
  101. elif card["role"] == 4: role = "Sk"
  102.  
  103. return "#" + str(card["ordinal"]) + " " + rarity + " " + attr + " " + role + " (" + card["normal_appearance"]["name"] + " / " + card["idolized_appearance"]["name"] + ")"
  104.  
  105. def card_url(card):
  106. return "https://allstars.kirara.ca/card/" + str(card["ordinal"])
  107.  
  108.  
  109. # Functions to find matching cards in an API response
  110.  
  111. def make_property_equals_filter(prop, value):
  112. def filter(c):
  113. return c[prop] == value
  114. return filter
  115.  
  116. def make_rarity_filter(rarity):
  117. return make_property_equals_filter("rarity", rarity)
  118.  
  119. def make_attribute_filter(attribute):
  120. return make_property_equals_filter("attribute", attribute)
  121.  
  122. def make_role_filter(role):
  123. return make_property_equals_filter("role", role)
  124.  
  125. def make_is_fes_filter():
  126. def filter(c):
  127. return c["sp_point"] == 4 and len(c["passive_skills"][0]["levels"]) == 5
  128. return filter
  129.  
  130. def make_is_party_filter():
  131. def filter(c):
  132. return c["sp_point"] == 4 and len(c["passive_skills"][0]["levels"]) == 7
  133. return filter
  134.  
  135.  
  136. # Dealing with user input
  137.  
  138. def do_lookup(request):
  139. global namemap, number_regex
  140.  
  141. membername = None
  142. number = None
  143. filters = []
  144. filtered_rarity = False
  145. filtered_attribute = False
  146. filtered_role = False
  147. filtered_number = False
  148.  
  149. # Parse arguments
  150. def parse_keywords(k):
  151. nonlocal filtered_rarity, filtered_attribute, filtered_role, filtered_number, filters, number
  152.  
  153. if k.isnumeric():
  154. if filtered_number: raise RequestError("Multiple Number filters specified")
  155. filtered_number = True
  156. number = int(k)
  157. return False
  158.  
  159. k = k.lower()
  160. if k == "r":
  161. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  162. filtered_rarity = True
  163. filters.append(make_rarity_filter(10))
  164. return False
  165. if k == "sr":
  166. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  167. filtered_rarity = True
  168. filters.append(make_rarity_filter(20))
  169. return False
  170. if k == "ur":
  171. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  172. filtered_rarity = True
  173. filters.append(make_rarity_filter(30))
  174. return False
  175. if k == "fes":
  176. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  177. filtered_rarity = True
  178. filters.append(make_is_fes_filter())
  179. return False
  180. if k == "party":
  181. if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
  182. filtered_rarity = True
  183. filters.append(make_is_party_filter())
  184. return False
  185.  
  186. if k == "vo":
  187. if filtered_role: raise RequestError("Multiple Role filters specified")
  188. filtered_role = True
  189. filters.append(make_role_filter(1))
  190. return False
  191. if k == "sp":
  192. if filtered_role: raise RequestError("Multiple Role filters specified")
  193. filtered_role = True
  194. filters.append(make_role_filter(2))
  195. return False
  196. if k == "gd":
  197. if filtered_role: raise RequestError("Multiple Role filters specified")
  198. filtered_role = True
  199. filters.append(make_role_filter(3))
  200. return False
  201. if k == "sk":
  202. if filtered_role: raise RequestError("Multiple Role filters specified")
  203. filtered_role = True
  204. filters.append(make_role_filter(4))
  205. return False
  206.  
  207. if k == "s" or k == "smile":
  208. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  209. filtered_attribute = True
  210. filters.append(make_attribute_filter(1))
  211. return False
  212. if k == "p" or k == "pure":
  213. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  214. filtered_attribute = True
  215. filters.append(make_attribute_filter(2))
  216. return False
  217. if k == "c" or k == "cool":
  218. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  219. filtered_attribute = True
  220. filters.append(make_attribute_filter(3))
  221. return False
  222. if k == "a" or k == "active":
  223. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  224. filtered_attribute = True
  225. filters.append(make_attribute_filter(4))
  226. return False
  227. if k == "n" or k == "natural":
  228. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  229. filtered_attribute = True
  230. filters.append(make_attribute_filter(5))
  231. return False
  232. if k == "e" or k == "elegant":
  233. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  234. filtered_attribute = True
  235. filters.append(make_attribute_filter(6))
  236. return False
  237.  
  238. # Test whether it's a combined short attribute + type keyword
  239. if len(k) == 3:
  240. combined_keyword = False
  241. try:
  242. r = role_to_id(k[-2:])
  243. a = short_attr_to_id(k[0])
  244. combined_keyword = True
  245. except RequestError:
  246. pass
  247. if combined_keyword:
  248. if filtered_role: raise RequestError("Multiple Role filters specified")
  249. if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
  250. filtered_role = True
  251. filters.append(make_role_filter(r))
  252. filtered_attribute = True
  253. filters.append(make_attribute_filter(a))
  254. return False
  255.  
  256. return True
  257. leftover_keywords = [x for x in filter(parse_keywords, request)]
  258.  
  259. # There should be only the character's name left now. Might be a numbered reference?
  260. if len(leftover_keywords) == 0:
  261. raise RequestError("No character name defined")
  262. if len(leftover_keywords) > 1:
  263. leftover_keywords = [x for x in leftover_keywords if x not in namemap]
  264. raise RequestError("Unrecognized keywords: " + ", ".join(leftover_keywords))
  265.  
  266. m = number_regex.match(leftover_keywords[0])
  267. if m != None:
  268. if filtered_number: raise RequestError("Multiple Number filters specified")
  269. filtered_number = True
  270. membername = m.group(1).lower()
  271. number = int(m.group(2))
  272. else:
  273. membername = leftover_keywords[0].lower()
  274.  
  275. if not filtered_rarity:
  276. filters.insert(0, make_rarity_filter(30))
  277.  
  278. # get member ID from requested name
  279. if membername not in namemap:
  280. try:
  281. from Levenshtein import jaro_winkler
  282. bestdist = 0
  283. bestname = None
  284. for name in namemap.keys():
  285. dist = jaro_winkler(membername, name)
  286. if dist > bestdist:
  287. bestdist = dist
  288. bestname = name
  289.  
  290. if bestdist < 0.9: # minimum similarity to meet
  291. raise RequestError("Unknown idol name: " + membername + " (best match: " + bestname + " with similarity " + str(bestdist) + ")")
  292. membername = bestname
  293. except ImportError:
  294. raise RequestError("Unknown idol name: " + membername + " (Misspelled? Try installing the Levenshtein module to allow this script to correct small typos. pip3 install python-Levenshtein)")
  295. member_id = namemap[membername]
  296.  
  297. try:
  298. cards = get_cards(member_id, number, filters)
  299. except CardLookupError as e:
  300. raise CardLookupError(str(e) + ": " + " ".join(request))
  301.  
  302. if len(cards) == 1:
  303. if sys.stdin.isatty() and not debug_in_terminal:
  304. print(card_summary(cards[0]))
  305. print(" " + card_url(cards[0]))
  306. else:
  307. webbrowser.open(card_url(cards[0]))
  308. else:
  309. if sys.stdin.isatty() and not debug_in_terminal:
  310. print("Multiple cards were found:")
  311. print()
  312. for card in cards:
  313. print(card_summary(card))
  314. print(" " + card_url(card))
  315. else:
  316. with open(TMPFILE, "w") as tfile:
  317. tfile.write("<html>==========<br>MULTIPLE RESULTS<br>==========<br><br>")
  318. for card in cards:
  319. tfile.write("<a href='" + card_url(card) + "'>" + card_summary(card) + "</a><br>")
  320. tfile.write("</html>")
  321. webbrowser.open("file://" + TMPFILE)
  322.  
  323.  
  324. # Actual card lookup
  325.  
  326. def get_cards(member_id, number, filters):
  327. global CACHEDIR
  328. force_cache_refresh = False
  329.  
  330. while True:
  331. # get data from cache or API
  332. cfilename = os.path.join(CACHEDIR, str(member_id))
  333. if not os.path.isfile(cfilename) or force_cache_refresh:
  334. with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json" + use_translated_names) as url:
  335. with open(cfilename, "w") as jfile:
  336. jfile.write(url.read().decode())
  337.  
  338. with open(cfilename, "r") as jfile:
  339. data = json.loads(jfile.read())["result"]
  340. cards = reversed(data)
  341. for f in filters:
  342. cards = filter(f, cards)
  343. cards = [x for x in cards]
  344. result_count = len(cards)
  345.  
  346. if number != None:
  347. cards = cards[number-1:number]
  348.  
  349. if len(cards) == 0:
  350. if not force_cache_refresh:
  351. # No matching card - reload cache and try again to see whether it's a new card
  352. force_cache_refresh = True
  353. continue
  354. else:
  355. # No matching card, already tried reloading cache - this card does not exist
  356. if result_count == 0:
  357. raise CardLookupError("No matching cards for this request")
  358. else:
  359. raise CardLookupError("Result #" + str(number) + " was requested, but there's only " + str(result_count) + " matching card(s) for this request")
  360. return cards
  361.  
  362.  
  363. # Download all API data - if the parameter is set to true, all caches will be reloaded, whether they exist or not
  364.  
  365. def download_all_data(reload_all):
  366. for member_id in member_ids:
  367. cfilename = os.path.join(CACHEDIR, str(member_id))
  368. if reload_all or not os.path.isfile(cfilename):
  369. with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json" + use_translated_names) as url:
  370. with open(cfilename, "w") as jfile:
  371. jfile.write(url.read().decode())
  372.  
  373.  
  374.  
  375. # If run directly instead of being imported as a module, take command line arguments as input
  376.  
  377. if __name__ == '__main__':
  378. download_all = False
  379. if not os.path.isdir(CACHEDIR):
  380. # First launch! - download all member lists for initial caching
  381. # But only after getting the requested card... response time is important
  382. os.makedirs(CACHEDIR)
  383. download_all = True
  384.  
  385. try:
  386. do_lookup(sys.argv[1:])
  387. except (RequestError,CardLookupError) as e:
  388. if sys.stdin.isatty() and not debug_in_terminal:
  389. print("ERROR:", str(e), file=sys.stderr)
  390. else:
  391. with open(TMPFILE, "w") as tfile:
  392. tfile.write("<html>==========<br>ERROR<br>==========<br><br>")
  393. tfile.write(str(e))
  394. tfile.write("</html>")
  395. webbrowser.open("file://" + TMPFILE)
  396.  
  397. if download_all == True:
  398. print()
  399. print("Downloading all card lists and caching them for future use...")
  400. 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:
tools/card-lookup.txt · Last modified: 2022/01/13 22:25 by Suyooo