Compare commits

...

2 Commits

6 changed files with 335 additions and 446 deletions

Binary file not shown.

View File

@@ -108,8 +108,44 @@ def run_migrations():
pass # Column already exists pass # Column already exists
conn.commit() conn.commit()
repair_image_paths(conn)
conn.close() conn.close()
def repair_image_paths(conn):
"""Attempt to populate missing image_path for existing cards in old databases"""
print("Checking for missing image paths to repair...")
cur = conn.cursor()
# Find cards with missing image paths but have a URL
cur.execute("SELECT card_id, name, gametora_url FROM support_cards WHERE image_path IS NULL OR image_path = ''")
to_repair = cur.fetchall()
if not to_repair:
return
import re
repaired_count = 0
for card_id, name, url in to_repair:
if not url: continue
# Extract ID from URL (e.g., 30154 from .../supports/30154-mejiro-ramonu)
match = re.search(r'/supports/(\d+)-', url)
if match:
stable_id = match.group(1)
# Create safe filename matching scraper logic
safe_name = re.sub(r'[<>:"/\\\\|?*]', '_', name)
filename = f"{stable_id}_{safe_name}.png"
# Update DB with images/filename
cur.execute("UPDATE support_cards SET image_path = ? WHERE card_id = ?",
(f"images/{filename}", card_id))
repaired_count += 1
if repaired_count > 0:
conn.commit()
print(f"Successfully repaired {repaired_count} image paths!")
def check_for_updates(): def check_for_updates():
"""Check if database version matches app version, sync if outdated""" """Check if database version matches app version, sync if outdated"""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
@@ -486,6 +522,49 @@ def get_unique_effect_names(card_id):
conn.close() conn.close()
return rows return rows
def search_owned_effects(search_term):
"""
Search for effects among owned cards.
Returns list of (card_id, card_name, image_path, effect_name, effect_value, level)
"""
conn = get_conn()
cur = conn.cursor()
# We need to join support_effects with owned_cards to get the level
# But wait, owned_cards has a level column. support_effects stores effects for specific levels.
# So we need to match support_effects.level with owned_cards.level
# OR find the effect for the closest level <= owned level (if effects aren't stored for every single level)
# The current DB schema seems to store effects for specific levels (1, 5, 10, ...).
# If a card is level 49, `get_effects_at_level` usually queries for exact level match.
# Let's check `get_effects_at_level` implementation: "WHERE card_id = ? AND level = ?"
# So if I have a card at level 49, and effects are only defined at 45 and 50, query for 49 returns nothing?
# That would be a bug or assumption in the current app.
# Let's look at `update_progression_table` in `effects_view.py`. It does some "nearest level" logic.
# For this search feature, to be robust, we should probably fetch ALL effects for the card
# and filter for the one active at the owned level.
# OR, assuming the scraper/DB populates "current" effects effectively.
# Actually, the most robust way in SQL for "value at level X" given sparse data is complex.
# However, let's assume for now we want exact matches or we'll handle the "effective level" logic in Python?
# No, that's too slow for search.
# Let's look at how `get_effects_at_level` is used.
# It is used in `update_current_effects` with `self.level_var.get()`.
# It expects an exact match.
# So we should probably join on `oc.level`.
query = """
SELECT sc.card_id, sc.name, sc.image_path, se.effect_name, se.effect_value, oc.level
FROM owned_cards oc
JOIN support_cards sc ON oc.card_id = sc.card_id
JOIN support_effects se ON oc.card_id = se.card_id AND oc.level = se.level
WHERE se.effect_name LIKE ?
ORDER BY sc.name
"""
cur.execute(query, (f"%{search_term}%",))
rows = cur.fetchall()
conn.close()
return rows
# ============================================ # ============================================
# Hint Queries # Hint Queries
# ============================================ # ============================================

View File

@@ -1,298 +1,139 @@
""" """
Effects View - Display support effects at all levels with interactive slider Effects Search View - Search for effects across all owned cards
""" """
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox from tkinter import ttk, messagebox
import sys import sys
import os import os
import re
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from db.db_queries import get_all_effects, get_effects_at_level, get_unique_effect_names, get_card_by_id from db.db_queries import search_owned_effects
from gui.theme import ( from gui.theme import (
BG_DARK, BG_MEDIUM, BG_LIGHT, BG_HIGHLIGHT, BG_DARK, BG_MEDIUM, BG_LIGHT, BG_HIGHLIGHT,
ACCENT_PRIMARY, ACCENT_SECONDARY, ACCENT_SUCCESS, ACCENT_TERTIARY, ACCENT_PRIMARY, ACCENT_SECONDARY, ACCENT_SUCCESS, ACCENT_TERTIARY,
TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED,
FONT_HEADER, FONT_SUBHEADER, FONT_BODY, FONT_BODY_BOLD, FONT_SMALL, FONT_MONO, FONT_HEADER, FONT_SUBHEADER, FONT_BODY, FONT_BODY_BOLD, FONT_SMALL,
create_styled_button, create_styled_text, create_card_frame, create_styled_button, create_styled_entry
EFFECT_DESCRIPTIONS
) )
from utils import resolve_image_path
class EffectsFrame(ttk.Frame): class EffectsFrame(ttk.Frame):
"""Frame for viewing support effects at different levels""" """Frame for searching effects across owned cards"""
def __init__(self, parent): def __init__(self, parent):
super().__init__(parent) super().__init__(parent)
self.current_card_id = None
self.current_card_name = None
self.max_level = 50
self.create_widgets() self.create_widgets()
def create_widgets(self): def create_widgets(self):
"""Create the effects view interface""" """Create the effects search interface"""
# Header # Header / Search Bar
header_frame = tk.Frame(self, bg=BG_DARK) header_frame = tk.Frame(self, bg=BG_DARK)
header_frame.pack(fill=tk.X, padx=20, pady=15) header_frame.pack(fill=tk.X, padx=20, pady=15)
self.card_label = tk.Label(header_frame, text="📊 Select a card from the Card List tab", # Search container
font=FONT_HEADER, bg=BG_DARK, fg=ACCENT_PRIMARY) search_container = tk.Frame(header_frame, bg=BG_DARK)
self.card_label.pack(side=tk.LEFT) search_container.pack(fill=tk.X)
# Legend Button tk.Label(search_container, text="🔍 Search Effect:",
legend_btn = create_styled_button(header_frame, text="❓ Legend", font=FONT_HEADER, bg=BG_DARK, fg=TEXT_PRIMARY).pack(side=tk.LEFT, padx=(0, 10))
command=self.show_legend, style_type='default')
legend_btn.config(font=FONT_SMALL, padx=10, pady=4)
legend_btn.pack(side=tk.RIGHT)
# Level control frame self.search_var = tk.StringVar()
control_frame = tk.Frame(self, bg=BG_MEDIUM, padx=15, pady=12) self.search_entry = create_styled_entry(search_container, textvariable=self.search_var)
control_frame.pack(fill=tk.X, padx=20) self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
self.search_entry.bind('<Return>', lambda e: self.perform_search())
# Level label search_btn = create_styled_button(search_container, text="Search",
tk.Label(control_frame, text="Level:", font=FONT_BODY, command=self.perform_search, style_type='primary')
bg=BG_MEDIUM, fg=TEXT_SECONDARY).pack(side=tk.LEFT) search_btn.pack(side=tk.LEFT)
# Level display with increment/decrement buttons # Example/Help text
level_ctrl = tk.Frame(control_frame, bg=BG_MEDIUM) help_frame = tk.Frame(header_frame, bg=BG_DARK)
level_ctrl.pack(side=tk.LEFT, padx=15) help_frame.pack(fill=tk.X, pady=(5, 0))
tk.Label(help_frame, text="Examples: Friendship, Motivation, Race Bonus, Skill Pt",
font=FONT_SMALL, bg=BG_DARK, fg=TEXT_MUTED).pack(side=tk.LEFT)
# Decrement button # Results Area
dec_btn = tk.Button(level_ctrl, text="", font=FONT_HEADER, results_frame = ttk.LabelFrame(self, text=" Search Results (Owned Cards) ", padding=10)
bg=BG_LIGHT, fg=TEXT_PRIMARY, bd=0, width=2, results_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 20))
activebackground=BG_HIGHLIGHT, cursor='hand2',
command=self.decrement_level)
dec_btn.pack(side=tk.LEFT)
self.level_var = tk.IntVar(value=50) # Treeview
self.level_display = tk.Label(level_ctrl, text="50", width=4, font=FONT_HEADER, columns = ('card', 'level', 'current_value', 'effect_name')
bg=BG_LIGHT, fg=ACCENT_PRIMARY, padx=10) self.tree = ttk.Treeview(results_frame, columns=columns, show='headings', selectmode='browse')
self.level_display.pack(side=tk.LEFT, padx=2)
# Increment button self.tree.heading('card', text='Card Name', anchor='w')
inc_btn = tk.Button(level_ctrl, text="+", font=FONT_HEADER, self.tree.heading('level', text='Level', anchor='center')
bg=BG_LIGHT, fg=TEXT_PRIMARY, bd=0, width=2, self.tree.heading('current_value', text='Value', anchor='center')
activebackground=BG_HIGHLIGHT, cursor='hand2', self.tree.heading('effect_name', text='Effect Name', anchor='w')
command=self.increment_level)
inc_btn.pack(side=tk.LEFT)
# Quick level buttons self.tree.column('card', width=250)
button_frame = tk.Frame(control_frame, bg=BG_MEDIUM) self.tree.column('level', width=60, anchor='center')
button_frame.pack(side=tk.LEFT, padx=25) self.tree.column('current_value', width=80, anchor='center')
self.tree.column('effect_name', width=150)
quick_levels = [25, 30, 35, 40, 45, 50] scrollbar = ttk.Scrollbar(results_frame, orient=tk.VERTICAL, command=self.tree.yview)
for lvl in quick_levels: self.tree.configure(yscrollcommand=scrollbar.set)
btn = create_styled_button(button_frame, text=f"Lv{lvl}",
command=lambda l=lvl: self.set_level(l),
style_type='default')
btn.config(width=5, font=FONT_SMALL, padx=6, pady=3)
btn.pack(side=tk.LEFT, padx=3)
# Main content area self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
content_frame = tk.Frame(self, bg=BG_DARK)
content_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=15)
# Left: Current level effects
left_frame = ttk.LabelFrame(content_frame, text=" Current Level Effects ", padding=12)
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))
self.current_effects = create_styled_text(left_frame, height=15)
self.current_effects.pack(fill=tk.BOTH, expand=True)
self.current_effects.config(state=tk.DISABLED)
# Right: Effect progression table
right_frame = ttk.LabelFrame(content_frame, text=" Effect Progression ", padding=12)
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
# Treeview for effect table
columns = ('effect', 'lv1', 'lv25', 'lv40', 'lv50')
self.effects_tree = ttk.Treeview(right_frame, columns=columns, show='headings', height=12)
self.effects_tree.heading('effect', text='Effect', anchor='w')
self.effects_tree.column('effect', width=140, minwidth=120)
for col in columns[1:]:
level = col.replace('lv', 'Lv ')
self.effects_tree.heading(col, text=level)
self.effects_tree.column(col, width=65, anchor='center')
scrollbar = ttk.Scrollbar(right_frame, orient=tk.VERTICAL, command=self.effects_tree.yview)
self.effects_tree.configure(yscrollcommand=scrollbar.set)
self.effects_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y) scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
def show_legend(self): # Status Label
"""Show effect explanations""" self.status_label = tk.Label(results_frame, text="", bg=BG_MEDIUM, fg=TEXT_SECONDARY, font=FONT_SMALL)
legend = { self.status_label.pack(side=tk.BOTTOM, fill=tk.X, pady=(5, 0))
"Friendship Bonus": "Increases stats gained when training with this support card during Friendship Training (orange aura).",
"Motivation Bonus": "Increases stats gained based on your Uma's motivation level.",
"Specialty Rate": "Increases the chance of this card appearing in its specialty training.",
"Training Bonus": "Flat percentage increase to stats gained in training where this card is present.",
"Initial Bond": "Starting gauge value for this card.",
"Race Bonus": "Increases stats gained from racing.",
"Fan Count Bonus": "Increases fans gained from racing.",
"Skill Pt Bonus": "Bonus skill points gained when training with this card.",
"Hint Lv": "Starting level of skills taught by this card's hints.",
"Hint Rate": "Increases chance of getting a hint event."
}
text = "📖 Effect Explanations:\n\n" def parse_value(self, value_str):
for name, desc in legend.items(): """Parse effect value string to float for sorting"""
text += f"{name}:\n {desc}\n\n" try:
# Extract number from string (e.g. "20%" -> 20, "+15" -> 15)
# Remove non-numeric characters except . and -
clean = re.sub(r'[^\d.-]', '', str(value_str))
return float(clean)
except:
return -999999.0 # Sort to bottom if invalid
messagebox.showinfo("Effect Legend", text) def perform_search(self):
"""Execute search and update results"""
term = self.search_var.get().strip()
if not term:
messagebox.showwarning("Search", "Please enter a search term")
return
# clear current
for item in self.tree.get_children():
self.tree.delete(item)
# Query DB
results = search_owned_effects(term)
if not results:
self.status_label.config(text="No matching effects found among owned cards.")
return
# Process and Sort
# Row: (card_id, card_name, image_path, effect_name, effect_value, level)
processed_results = []
for r in results:
val_num = self.parse_value(r[4])
processed_results.append({
'data': r,
'sort_val': val_num
})
# Sort by value descending
processed_results.sort(key=lambda x: x['sort_val'], reverse=True)
# Populate Tree
for item in processed_results:
r = item['data']
#Columns: card, level, current_value, effect_name
values = (r[1], f"Lv {r[5]}", r[4], r[3])
self.tree.insert('', tk.END, values=values)
self.status_label.config(text=f"Found {len(processed_results)} owned cards with matching effects.")
# Compatibility methods for main_window integration (empty as we don't need them anymore)
def set_card(self, card_id): def set_card(self, card_id):
"""Load a card's effects""" pass
self.current_card_id = card_id
# Get card info for max level
card = get_card_by_id(card_id)
if card:
self.current_card_name = card[1]
self.max_level = card[4]
if self.level_var.get() > self.max_level:
self.level_var.set(self.max_level)
self.level_display.config(text=str(self.max_level))
self.card_label.config(text=f"📊 {self.current_card_name}")
# Update displays
self.update_current_effects()
self.update_progression_table()
def set_level(self, level):
"""Set level from quick button"""
if level <= self.max_level:
self.level_var.set(level)
self.level_display.config(text=str(level))
self.update_current_effects()
def increment_level(self):
"""Increase level by 1"""
current = self.level_var.get()
if current < self.max_level:
self.set_level(current + 1)
def decrement_level(self):
"""Decrease level by 1"""
current = self.level_var.get()
if current > 1:
self.set_level(current - 1)
def update_current_effects(self):
"""Update the current level effects display"""
self.current_effects.config(state=tk.NORMAL)
self.current_effects.delete('1.0', tk.END)
# Configure tags
self.current_effects.tag_configure('header', font=FONT_SUBHEADER, foreground=ACCENT_PRIMARY)
self.current_effects.tag_configure('highlight', foreground=ACCENT_SUCCESS)
self.current_effects.tag_configure('effect_name', foreground=TEXT_SECONDARY)
self.current_effects.tag_configure('effect_value', foreground=TEXT_PRIMARY, font=FONT_BODY_BOLD)
self.current_effects.tag_configure('effect_tooltip', underline=False)
if not self.current_card_id:
self.current_effects.insert(tk.END, "No card selected\n\n", 'effect_name')
self.current_effects.insert(tk.END, "Select a card from the Card List tab to view its effects.", 'effect_name')
self.current_effects.config(state=tk.DISABLED)
return
level = self.level_var.get()
effects = get_effects_at_level(self.current_card_id, level)
self.current_effects.insert(tk.END, f"━━━ Level {level} ━━━\n\n", 'header')
if effects:
for name, value in effects:
# Highlight high values
prefix = ""
if '%' in str(value):
try:
num = int(str(value).replace('%', '').replace('+', ''))
if num >= 20:
prefix = ""
except:
pass
if prefix:
self.current_effects.insert(tk.END, prefix, 'highlight')
# Insert effect name with tooltip tag
tag_name = f"tooltip_{name.replace(' ', '_')}"
self.current_effects.insert(tk.END, f"{name}: ", ('effect_name', tag_name))
# Bind tooltip events
self.current_effects.tag_bind(tag_name, "<Enter>", lambda e, n=name: self.show_effect_tooltip(e, n))
self.current_effects.tag_bind(tag_name, "<Leave>", self.hide_effect_tooltip)
self.current_effects.insert(tk.END, f"{value}\n", 'effect_value')
else:
self.current_effects.insert(tk.END, "No effect data available for this level.\n\n", 'effect_name')
self.current_effects.insert(tk.END, "Try selecting: Lv 1, 25, 40, or 50", 'effect_name')
self.current_effects.config(state=tk.DISABLED)
def show_effect_tooltip(self, event, effect_name):
"""Show tooltip for effect"""
if effect_name in EFFECT_DESCRIPTIONS:
text = EFFECT_DESCRIPTIONS[effect_name]
x = event.x_root + 15
y = event.y_root + 10
# Close existing if any
self.hide_effect_tooltip(None)
self.tooltip_window = tk.Toplevel(self)
self.tooltip_window.wm_overrideredirect(True)
self.tooltip_window.wm_geometry(f"+{x}+{y}")
label = tk.Label(self.tooltip_window, text=text, justify=tk.LEFT,
background=BG_LIGHT, foreground=TEXT_PRIMARY,
relief=tk.SOLID, borderwidth=1, font=FONT_SMALL,
padx=10, pady=5, wraplength=250)
label.pack()
def hide_effect_tooltip(self, event):
"""Hide tooltip"""
if hasattr(self, 'tooltip_window') and self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
def update_progression_table(self):
"""Update the effect progression table"""
self.effects_tree.delete(*self.effects_tree.get_children())
if not self.current_card_id:
return
# Get all effects
all_effects = get_all_effects(self.current_card_id)
# Organize by effect name
effect_by_level = {}
for level, effect_name, effect_value in all_effects:
if effect_name not in effect_by_level:
effect_by_level[effect_name] = {}
effect_by_level[effect_name][level] = effect_value
# Key levels for the table
key_levels = [1, 25, 40, 50]
# Add rows
for effect_name, levels in sorted(effect_by_level.items()):
row = [effect_name]
for lvl in key_levels:
# Find closest level we have data for
value = levels.get(lvl, '')
if not value:
# Try to find nearest
for l in sorted(levels.keys()):
if l <= lvl:
value = levels[l]
row.append(value)
self.effects_tree.insert('', tk.END, values=row)

View File

@@ -256,6 +256,17 @@ def configure_styles(root: tk.Tk):
# WIDGET HELPER FUNCTIONS # WIDGET HELPER FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
def create_styled_entry(parent, textvariable=None, **kwargs):
"""Create a styled tk.Entry with modern appearance"""
entry = ttk.Entry(
parent,
textvariable=textvariable,
font=FONT_BODY,
**kwargs
)
return entry
def create_styled_button(parent, text, command=None, style_type='default', **kwargs): def create_styled_button(parent, text, command=None, style_type='default', **kwargs):
"""Create a styled tk.Button with modern appearance""" """Create a styled tk.Button with modern appearance"""
bg_colors = { bg_colors = {

View File

@@ -549,242 +549,200 @@ def scrape_hints(page, card_id, cur):
print(f" Found {len(hints)} hints") print(f" Found {len(hints)} hints")
def scrape_events(page, card_id, cur): def scrape_events(page, card_id, cur):
"""Scrape the LAST chain event (Golden Perk) with OR options""" """Scrape all events including Chain, Dates, Random and Special"""
# Use a flag to avoid adding multiple console listeners # Use a flag to avoid adding multiple console listeners
if not hasattr(page, "_console_attached"): if not hasattr(page, "_console_attached"):
page.on("console", lambda msg: print(f" [JS Console] {msg.text}") if "scrapping" not in msg.text.lower() else None) page.on("console", lambda msg: print(f" [JS Console] {msg.text}") if "scrapping" not in msg.text.lower() else None)
page._console_attached = True page._console_attached = True
# 1. First, build a map of skills from the 'Skills from events' summary section # 1. Build a map of skills from the 'Skills from events' summary section
# This helps us identify which skills are Rare (Gold) # This remains useful for identifying golden skills.
skill_rarity_map = page.evaluate(""" skill_rarity_map = page.evaluate("""
() => { () => {
const map = {}; const map = {};
console.log("Building Skill Rarity Map...");
// 1. Find all skill containers. They usually have a name and a 'Details' button.
// In the "Skills from events" or "Support hints" sections.
const containers = Array.from(document.querySelectorAll('div')).filter(d => const containers = Array.from(document.querySelectorAll('div')).filter(d =>
(d.innerText.includes('Details') || d.innerText.includes('Reward')) && d.innerText.length < 500 (d.innerText.includes('Details') || d.innerText.includes('Reward')) && d.innerText.length < 500
); );
containers.forEach(c => { containers.forEach(c => {
// Try to extract the skill name. It's usually the first text node or a bold tag.
const nameNode = c.querySelector('b, span[font-weight="bold"], div[font-weight="bold"]'); const nameNode = c.querySelector('b, span[font-weight="bold"], div[font-weight="bold"]');
let name = ""; let name = nameNode ? nameNode.innerText.trim() : c.innerText.split('Details')[0].replace(/\\n/g, ' ').trim();
if (nameNode) {
name = nameNode.innerText.trim();
} else {
// Fallback to text before 'Details'
name = c.innerText.split('Details')[0].replace(/\\n/g, ' ').trim();
}
if (name && name.length > 2) { if (name && name.length > 2) {
const style = window.getComputedStyle(c); const style = window.getComputedStyle(c);
const nameStyle = nameNode ? window.getComputedStyle(nameNode) : null; const nameStyle = nameNode ? window.getComputedStyle(nameNode) : null;
// Golden skills have a specific background
const isGold = style.backgroundImage.includes('linear-gradient') || const isGold = style.backgroundImage.includes('linear-gradient') ||
style.backgroundColor.includes('rgb(255, 193, 7)') || style.backgroundColor.includes('rgb(255, 193, 7)') ||
(nameStyle && nameStyle.color === 'rgb(255, 193, 7)') || (nameStyle && nameStyle.color === 'rgb(255, 193, 7)') ||
c.className.includes('kkspcu') || c.className.includes('kkspcu');
c.innerHTML.includes('kkspcu');
const normalized = name.toLowerCase().replace(/\\s+/g, ' ').replace(/[()-]/g, '').trim(); const normalized = name.toLowerCase().replace(/\\s+/g, ' ').replace(/[()-]/g, '').trim();
map[normalized] = isGold; map[normalized] = isGold;
console.log(`Mapped Skill: "${name}" [${normalized}] -> Gold: ${isGold}`);
} }
}); });
return map; return map;
} }
""") """)
# Scroll to the Events section specifically # 2. Scrape all event types
print(" Ensuring events are loaded...") print(" Scraping all event categories...")
page.evaluate("() => { const h = Array.from(document.querySelectorAll('h2, h1, div')).find(el => el.innerText.toLowerCase().includes('training events')); if (h) h.scrollIntoView(); }") all_events_data = page.evaluate("""
page.wait_for_timeout(1000)
# 2. Scrape ONLY the LAST chain event (Golden Perk) with OR options
golden_perk_data = page.evaluate("""
async () => { async () => {
console.log("Scraping Golden Perk (last chain event)..."); const results = [];
// Find all chain event buttons // Define categories to look for
const getChainEventButtons = () => { const categories = [
const buttons = []; { label: 'Chain Events', type: 'Chain' },
// Look for "Chain Events" text (case-insensitive substring) { label: 'Dates', type: 'Date' },
const labels = Array.from(document.querySelectorAll('div, span, h2, h3, h4')).filter(el => { label: 'Random Events', type: 'Random' },
el.innerText.toLowerCase().includes('chain events') && el.innerText.trim().length < 20 { label: 'Special Events', type: 'Special' }
];
for (const cat of categories) {
// Find category headers
const headers = Array.from(document.querySelectorAll('div, span, h2, h3, h4')).filter(el =>
el.innerText.trim() === cat.label && el.children.length === 0
); );
labels.forEach(label => { for (const header of headers) {
// The buttons are usually in the same container or next container // Find buttons in the following siblings or parent siblings
let container = label.parentElement; let container = header.parentElement;
let foundButtons = [];
let attempts = 0; let attempts = 0;
while (container && container.querySelectorAll('button').length === 0 && attempts < 5) {
container = container.nextElementSibling || container.parentElement;
attempts++;
if (container && container.tagName === 'BODY') break;
}
if (container) { while (container && foundButtons.length === 0 && attempts < 5) {
const btns = Array.from(container.querySelectorAll('button')); foundButtons = Array.from(container.querySelectorAll('button')).filter(btn => {
btns.forEach(btn => {
const text = btn.innerText.trim();
const style = window.getComputedStyle(btn); const style = window.getComputedStyle(btn);
const isVisible = style.display !== 'none' && style.visibility !== 'hidden'; return style.display !== 'none' && style.visibility !== 'hidden';
// Look for arrows (regular or heavy)
if (isVisible && (text.includes('>') || text.includes(''))) {
buttons.push(btn);
}
}); });
if (foundButtons.length === 0) {
// Check next siblings of the header's ancestors
let sibling = header;
let parent = header.parentElement;
while(parent && parent.tagName !== 'BODY') {
if (parent.innerText.includes(cat.label)) {
const next = parent.nextElementSibling;
if (next) {
const nextBtns = Array.from(next.querySelectorAll('button'));
if (nextBtns.length > 0) {
foundButtons = nextBtns;
break;
}
}
}
parent = parent.parentElement;
}
}
container = container.parentElement;
attempts++;
} }
});
return buttons;
};
const buttons = getChainEventButtons(); // Scrape each button in the category
console.log(`Found ${buttons.length} chain event buttons`); for (const btn of foundButtons) {
const eventName = btn.innerText.trim();
if (!eventName || results.some(r => r.name === eventName)) continue;
if (buttons.length === 0) { // Count arrows for chain/date importance
return null; const arrows = (eventName.match(/>|/g) || []).length;
}
let goldenPerkButton = null; try {
let maxArrows = 0; btn.scrollIntoViewIfNeeded ? btn.scrollIntoViewIfNeeded() : null;
await new Promise(r => setTimeout(r, 100));
btn.click();
await new Promise(r => setTimeout(r, 500));
for (const btn of buttons) { const popovers = Array.from(document.querySelectorAll('div')).filter(d =>
const text = btn.innerText.trim(); d.innerText.includes(eventName) &&
// Count both regular and heavy arrows window.getComputedStyle(d).zIndex > 50 &&
const arrowCount = (text.match(/>|/g) || []).length; d.innerText.length < 2500
);
// If it has three heavy arrows, it's almost certainly the golden perk if (popovers.length > 0) {
if (text.includes('')) { const pop = popovers[popovers.length - 1];
goldenPerkButton = btn; const hasOrDivider = pop.querySelector('[class*="divider_or"]') !== null ||
break; pop.innerText.includes('Randomly either') ||
} pop.innerText.toLowerCase().includes(' or ');
if (arrowCount > maxArrows) { const skillLinks = Array.from(pop.querySelectorAll('span, a')).filter(el =>
maxArrows = arrowCount; el.innerText.length > 2 &&
goldenPerkButton = btn; !el.innerText.includes('Energy') &&
} (window.getComputedStyle(el).color === 'rgb(102, 107, 255)' ||
} el.className.includes('linkcolor'))
);
if (!goldenPerkButton) { const skills = [];
console.log("No golden perk button found"); skillLinks.forEach(link => {
return null; const sName = link.innerText.trim();
} if (sName && !skills.some(s => s.name === sName)) {
skills.push({ name: sName, is_or: hasOrDivider });
}
});
const eventName = goldenPerkButton.innerText.trim(); results.push({
console.log(`Found Golden Perk: ${eventName} (${maxArrows} arrows)`); name: eventName,
type: cat.type,
skills: skills,
arrows: arrows
});
}
try { document.body.click();
// Click to open popover await new Promise(r => setTimeout(r, 100));
goldenPerkButton.scrollIntoViewIfNeeded ? goldenPerkButton.scrollIntoViewIfNeeded() : null; } catch (err) {
await new Promise(r => setTimeout(r, 100)); console.log(`Failed to scrape event ${eventName}: ${err.message}`);
goldenPerkButton.click(); }
await new Promise(r => setTimeout(r, 600));
// Find popover
const popovers = Array.from(document.querySelectorAll('div')).filter(d =>
d.innerText.includes(eventName) &&
window.getComputedStyle(d).zIndex > 50 &&
d.innerText.length < 2500
);
if (popovers.length === 0) {
console.log(`Popover NOT found for ${eventName}`);
document.body.click();
return { name: eventName, type: 'Chain', skills: [] };
}
const pop = popovers[popovers.length - 1];
console.log(`Found popover for ${eventName}`);
// Check for OR structure - look for "Randomly either" or "or" divider
const hasOrDivider = pop.querySelector('[class*="divider_or"]') !== null ||
pop.innerText.includes('Randomly either') ||
pop.innerText.toLowerCase().includes(' or ');
// Find all skill names (purple/blue links)
const skillLinks = Array.from(pop.querySelectorAll('span, a')).filter(el =>
el.innerText.length > 2 &&
!el.innerText.includes('Energy') &&
!el.innerText.includes('bond') &&
(window.getComputedStyle(el).color === 'rgb(102, 107, 255)' ||
el.className.includes('linkcolor'))
);
console.log(`Found ${skillLinks.length} potential skills in popover`);
const skills = [];
skillLinks.forEach(link => {
const skillName = link.innerText.trim();
if (skillName && skillName.length > 2 && !skills.some(s => s.name === skillName)) {
// If there's an OR divider, all skills in this popover are part of OR groups
const isOr = hasOrDivider;
skills.push({ name: skillName, is_or: isOr });
} }
}); }
// Close popover
document.body.click();
await new Promise(r => setTimeout(r, 200));
return { name: eventName, type: 'Chain', skills: skills };
} catch (err) {
console.log(`Error clicking ${eventName}: ${err.message}`);
return { name: eventName, type: 'Chain', skills: [] };
} }
return results;
} }
""") """)
# 3. Store ONLY the golden perk in database # 3. Store all found events and identify golden skills
if golden_perk_data: if all_events_data:
cur.execute(""" # Determine max arrows for Chain and Dates to identify final step
INSERT INTO support_events (card_id, event_name, event_type) max_arrows = {
VALUES (?, ?, ?) 'Chain': max([e['arrows'] for e in all_events_data if e['type'] == 'Chain'] + [0]),
""", (card_id, golden_perk_data['name'], golden_perk_data['type'])) 'Date': max([e['arrows'] for e in all_events_data if e['type'] == 'Date'] + [0])
event_id = cur.lastrowid }
for skill in golden_perk_data['skills']: for event in all_events_data:
# Normalization helper cur.execute("INSERT INTO support_events (card_id, event_name, event_type) VALUES (?, ?, ?)",
def normalize(s): (card_id, event['name'], event['type']))
return s.lower().replace(" hint +1", "").replace(" hint +3", "").replace(" hint +5", "").replace(" hint +", "").strip().replace(" ", " ").replace("-", "").replace("(", "").replace(")", "").replace(" ", "") event_id = cur.lastrowid
skill_name = normalize(skill['name']) for skill in event['skills']:
def normalize(s):
# Remove hint suffix and special characters
s = s.lower().split(' hint +')[0]
return re.sub(r'[()\-\s\+]', '', s).strip()
# Use extra aggressive name matching against the map values n_name = normalize(skill['name'])
# (The map keys are already normalized) is_gold = 0
is_gold = 0 for k, gold in skill_rarity_map.items():
for k, gold in skill_rarity_map.items(): if normalize(k) == n_name:
if normalize(k) == skill_name: is_gold = 1 if gold else 0
is_gold = 1 if gold else 0 break
break
# Fallback 1: If it's a chain event and specifically the last one, it's almost certainly gold # Heuristic: If it's the last step of a Chain or Date, it's likely gold
if not is_gold and golden_perk_data.get('type') == 'Chain': if not is_gold and event['type'] in ['Chain', 'Date']:
# Check for "hint" patterns which usually accompany gold perks in chain events if event['arrows'] >= 3 and event['arrows'] == max_arrows[event['type']]:
if "hint +" in skill['name'].lower() or len(golden_perk_data['skills']) <= 2: if len(event['skills']) <= 2 or "hint +" in skill['name'].lower():
is_gold = 1 is_gold = 1
print(f"Golden Skill Fallback (Last Chain Event): {skill['name']}") print(f"Heuristic Gold: {skill['name']} in {event['name']}")
if is_gold: if is_gold: print(f" ✨ Verified Gold: {skill['name']}")
print(f" ✨ Golden Skill Verified: {skill['name']}")
cur.execute(""" cur.execute("""
INSERT INTO event_skills (event_id, skill_name, is_gold, is_or) INSERT INTO event_skills (event_id, skill_name, is_gold, is_or)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""", (event_id, skill['name'], is_gold, 1 if skill['is_or'] else 0)) """, (event_id, skill['name'], is_gold, 1 if skill['is_or'] else 0))
skill_count = len(golden_perk_data['skills']) print(f" Scraped {len(all_events_data)} total events.")
or_count = sum(1 for s in golden_perk_data['skills'] if s['is_or'])
print(f" Golden Perk: {golden_perk_data['name']} ({skill_count} skills, {or_count} with OR)")
else: else:
print(f" No Golden Perk found for this card") print(f" No events found.")
def run_scraper(): def run_scraper():
""" Run the web scraper to fetch card data from GameTora.com """ """ Run the web scraper to fetch card data from GameTora.com """

View File

@@ -4,7 +4,7 @@ This file is the single source of truth for the application version.
""" """
# Semantic versioning: MAJOR.MINOR.PATCH # Semantic versioning: MAJOR.MINOR.PATCH
VERSION: str = "13.0.3" VERSION: str = "13.0.6"
# Application metadata # Application metadata
APP_NAME: str = "UmamusumeCardManager" APP_NAME: str = "UmamusumeCardManager"