import logging from math import sqrt, cbrt from enum import Enum from typing import NamedTuple from urllib.parse import quote from datetime import datetime, timedelta from time import sleep from core.config import WIKIRANK_USER_AGENT HEADERS = {"User-Agent": WIKIRANK_USER_AGENT} logger = logging.getLogger("app") class CardType(Enum): other = 0 person = 1 location = 2 artwork = 3 life_form = 4 event = 5 group = 6 science_thing = 7 vehicle = 8 organization = 9 class CardRarity(Enum): common = 0 uncommon = 1 rare = 2 super_rare = 3 epic = 4 legendary = 5 class Card(NamedTuple): name: str generated_at: datetime image_link: str card_rarity: CardRarity card_type: CardType wikidata_instance: str text: str attack: int defense: int cost: int def __str__(self) -> str: return_string = "┏"+"━"*50+"┓\n" return_string += "┃"+f"{self.name:{' '}<50}"+"┃\n" return_string += "┠"+"─"*50+"┨\n" card_type = "Type: "+self.card_type.name if self.card_type == CardType.other: card_type += f" ({self.wikidata_instance})" return_string += "┃"+f"{card_type:{' '}<50}"+"┃\n" return_string += "┠"+"─"*50+"┨\n" rarity_text = f"Rarity: {self.card_rarity.name}" return_string += "┃"+f"{rarity_text:<50}"+"┃\n" return_string += "┠"+"─"*50+"┨\n" link = "Image: "+self.image_link return_string += "┃"+f"{link:{' '}<50}"+"┃\n" return_string += "┠"+"─"*50+"┨\n" return_string += "┃"+f"{'*'*self.cost:{' '}<50}"+"┃\n" return_string += "┠"+"─"*50+"┨\n" lines = [] words = self.text.split(" ") current_line = "" for w in words: if len(current_line + " " + w) < 50: current_line += " " + w elif len(current_line) < 40 and len(w) > 10: new_len = 48 - len(current_line) current_line += " " + w[:new_len] + "-" lines.append(current_line) current_line = w[new_len:] else: lines.append(current_line) current_line = w lines.append(current_line) for l in lines : return_string += "┃"+f"{l:{' '}<50}"+"┃\n" return_string += "┠"+"─"*50+"┨\n" date_text = str(self.generated_at.date()) stats = f"{self.attack}/{self.defense}" spaces = 50 - (len(date_text) + len(stats)) return_string += "┃"+date_text + " "*spaces + stats + "┃\n" return_string += "┗"+"━"*50+"┛" return return_string WIKIDATA_INSTANCE_TYPE_MAP = { "Q5": CardType.person, # human "Q95074": CardType.person, # character "Q215627": CardType.person, # person "Q15632617": CardType.person, # fictional human "Q22988604": CardType.person, # fictional human "Q1004": CardType.artwork, # comics "Q7889": CardType.artwork, # video game "Q11424": CardType.artwork, # film "Q11410": CardType.artwork, # game "Q15416": CardType.artwork, # television program "Q24862": CardType.artwork, # short film "Q11032": CardType.artwork, # newspaper "Q25379": CardType.artwork, # play "Q41298": CardType.artwork, # magazine "Q482994": CardType.artwork, # album "Q134556": CardType.artwork, # single "Q169930": CardType.artwork, # EP "Q196600": CardType.artwork, # media franchise "Q202866": CardType.artwork, # animated film "Q277759": CardType.artwork, # book series "Q734698": CardType.artwork, # collectible card game "Q506240": CardType.artwork, # television film "Q738377": CardType.artwork, # student newspaper "Q2031291": CardType.artwork, # musical release "Q1259759": CardType.artwork, # miniseries "Q3305213": CardType.artwork, # painting "Q3177859": CardType.artwork, # dedicated deck card game "Q5398426": CardType.artwork, # television series "Q7725634": CardType.artwork, # literary work "Q1761818": CardType.artwork, # advertising campaign "Q1446621": CardType.artwork, # recital "Q1868552": CardType.artwork, # local newspaper "Q3244175": CardType.artwork, # tabletop game "Q2031291": CardType.artwork, # musical release "Q63952888": CardType.artwork, # anime television series "Q47461344": CardType.artwork, # written work "Q71631512": CardType.artwork, # tabletop role-playing game supplement "Q21198342": CardType.artwork, # manga series "Q58483083": CardType.artwork, # dramatico-musical work "Q24634210": CardType.artwork, # podcast show "Q105543609": CardType.artwork, # musical work / composition "Q106499608": CardType.artwork, # literary reading "Q117467246": CardType.artwork, # animated television series "Q106042566": CardType.artwork, # single album "Q515": CardType.location, # city "Q8502": CardType.location, # mountain "Q4022": CardType.location, # river "Q6256": CardType.location, # country "Q15284": CardType.location, # municipality "Q27686": CardType.location, # hotel "Q41176": CardType.location, # building "Q23442": CardType.location, # island "Q82794": CardType.location, # geographic region "Q34442": CardType.location, # road "Q486972": CardType.location, # human settlement "Q192611": CardType.location, # electoral unit "Q398141": CardType.location, # school district "Q133056": CardType.location, # mountain pass "Q3624078": CardType.location, # sovereign state "Q1093829": CardType.location, # city in the United States "Q7930989": CardType.location, # city/town "Q1250464": CardType.location, # realm "Q3146899": CardType.location, # diocese of the catholic church "Q17350442": CardType.location, # venue "Q23764314": CardType.location, # sports location "Q12076836": CardType.location, # administrative territorial entity of a single country "Q35145263": CardType.location, # natural geographic object "Q15642541": CardType.location, # human-geographic territorial entity "Q16521": CardType.life_form, # taxon "Q38829": CardType.life_form, # breed "Q310890": CardType.life_form, # monotypic taxon "Q23038290": CardType.life_form, # fossil taxon "Q12045585": CardType.life_form, # cattle breed "Q198": CardType.event, # war "Q8465": CardType.event, # civil war "Q844482": CardType.event, # killing "Q141022": CardType.event, # eclipse "Q103495": CardType.event, # world war "Q350604": CardType.event, # armed conflict "Q217327": CardType.event, # suicide attack "Q750215": CardType.event, # mass murder "Q898712": CardType.event, # aircraft hijacking "Q997267": CardType.event, # skirmish "Q178561": CardType.event, # battle "Q273120": CardType.event, # protest "Q1190554": CardType.event, # occurrence "Q1361229": CardType.event, # conquest "Q2223653": CardType.event, # terrorist attack "Q2672648": CardType.event, # social conflict "Q2627975": CardType.event, # ceremony" "Q16510064": CardType.event, # sporting event "Q10688145": CardType.event, # season "Q13418847": CardType.event, # historical event "Q13406554": CardType.event, # sports competition "Q15275719": CardType.event, # recurring event "Q27968055": CardType.event, # recurring event edition "Q15091377": CardType.event, # cycling race "Q87267404": CardType.event, # formula race "Q114609228": CardType.event, # recurring sporting event "Q7278": CardType.group, # political party "Q476028": CardType.group, # association football club "Q732717": CardType.group, # law enforcement agency "Q215380": CardType.group, # musical group "Q176799": CardType.group, # military unit "Q178790": CardType.group, # labor union "Q851990": CardType.group, # people group "Q2367225": CardType.group, # university and college sports club "Q4801149": CardType.group, # artillery brigade "Q9248092": CardType.group, # infantry division "Q7210356": CardType.group, # political organization "Q5419137": CardType.group, # veterans' organization "Q6979593": CardType.group, # national association football team "Q1194951": CardType.group, # national sports team "Q1539532": CardType.group, # sports season of a sports club "Q13393265": CardType.group, # basketball team "Q17148672": CardType.group, # social club "Q12973014": CardType.group, # sports team "Q11446438": CardType.group, # female idol group "Q10517054": CardType.group, # handball team "Q135408445": CardType.group, # men's national association football team "Q120143756": CardType.group, # division "Q134601727": CardType.group, # group of persons "Q127334927": CardType.group, # band "Q523": CardType.science_thing, # star "Q318": CardType.science_thing, # galaxy "Q6999": CardType.science_thing, # astronomical object "Q7187": CardType.science_thing, # gene "Q8054": CardType.science_thing, # protein "Q12136": CardType.science_thing, # disease "Q65943": CardType.science_thing, # theorem "Q12140": CardType.science_thing, # medication "Q11276": CardType.science_thing, # globular cluster "Q83373": CardType.science_thing, # quasar "Q177719": CardType.science_thing, # medical diagnosis "Q898273": CardType.science_thing, # protein domain "Q134808": CardType.science_thing, # vaccine "Q168845": CardType.science_thing, # star cluster "Q1491746": CardType.science_thing, # galaxy group "Q2465832": CardType.science_thing, # branch of science "Q1341811": CardType.science_thing, # astronomical maser "Q1840368": CardType.science_thing, # cloud type "Q2154519": CardType.science_thing, # astrophysical x-ray source "Q3132741": CardType.science_thing, # substellar object "Q15636229": CardType.science_thing, # surgery procedure "Q11862829": CardType.science_thing, # academic discipline "Q78088984": CardType.science_thing, # study type "Q17444909": CardType.science_thing, # astronomical object type "Q24034552": CardType.science_thing, # mathematical concept "Q12089225": CardType.science_thing, # mineral species "Q55640599": CardType.science_thing, # group of chemical entities "Q17339814": CardType.science_thing, # group or class of chemical substances "Q119459661": CardType.science_thing, # scientific activity "Q113145171": CardType.science_thing, # type of chemical entity "Q1420": CardType.vehicle, # car "Q42889": CardType.vehicle, # vehicle "Q11446": CardType.vehicle, # ship "Q43193": CardType.vehicle, # truck "Q25956": CardType.vehicle, # space station "Q39804": CardType.vehicle, # cruise ship "Q170483": CardType.vehicle, # sailing ship "Q964162": CardType.vehicle, # express train "Q848944": CardType.vehicle, # merchant vessel "Q189418": CardType.vehicle, # brigantine "Q281019": CardType.vehicle, # ghost ship "Q811704": CardType.vehicle, # rolling stock class "Q673687": CardType.vehicle, # racing automobile "Q174736": CardType.vehicle, # destroyer "Q484000": CardType.vehicle, # unmanned aerial vehicle "Q559026": CardType.vehicle, # ship class "Q830335": CardType.vehicle, # protected cruiser "Q928235": CardType.vehicle, # sloop-of-war "Q391022": CardType.vehicle, # research vessel "Q202527": CardType.vehicle, # minesweeper "Q1229765": CardType.vehicle, # watercraft "Q2031121": CardType.vehicle, # warship "Q1185562": CardType.vehicle, # light aircraft carrier "Q7233751": CardType.vehicle, # post ship "Q3231690": CardType.vehicle, # automobile model "Q1428357": CardType.vehicle, # submarine class "Q1499623": CardType.vehicle, # destroyer escort "Q4818021": CardType.vehicle, # attack submarine "Q45296117": CardType.vehicle, # aircraft type "Q15141321": CardType.vehicle, # train service "Q19832486": CardType.vehicle, # locomotive class "Q23866334": CardType.vehicle, # motorcycle model "Q29048322": CardType.vehicle, # vehicle model "Q137188246": CardType.vehicle, # combat vehicle model "Q100709275": CardType.vehicle, # combat vehicle family "Q43229": CardType.organization, # organization "Q47913": CardType.organization, # intelligence agency "Q35535": CardType.organization, # police "Q740752": CardType.organization, # transport company "Q4830453": CardType.organization, # business "Q4671277": CardType.organization, # academic institution "Q2659904": CardType.organization, # government organization "Q686822": CardType.other, # bill (written work) } import asyncio import httpx async def _get_random_summary_async(client: httpx.AsyncClient) -> dict: try: response = await client.get( "https://en.wikipedia.org/api/rest_v1/page/random/summary", headers=HEADERS, follow_redirects=False, ) if response.status_code in (301, 302, 303, 307, 308): # Extract the title from the redirect location and re-encode it location = response.headers["location"] title = location.split("/page/summary/")[-1] response = await client.get( f"https://en.wikipedia.org/api/rest_v1/page/summary/{quote(title, safe='%')}", headers=HEADERS, follow_redirects=False, ) except: return {} if not response.is_success: logger.error( "Error in request:" + str(response.status_code) + response.text ) return {} return response.json() async def _get_page_summary_async(client: httpx.AsyncClient, title: str) -> dict: try: response = await client.get( f"https://en.wikipedia.org/api/rest_v1/page/summary/{title}", headers=HEADERS, follow_redirects=False, ) if response.status_code in (301, 302, 303, 307, 308): # Extract the title from the redirect location and re-encode it location = response.headers["location"] title = location.split("/page/summary/")[-1] response = await client.get( f"https://en.wikipedia.org/api/rest_v1/page/summary/{quote(title, safe='%')}", headers=HEADERS, follow_redirects=False, ) except: return {} if not response.is_success: logger.error( "Error in request:" + str(response.status_code) + response.text ) return {} return response.json() async def _get_superclasses(client: httpx.AsyncClient, qid: str) -> list[str]: try: response = await client.get( "https://www.wikidata.org/wiki/Special:EntityData/" + qid + ".json", headers=HEADERS ) except: return [] if not response.is_success: return [] entity = response.json().get("entities", {}).get(qid, {}) subclass_claims = entity.get("claims", {}).get("P279", []) return [ c.get("mainsnak", {}).get("datavalue", {}).get("value", {}).get("id") for c in subclass_claims if c.get("mainsnak", {}).get("datavalue") ] async def _infer_card_type_async(client: httpx.AsyncClient, entity_id: str) -> tuple[CardType, str, int]: try: response = await client.get( "https://www.wikidata.org/wiki/Special:EntityData/" + entity_id + ".json", headers=HEADERS ) except: return CardType.other, "", 0 if not response.is_success: return CardType.other, "", 0 entity = response.json().get("entities", {}).get(entity_id, {}) claims = entity.get("claims", {}) instance_of_claims = claims.get("P31", []) qids = [ c.get("mainsnak", {}).get("datavalue", {}).get("value", {}).get("id") for c in instance_of_claims if c.get("mainsnak", {}).get("datavalue") ] sitelinks = entity.get("sitelinks", {}) language_count = sum( 1 for key in sitelinks if key.endswith("wiki") and key not in ("commonswiki", "wikidatawiki", "specieswiki") ) # First pass: direct match for qid in qids: if qid in WIKIDATA_INSTANCE_TYPE_MAP: return WIKIDATA_INSTANCE_TYPE_MAP[qid], qid, language_count # Second pass: check superclasses of each instance-of QID superclass_results = sum( await asyncio.gather(*[_get_superclasses(client, qid) for qid in qids if qid]), []) for superclass_qid in superclass_results: if superclass_qid in WIKIDATA_INSTANCE_TYPE_MAP: return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid], superclass_qid, language_count # Third pass: check superclasses of each superclass superclass_results2 = sum( await asyncio.gather(*[_get_superclasses(client, qid) for qid in superclass_results if qid]) ,[]) for superclass_qid2 in superclass_results2: if superclass_qid2 in WIKIDATA_INSTANCE_TYPE_MAP: return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid2], superclass_qid2, language_count # Fallback: classify by presence of specific claims CLAIMS_FALLBACK = { "P625": CardType.location, # coordinate location "P437": CardType.artwork, # distribution format } for prop, fallback_type in CLAIMS_FALLBACK.items(): if prop in claims: return fallback_type, (qids[0] if qids else ""), language_count return CardType.other, (qids[0] if qids != [] else ""), language_count async def _get_wikirank_score(client: httpx.AsyncClient, title: str) -> float | None: """Returns a quality score from 0-100, or None if unavailable.""" try: response = await client.get( f"https://api.wikirank.net/api.php?name={quote(title, safe='')}&lang=en", headers=HEADERS ) except: return None if not response.is_success: return None data = response.json() result = data.get("result",{}) if result == "not found": return None return result.get("en",{}).get("quality") def _score_to_rarity(score: float | None) -> CardRarity: if score is None: return CardRarity.common if score >= 95: return CardRarity.legendary if score >= 80: return CardRarity.epic if score >= 65: return CardRarity.super_rare if score >= 50: return CardRarity.rare if score >= 35: return CardRarity.uncommon return CardRarity.common RARITY_MULTIPLIER = { CardRarity.common: 1, CardRarity.uncommon: 1.1, CardRarity.rare: 1.3, CardRarity.super_rare: 1.7, CardRarity.epic: 2.5, CardRarity.legendary: 3, } async def _get_monthly_pageviews(client: httpx.AsyncClient, title: str) -> int | None: # Uses the last full month today = datetime.now() first_of_month = today.replace(day=1) last_month_end = first_of_month - timedelta(days=1) last_month_start = last_month_end.replace(day=1) start = last_month_start.strftime("%Y%m01") end = last_month_end.strftime("%Y%m%d") try: response = await client.get( f"https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/en.wikipedia/all-access/all-agents/{quote(title, safe='%')}/monthly/{start}/{end}", headers=HEADERS, ) if not response.is_success: return None items = response.json().get("items", []) return items[0]["views"] if items else None except: return None def _pageviews_to_defense(views: int | None) -> int: if views is None: return 0 return int(sqrt(views)) async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None) -> Card|None: if page_title is None: summary = await _get_random_summary_async(client) else: summary = await _get_page_summary_async(client, quote(page_title)) if summary == {}: return None title = summary["title"] entity_id = summary.get("wikibase_item") text = summary.get("extract", "") card_type_task = ( _infer_card_type_async(client, entity_id) if entity_id else asyncio.sleep(0, result=(CardType.other, "", 0)) ) wikirank_task = _get_wikirank_score(client, title) pageviews_task = _get_monthly_pageviews(client, title) (card_type, instance, language_count), score, views = await asyncio.gather( card_type_task, wikirank_task, pageviews_task ) if ( language_count == 0 or score is None or views is None ): error_message = f"Could not generate card '{title}': " if language_count == 0: error_message += "No language pages found" elif score is None: error_message += "No wikirank score" elif views is None: error_message += "No monthly view data" logger.warning(error_message) return None rarity = _score_to_rarity(score) multiplier = RARITY_MULTIPLIER[rarity] attack = min(2500,int(((language_count*1.5)**1.2)*multiplier**2)) defense = min(2500,int(_pageviews_to_defense(views)*max(multiplier,(multiplier**2)/2))) return Card( name=summary["title"], generated_at=datetime.now(), image_link=summary.get("thumbnail", {}).get("source", ""), card_rarity=rarity, card_type=card_type, wikidata_instance=instance, text=text, attack=attack, defense=defense, cost=min(10,max(1,int(((attack**2+defense**2)**0.18)/1.5))) ) async def _get_cards_async(size: int) -> list[Card]: logger.debug(f"Generating {size} cards") async with httpx.AsyncClient(follow_redirects=True) as client: cards = await asyncio.gather(*[_get_card_async(client) for _ in range(size)]) return [c for c in cards if c is not None] async def _get_specific_card_async(title: str) -> Card|None: async with httpx.AsyncClient(follow_redirects=True) as client: return await _get_card_async(client, title) # Sync entrypoints def generate_cards(size: int) -> list[Card]: cards = [] remaining = size while remaining > 0: batch = min(remaining,10) logger.warning(f"Generating {batch} cards ({len(cards)}/{size})") cards += asyncio.run(_get_cards_async(batch)) remaining = size - len(cards) if remaining > 0: sleep(4) return cards def generate_card(title: str) -> Card|None: return asyncio.run(_get_specific_card_async(title)) # Cards helper function def compute_deck_type(cards: list) -> str | None: if len(cards) == 0: return None avg_atk = sum(c.attack for c in cards) / len(cards) avg_def = sum(c.defense for c in cards) / len(cards) avg_cost = sum(c.cost for c in cards) / len(cards) if all(c.cost > 6 for c in cards): return "Unplayable" if sum(1 for c in cards if c.cost >= 7) == 1: return "God Card" if sum(1 for c in cards if c.cost >= 7) > 1: return "Pantheon" if avg_cost >= 3.2: return "Control" if avg_cost <= 1.8: return "Rush" if avg_def > avg_atk * 1.5: return "Wall" if avg_atk > avg_def * 1.5: return "Aggro" return "Balanced"