Skip to content

Instantly share code, notes, and snippets.

@jjangsangy
Last active November 7, 2023 08:42
Show Gist options
  • Save jjangsangy/b0600f5fecf828828eb321ff10cfa4b0 to your computer and use it in GitHub Desktop.
Save jjangsangy/b0600f5fecf828828eb321ff10cfa4b0 to your computer and use it in GitHub Desktop.
Create Yu-Gi-Oh Anki deck from YDK file
#!/usr/bin/env python3
"""
Yu-Gi-Oh Anki Deck Tool
# Usage
## Create Deck
python3 ygo_anki_deck.py create --name "Deck Name" deck.ydk
# Requirements
* AnkiConnect plugin: https://ankiweb.net/shared/info/2055492159
* Yu-Gi-Oh Note Type (see below)
# Note Type
* By default, the script will use note type named "Yu-Gi-Oh Cards".
This can be changed by adding the --note-type argument.
* Anki note type must have the following fields:
1. Name
2. Type
3. ATK/DEF
4. Description
5. Art
## Note Front Template
<div class="front">
<b>{{Name}}</b><br>
<small>[{{Type}}]</small><br>
<small>{{ATK/DEF}}</small><br><br>
{{Art}}
</div>
## Note Back Template
{{FrontSide}}
<hr id=answer>
{{Description}}
## Note Styling
.card {
font-family: arial;
font-size: 20px;
text-align: left;
color: black;
background-color: white;
}
.front {
text-align: center;
}
img {
width: 300px;
}
"""
import json
import argparse
import io
import inspect
from urllib.request import Request, urlopen
from urllib.parse import urlencode
from urllib.error import URLError
from typing import Set, Iterable, List, Optional
class YGOCard:
"""
Encapsulates a yugioh card info scraped from api
Parameters
----------
card : dict
Card data from ygoprodeck API
Attributes
----------
card : dict
Card data from ygoprodeck API
name : str
Name of card
type : str
Type of card
attribute : str
Attribute of card
level : int
Level of card
desc : str
Card description text
image_url : str
URL to card image cropped
Methods
-------
is_type(type_name: str) -> bool
Check if card is of type
Properties
----------
tags : List[str]
List of tags for card
has_effect : Optional[bool]
Whether card has effect
is_monster : bool
Whether card is a monster
is_pendulum : bool
Whether card is a pendulum
full_type : str
Full type string of card
atk_def : str
ATK/DEF of card
"""
def __init__(self, card: dict) -> None:
self.card = card
self.name = card["name"]
self.type = card["frameType"]
self.attribute = card.get("attribute", "").lower()
self.level = card.get("level")
self.desc = card["desc"].replace("\r\n", "\n")
self.image_url = card["card_images"][0]["image_url_cropped"]
def __repr__(self) -> str:
return f"<{self.name}[{self.full_type}]>"
def is_type(self, type_name: str) -> bool:
return type_name.capitalize() in self.card["type"]
@property
def tags(self) -> List[str]:
t = [self.type.capitalize()]
if "archetype" in self.card:
t.append(self.card["archetype"].replace(" ", ""))
return t
@property
def has_effect(self) -> Optional[bool]:
if self.is_type("Normal"):
return False
if self.is_type("Effect"):
return True
info = self.card.get("misc_info")
if not info:
return None
return any([i.get("has_effect") == 1 for i in info])
@property
def is_monster(self) -> bool:
return not self.type in ["spell", "trap", "skill"]
@property
def is_pendulum(self) -> bool:
return "pendulum" in self.type
@property
def full_type(self) -> str:
"""
Return full type of card
"""
if not self.is_monster:
return f"{self.card['race']} {self.type.capitalize()}"
t = [self.card["race"]]
if self.is_pendulum:
pendulum_type = self.type.split("_")[0]
if pendulum_type not in ["normal", "effect"]:
t.append(pendulum_type.capitalize())
t.append("Pendulum")
else:
# effect should go at the end of card
if not self.type == "effect":
t.append(self.type.capitalize())
for types in ["Tuner", "Gemini", "Spirit", "Toon", "Union", "Token", "Skill"]:
if self.is_type(types):
t.append(types)
if self.has_effect and "Effect" not in t:
t.append("Effect")
return "/".join(t)
@property
def atk_def(self) -> str:
if not self.is_monster:
return ""
if self.is_type("Link"):
return f'{self.card["atk"]}/{self.card["linkval"]}'
else:
return f'{self.card["atk"]}/{self.card["def"]}'
class AnkiAPIException(Exception):
"""
Exception for Anki API errors
Parameters
----------
message : str
Error message
"""
def invoke(action: str, **params) -> dict:
"""
Invoke Anki API
Parameters
----------
action : str
Anki API action to invoke
params : dict
Anki API parameters
See: https://foosoft.net/projects/anki-connect/
Returns
-------
dict
Anki API response
"""
body = json.dumps({"action": action, "params": params, "version": 6}).encode(
"utf-8"
)
try:
response = json.load(urlopen(Request("http://localhost:8765", body)))
except URLError as e:
raise AnkiAPIException("AnkiConnect is not running") from e
if len(response) != 2:
raise AnkiAPIException("response has an unexpected number of fields")
if "error" not in response:
raise AnkiAPIException("response is missing required error field")
if "result" not in response:
raise AnkiAPIException("response is missing required result field")
if response["error"] is not None:
raise AnkiAPIException(response["error"])
return response["result"]
def fetch_cards(*ids: str, query_type: str = "id") -> dict:
"""
Fetch cards from ygoprodeck API
Parameters
----------
ids: str
Card ids
query_type: str
Whether to use `name` or `id` to search api
Returns
-------
dict
Card data from ygoprodeck API
"""
# encode querystring
if query_type == "id":
params = urlencode({"id": ",".join(ids), "misc": "Yes"})
elif query_type == "name":
params = urlencode({"name": "|".join(ids), "misc": "Yes"})
else:
raise ValueError(
f"`query_type` should be one of {{id, name}}, got {query_type}"
)
# make request
url = f"https://db.ygoprodeck.com/api/v7/cardinfo.php?{params}"
headers = {"User-Agent": "YGO: Anki Card Generator"}
data = json.loads(urlopen(Request(url, headers=headers)).read())
# error handling
if "error" in data:
raise ValueError("An invalid card [name|id] was provided.")
if len(data["data"]) != len(ids):
lost_ids = set([i[query_type] for i in data["data"]]) - set(ids)
raise ValueError(f"Some cards did not exist. {lost_ids}")
return data["data"]
def load_ydk_file(ydk_file: io.TextIOBase) -> Set[str]:
"""
Load ydk file and return set of card ids
Parameters
----------
ydk_file : argparse.FileType
ydk file to load
Returns
-------
set[str]
set of card ids
"""
lines = ydk_file.readlines()
return set(
[
line.strip()
for line in lines
if not (line.startswith("#") or line.startswith("!")) and line.strip()
]
)
def add_notes(name: str, note_type: str, cards: Iterable[dict]) -> None:
"""
Add notes to deck
Parameters
----------
name: str
Name of deck to add notes to
note_type: str
The note type to apply
cards: Iterable[dict]
An iterable of cards
"""
for card in cards:
params = create_card_params(name, note_type, card)
try:
invoke("addNote", **params)
print(f'Created Card: {card["name"]}')
except AnkiAPIException as e:
print(f"{e}: {card['name']}")
def create_card_params(deck_name: str, note_type: str, card: dict) -> dict:
"""
Create card parameters for Anki API
Parameters
----------
deck_name : str
Name of deck to create card in
card : dict
Card data from ygoprodeck API
Returns
-------
dict
Card parameters for Anki API
"""
c = YGOCard(card)
return {
"note": {
"deckName": deck_name,
"modelName": note_type,
"fields": {
"Name": c.name,
"Type": c.full_type,
"ATK/DEF": c.atk_def,
"Description": c.desc,
},
"tags": c.tags,
"picture": [
{
"url": c.image_url,
"filename": c.image_url.split("/")[-1],
"fields": ["Art"],
}
],
},
}
def cli() -> argparse.Namespace:
"""
Parse command line arguments
"""
parser = argparse.ArgumentParser(description="Yu-Gi-Oh Anki Deck Tool")
subparser = parser.add_subparsers(required=True)
# clear unused tags
clear_parser = subparser.add_parser(
"clear-unused-tags", help="Clear tags which have no references"
)
clear_parser.set_defaults(func=clear_unused_tags)
# create template
template_parser = subparser.add_parser(
"create-note-type", help="Create note type template"
)
template_parser.add_argument(
"--name",
"-n",
type=str,
default="Yu-Gi-Oh Cards",
help="Name of note type to create",
)
template_parser.add_argument(
"--overwrite",
"-o",
action="store_true",
help="Overwrite existing note type",
)
template_parser.set_defaults(func=create_template)
# create anki deck
create_parser = subparser.add_parser(
"create-deck", help="Create Anki Deck from YDK File"
)
create_parser.add_argument(
"--name", "-n", type=str, required=True, help="Name of deck to create"
)
create_parser.add_argument(
"--note-type",
"-t",
type=str,
default="Yu-Gi-Oh Cards",
help="Name of note type to use",
)
create_parser.add_argument(
"--query-type",
"-q",
type=str,
default="id",
choices=["id", "name"],
help="Whether to use `name` or `id` to search api",
)
create_parser.add_argument(
"ydk_file",
type=argparse.FileType("r", encoding="utf8"),
help="Path to ydk file",
)
create_parser.set_defaults(func=create_deck)
return parser.parse_args()
def clear_unused_tags(args: argparse.Namespace) -> int:
"""
Clear tags which have no references
Parameters
----------
args : argparse.Namespace
Command line arguments
Returns
-------
int
Exit code
"""
invoke("clearUnusedTags")
return 0
def create_template(args: argparse.Namespace) -> None:
"""
Create note type template
Parameters
----------
args : argparse.Namespace
Command line arguments
"""
fields = ["Name", "Type", "ATK/DEF", "Art", "Description"]
back_template = inspect.cleandoc(
"""
{{FrontSide}}
<hr id=answer>
{{Description}}
"""
)
front_template = inspect.cleandoc(
"""
<div class="front">
<b>{{Name}}</b><br>
<small>[{{Type}}]</small><br>
<small>{{ATK/DEF}}</small><br><br>
{{Art}}
</div>
"""
)
css = inspect.cleandoc(
"""
.card {
font-family: arial;
font-size: 20px;
text-align: left;
color: black;
background-color: white;
}
.front {
text-align: center;
}
img {
width: 300px;
}
"""
)
model_names = invoke("modelNames")
if args.name in model_names:
# overwrite existing note type
if args.force:
invoke(
"updateModelTemplates",
model={
"name": args.name,
"templates": {
"Card 1": {
"Front": front_template,
"Back": back_template,
}
},
},
)
invoke(
"updateModelStyling",
model={
"name": args.name,
"css": css,
},
)
else:
# note type already exists
raise AnkiAPIException(
f"Note Type: {args.name} already exists, use --overwrite to force"
)
else:
# create new note type
params = {
"modelName": args.name,
"inOrderFields": fields,
"css": css,
"isCloze": False,
"cardTemplates": [
{"Name": "Card 1", "Front": front_template, "Back": back_template}
],
}
invoke("createModel", **params)
def create_deck(args: argparse.Namespace) -> int:
"""
Create Anki Deck from YDK File
Parameters
----------
args : argparse.Namespace
Command line arguments
Returns
-------
int
Exit code
"""
invoke("createDeck", deck=args.name)
ydk_ids = load_ydk_file(args.ydk_file)
cards = fetch_cards(*ydk_ids, query_type=args.query_type)
add_notes(args.name, args.note_type, cards)
return 0
def main() -> int:
"""
Main function
Returns
-------
int
Exit code
"""
args = cli()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment