Last active
November 7, 2023 08:42
-
-
Save jjangsangy/b0600f5fecf828828eb321ff10cfa4b0 to your computer and use it in GitHub Desktop.
Create Yu-Gi-Oh Anki deck from YDK file
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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