#!/usr/bin/env python3
import json, os, re, stat, sys, time, urllib.request, webbrowser
###
# FEEL FREE TO TOUCH THIS
###
CACHEDIR = os.path.expanduser("~/.cache/sifas-lookup")
TMPFILE = "/tmp/sifas-lookup-response"
use_translated_names = True # if available
namemap = {
1: ["honoka", "honk"],
2: ["eli"],
3: ["kotori", "koto", "bird"],
4: ["umi"],
5: ["rin"],
6: ["maki"],
7: ["nozomi", "nozo"],
8: ["hanayo", "pana"],
9: ["nico"],
101: ["chika"],
102: ["riko"],
103: ["kanan", "hagu"],
104: ["dia"],
105: ["you"],
106: ["yoshiko", "yohane", "yoha"],
107: ["hanamaru", "maru", "zuramaru", "zura"],
108: ["mari"],
109: ["ruby"],
201: ["ayumu", "pomu"],
202: ["kasumi", "kasumin", "kasu", "kasukasu"],
203: ["shizuku", "shizu"],
204: ["karin"],
205: ["ai"],
206: ["kanata"],
207: ["setsuna", "setsu", "nana"],
208: ["emma"],
209: ["rina"],
210: ["shioriko", "shio"],
211: ["mia"],
212: ["lanzhu"]
}
###
# ENTERING "PROBABLY DON'T WANT TO TOUCH" ZONE
###
debug_in_terminal = False
use_translated_names = "?with_accept_language=1" if use_translated_names else ""
member_ids = namemap.keys()
namemap = {n:i for (i,nl) in namemap.items() for n in nl}
number_regex = re.compile("^([A-Za-z]+) ?(\d+)$")
class RequestError(Exception):
pass
class CardLookupError(Exception):
pass
# Functions to convert string to IDs
def short_attr_to_id(short_attr):
if short_attr == "s": return 1
if short_attr == "p": return 2
if short_attr == "c": return 3
if short_attr == "a": return 4
if short_attr == "n": return 5
if short_attr == "e": return 6
raise RequestError("Unknown attribute name: " + str(short_attr))
def role_to_id(role):
if role == "vo": return 1
if role == "sp": return 2
if role == "gd": return 3
if role == "sk": return 4
raise RequestError("Unknown role name: " + str(role))
# Functions for user-readable strings
def card_summary(card):
if card["sp_point"] == 4:
if len(card["passive_skills"][0]["levels"]) == 5: rarity = "Fes"
elif len(card["passive_skills"][0]["levels"]) == 7: rarity = "Party"
elif card["rarity"] == 10: rarity = "R"
elif card["rarity"] == 20: rarity = "SR"
elif card["rarity"] == 30: rarity = "UR"
if card["attribute"] == 1: attr = "Smile"
elif card["attribute"] == 2: attr = "Pure"
elif card["attribute"] == 3: attr = "Cool"
elif card["attribute"] == 4: attr = "Active"
elif card["attribute"] == 5: attr = "Natural"
elif card["attribute"] == 6: attr = "Elegant"
if card["role"] == 1: role = "Vo"
elif card["role"] == 2: role = "Sp"
elif card["role"] == 3: role = "Gd"
elif card["role"] == 4: role = "Sk"
return "#" + str(card["ordinal"]) + " " + rarity + " " + attr + " " + role + " (" + card["normal_appearance"]["name"] + " / " + card["idolized_appearance"]["name"] + ")"
def card_url(card):
return "https://allstars.kirara.ca/card/" + str(card["ordinal"])
# Functions to find matching cards in an API response
def make_property_equals_filter(prop, value):
def filter(c):
return c[prop] == value
return filter
def make_rarity_filter(rarity):
return make_property_equals_filter("rarity", rarity)
def make_attribute_filter(attribute):
return make_property_equals_filter("attribute", attribute)
def make_role_filter(role):
return make_property_equals_filter("role", role)
def make_is_fes_filter():
def filter(c):
return c["sp_point"] == 4 and len(c["passive_skills"][0]["levels"]) == 5
return filter
def make_is_party_filter():
def filter(c):
return c["sp_point"] == 4 and len(c["passive_skills"][0]["levels"]) == 7
return filter
# Dealing with user input
def do_lookup(request):
global namemap, number_regex
membername = None
number = None
filters = []
filtered_rarity = False
filtered_attribute = False
filtered_role = False
filtered_number = False
# Parse arguments
def parse_keywords(k):
nonlocal filtered_rarity, filtered_attribute, filtered_role, filtered_number, filters, number
if k.isnumeric():
if filtered_number: raise RequestError("Multiple Number filters specified")
filtered_number = True
number = int(k)
return False
k = k.lower()
if k == "r":
if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
filtered_rarity = True
filters.append(make_rarity_filter(10))
return False
if k == "sr":
if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
filtered_rarity = True
filters.append(make_rarity_filter(20))
return False
if k == "ur":
if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
filtered_rarity = True
filters.append(make_rarity_filter(30))
return False
if k == "fes":
if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
filtered_rarity = True
filters.append(make_is_fes_filter())
return False
if k == "party":
if filtered_rarity: raise RequestError("Multiple Rarity filters specified")
filtered_rarity = True
filters.append(make_is_party_filter())
return False
if k == "vo":
if filtered_role: raise RequestError("Multiple Role filters specified")
filtered_role = True
filters.append(make_role_filter(1))
return False
if k == "sp":
if filtered_role: raise RequestError("Multiple Role filters specified")
filtered_role = True
filters.append(make_role_filter(2))
return False
if k == "gd":
if filtered_role: raise RequestError("Multiple Role filters specified")
filtered_role = True
filters.append(make_role_filter(3))
return False
if k == "sk":
if filtered_role: raise RequestError("Multiple Role filters specified")
filtered_role = True
filters.append(make_role_filter(4))
return False
if k == "s" or k == "smile":
if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
filtered_attribute = True
filters.append(make_attribute_filter(1))
return False
if k == "p" or k == "pure":
if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
filtered_attribute = True
filters.append(make_attribute_filter(2))
return False
if k == "c" or k == "cool":
if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
filtered_attribute = True
filters.append(make_attribute_filter(3))
return False
if k == "a" or k == "active":
if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
filtered_attribute = True
filters.append(make_attribute_filter(4))
return False
if k == "n" or k == "natural":
if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
filtered_attribute = True
filters.append(make_attribute_filter(5))
return False
if k == "e" or k == "elegant":
if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
filtered_attribute = True
filters.append(make_attribute_filter(6))
return False
# Test whether it's a combined short attribute + type keyword
if len(k) == 3:
combined_keyword = False
try:
r = role_to_id(k[-2:])
a = short_attr_to_id(k[0])
combined_keyword = True
except RequestError:
pass
if combined_keyword:
if filtered_role: raise RequestError("Multiple Role filters specified")
if filtered_attribute: raise RequestError("Multiple Attribute filters specified")
filtered_role = True
filters.append(make_role_filter(r))
filtered_attribute = True
filters.append(make_attribute_filter(a))
return False
return True
leftover_keywords = [x for x in filter(parse_keywords, request)]
# There should be only the character's name left now. Might be a numbered reference?
if len(leftover_keywords) == 0:
raise RequestError("No character name defined")
if len(leftover_keywords) > 1:
leftover_keywords = [x for x in leftover_keywords if x not in namemap]
raise RequestError("Unrecognized keywords: " + ", ".join(leftover_keywords))
m = number_regex.match(leftover_keywords[0])
if m != None:
if filtered_number: raise RequestError("Multiple Number filters specified")
filtered_number = True
membername = m.group(1).lower()
number = int(m.group(2))
else:
membername = leftover_keywords[0].lower()
if not filtered_rarity:
filters.insert(0, make_rarity_filter(30))
# get member ID from requested name
if membername not in namemap:
try:
from Levenshtein import jaro_winkler
bestdist = 0
bestname = None
for name in namemap.keys():
dist = jaro_winkler(membername, name)
if dist > bestdist:
bestdist = dist
bestname = name
if bestdist < 0.9: # minimum similarity to meet
raise RequestError("Unknown idol name: " + membername + " (best match: " + bestname + " with similarity " + str(bestdist) + ")")
membername = bestname
except ImportError:
raise RequestError("Unknown idol name: " + membername + " (Misspelled? Try installing the Levenshtein module to allow this script to correct small typos. pip3 install python-Levenshtein)")
member_id = namemap[membername]
try:
cards = get_cards(member_id, number, filters)
except CardLookupError as e:
raise CardLookupError(str(e) + ": " + " ".join(request))
if len(cards) == 1:
if sys.stdin.isatty() and not debug_in_terminal:
print(card_summary(cards[0]))
print(" " + card_url(cards[0]))
else:
webbrowser.open(card_url(cards[0]))
else:
if sys.stdin.isatty() and not debug_in_terminal:
print("Multiple cards were found:")
print()
for card in cards:
print(card_summary(card))
print(" " + card_url(card))
else:
with open(TMPFILE, "w") as tfile:
tfile.write("==========
MULTIPLE RESULTS
==========
")
for card in cards:
tfile.write("" + card_summary(card) + "
")
tfile.write("")
webbrowser.open("file://" + TMPFILE)
# Actual card lookup
def get_cards(member_id, number, filters):
global CACHEDIR
force_cache_refresh = False
while True:
# get data from cache or API
cfilename = os.path.join(CACHEDIR, str(member_id))
if not os.path.isfile(cfilename) or force_cache_refresh:
with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json" + use_translated_names) as url:
with open(cfilename, "w") as jfile:
jfile.write(url.read().decode())
with open(cfilename, "r") as jfile:
data = json.loads(jfile.read())["result"]
cards = reversed(data)
for f in filters:
cards = filter(f, cards)
cards = [x for x in cards]
result_count = len(cards)
if number != None:
cards = cards[number-1:number]
if len(cards) == 0:
if not force_cache_refresh:
# No matching card - reload cache and try again to see whether it's a new card
force_cache_refresh = True
continue
else:
# No matching card, already tried reloading cache - this card does not exist
if result_count == 0:
raise CardLookupError("No matching cards for this request")
else:
raise CardLookupError("Result #" + str(number) + " was requested, but there's only " + str(result_count) + " matching card(s) for this request")
return cards
# Download all API data - if the parameter is set to true, all caches will be reloaded, whether they exist or not
def download_all_data(reload_all):
for member_id in member_ids:
cfilename = os.path.join(CACHEDIR, str(member_id))
if reload_all or not os.path.isfile(cfilename):
with urllib.request.urlopen("https://allstars.kirara.ca/api/private/cards/member/" + str(member_id) + ".json" + use_translated_names) as url:
with open(cfilename, "w") as jfile:
jfile.write(url.read().decode())
# If run directly instead of being imported as a module, take command line arguments as input
if __name__ == '__main__':
download_all = False
if not os.path.isdir(CACHEDIR):
# First launch! - download all member lists for initial caching
# But only after getting the requested card... response time is important
os.makedirs(CACHEDIR)
download_all = True
try:
do_lookup(sys.argv[1:])
except (RequestError,CardLookupError) as e:
if sys.stdin.isatty() and not debug_in_terminal:
print("ERROR:", str(e), file=sys.stderr)
else:
with open(TMPFILE, "w") as tfile:
tfile.write("==========
ERROR
==========
")
tfile.write(str(e))
tfile.write("")
webbrowser.open("file://" + TMPFILE)
if download_all == True:
print()
print("Downloading all card lists and caching them for future use...")
download_all_data(False)