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

Version History

  • 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.1613487543.txt.gz · Last modified: 2021/02/16 14:59 by Suyooo