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.

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.

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

Version History

  • 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.1613585022.txt.gz · Last modified: 2021/02/17 18:03 by Suyooo