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
conn.commit()
repair_image_paths(conn)
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():
"""Check if database version matches app version, sync if outdated"""
if getattr(sys, 'frozen', False):
@@ -486,6 +522,49 @@ def get_unique_effect_names(card_id):
conn.close()
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
# ============================================

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
from tkinter import ttk, messagebox
import sys
import os
import re
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 (
BG_DARK, BG_MEDIUM, BG_LIGHT, BG_HIGHLIGHT,
ACCENT_PRIMARY, ACCENT_SECONDARY, ACCENT_SUCCESS, ACCENT_TERTIARY,
TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED,
FONT_HEADER, FONT_SUBHEADER, FONT_BODY, FONT_BODY_BOLD, FONT_SMALL, FONT_MONO,
create_styled_button, create_styled_text, create_card_frame,
EFFECT_DESCRIPTIONS
FONT_HEADER, FONT_SUBHEADER, FONT_BODY, FONT_BODY_BOLD, FONT_SMALL,
create_styled_button, create_styled_entry
)
from utils import resolve_image_path
class EffectsFrame(ttk.Frame):
"""Frame for viewing support effects at different levels"""
"""Frame for searching effects across owned cards"""
def __init__(self, parent):
super().__init__(parent)
self.current_card_id = None
self.current_card_name = None
self.max_level = 50
self.create_widgets()
def create_widgets(self):
"""Create the effects view interface"""
# Header
"""Create the effects search interface"""
# Header / Search Bar
header_frame = tk.Frame(self, bg=BG_DARK)
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",
font=FONT_HEADER, bg=BG_DARK, fg=ACCENT_PRIMARY)
self.card_label.pack(side=tk.LEFT)
# Search container
search_container = tk.Frame(header_frame, bg=BG_DARK)
search_container.pack(fill=tk.X)
# Legend Button
legend_btn = create_styled_button(header_frame, text="❓ Legend",
command=self.show_legend, style_type='default')
legend_btn.config(font=FONT_SMALL, padx=10, pady=4)
legend_btn.pack(side=tk.RIGHT)
tk.Label(search_container, text="🔍 Search Effect:",
font=FONT_HEADER, bg=BG_DARK, fg=TEXT_PRIMARY).pack(side=tk.LEFT, padx=(0, 10))
# Level control frame
control_frame = tk.Frame(self, bg=BG_MEDIUM, padx=15, pady=12)
control_frame.pack(fill=tk.X, padx=20)
self.search_var = tk.StringVar()
self.search_entry = create_styled_entry(search_container, textvariable=self.search_var)
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
tk.Label(control_frame, text="Level:", font=FONT_BODY,
bg=BG_MEDIUM, fg=TEXT_SECONDARY).pack(side=tk.LEFT)
search_btn = create_styled_button(search_container, text="Search",
command=self.perform_search, style_type='primary')
search_btn.pack(side=tk.LEFT)
# Level display with increment/decrement buttons
level_ctrl = tk.Frame(control_frame, bg=BG_MEDIUM)
level_ctrl.pack(side=tk.LEFT, padx=15)
# Example/Help text
help_frame = tk.Frame(header_frame, bg=BG_DARK)
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)
# Results Area
results_frame = ttk.LabelFrame(self, text=" Search Results (Owned Cards) ", padding=10)
results_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 20))
# Decrement button
dec_btn = tk.Button(level_ctrl, text="", font=FONT_HEADER,
bg=BG_LIGHT, fg=TEXT_PRIMARY, bd=0, width=2,
activebackground=BG_HIGHLIGHT, cursor='hand2',
command=self.decrement_level)
dec_btn.pack(side=tk.LEFT)
# Treeview
columns = ('card', 'level', 'current_value', 'effect_name')
self.tree = ttk.Treeview(results_frame, columns=columns, show='headings', selectmode='browse')
self.level_var = tk.IntVar(value=50)
self.level_display = tk.Label(level_ctrl, text="50", width=4, font=FONT_HEADER,
bg=BG_LIGHT, fg=ACCENT_PRIMARY, padx=10)
self.level_display.pack(side=tk.LEFT, padx=2)
self.tree.heading('card', text='Card Name', anchor='w')
self.tree.heading('level', text='Level', anchor='center')
self.tree.heading('current_value', text='Value', anchor='center')
self.tree.heading('effect_name', text='Effect Name', anchor='w')
# Increment button
inc_btn = tk.Button(level_ctrl, text="+", font=FONT_HEADER,
bg=BG_LIGHT, fg=TEXT_PRIMARY, bd=0, width=2,
activebackground=BG_HIGHLIGHT, cursor='hand2',
command=self.increment_level)
inc_btn.pack(side=tk.LEFT)
self.tree.column('card', width=250)
self.tree.column('level', width=60, anchor='center')
self.tree.column('current_value', width=80, anchor='center')
self.tree.column('effect_name', width=150)
# Quick level buttons
button_frame = tk.Frame(control_frame, bg=BG_MEDIUM)
button_frame.pack(side=tk.LEFT, padx=25)
scrollbar = ttk.Scrollbar(results_frame, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscrollcommand=scrollbar.set)
quick_levels = [25, 30, 35, 40, 45, 50]
for lvl in quick_levels:
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
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)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
def show_legend(self):
"""Show effect explanations"""
legend = {
"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"
for name, desc in legend.items():
text += f"{name}:\n {desc}\n\n"
messagebox.showinfo("Effect Legend", text)
# Status Label
self.status_label = tk.Label(results_frame, text="", bg=BG_MEDIUM, fg=TEXT_SECONDARY, font=FONT_SMALL)
self.status_label.pack(side=tk.BOTTOM, fill=tk.X, pady=(5, 0))
def parse_value(self, value_str):
"""Parse effect value string to float for sorting"""
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
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):
"""Load a card's effects"""
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)
pass
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
# ═══════════════════════════════════════════════════════════════════════════════
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):
"""Create a styled tk.Button with modern appearance"""
bg_colors = {

View File

@@ -549,242 +549,200 @@ def scrape_hints(page, card_id, cur):
print(f" Found {len(hints)} hints")
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
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._console_attached = True
# 1. First, build a map of skills from the 'Skills from events' summary section
# This helps us identify which skills are Rare (Gold)
# 1. Build a map of skills from the 'Skills from events' summary section
# This remains useful for identifying golden skills.
skill_rarity_map = page.evaluate("""
() => {
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 =>
(d.innerText.includes('Details') || d.innerText.includes('Reward')) && d.innerText.length < 500
);
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"]');
let name = "";
if (nameNode) {
name = nameNode.innerText.trim();
} else {
// Fallback to text before 'Details'
name = c.innerText.split('Details')[0].replace(/\\n/g, ' ').trim();
}
let name = nameNode ? nameNode.innerText.trim() : c.innerText.split('Details')[0].replace(/\\n/g, ' ').trim();
if (name && name.length > 2) {
const style = window.getComputedStyle(c);
const nameStyle = nameNode ? window.getComputedStyle(nameNode) : null;
// Golden skills have a specific background
const isGold = style.backgroundImage.includes('linear-gradient') ||
style.backgroundColor.includes('rgb(255, 193, 7)') ||
(nameStyle && nameStyle.color === 'rgb(255, 193, 7)') ||
c.className.includes('kkspcu') ||
c.innerHTML.includes('kkspcu');
c.className.includes('kkspcu');
const normalized = name.toLowerCase().replace(/\\s+/g, ' ').replace(/[()-]/g, '').trim();
map[normalized] = isGold;
console.log(`Mapped Skill: "${name}" [${normalized}] -> Gold: ${isGold}`);
}
});
return map;
}
""")
# Scroll to the Events section specifically
print(" Ensuring events are loaded...")
page.evaluate("() => { const h = Array.from(document.querySelectorAll('h2, h1, div')).find(el => el.innerText.toLowerCase().includes('training events')); if (h) h.scrollIntoView(); }")
page.wait_for_timeout(1000)
# 2. Scrape ONLY the LAST chain event (Golden Perk) with OR options
golden_perk_data = page.evaluate("""
# 2. Scrape all event types
print(" Scraping all event categories...")
all_events_data = page.evaluate("""
async () => {
console.log("Scraping Golden Perk (last chain event)...");
const results = [];
// Find all chain event buttons
const getChainEventButtons = () => {
const buttons = [];
// Look for "Chain Events" text (case-insensitive substring)
const labels = Array.from(document.querySelectorAll('div, span, h2, h3, h4')).filter(el =>
el.innerText.toLowerCase().includes('chain events') && el.innerText.trim().length < 20
// Define categories to look for
const categories = [
{ label: 'Chain Events', type: 'Chain' },
{ label: 'Dates', type: 'Date' },
{ label: 'Random Events', type: 'Random' },
{ 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 => {
// The buttons are usually in the same container or next container
let container = label.parentElement;
for (const header of headers) {
// Find buttons in the following siblings or parent siblings
let container = header.parentElement;
let foundButtons = [];
let attempts = 0;
while (container && container.querySelectorAll('button').length === 0 && attempts < 5) {
container = container.nextElementSibling || container.parentElement;
while (container && foundButtons.length === 0 && attempts < 5) {
foundButtons = Array.from(container.querySelectorAll('button')).filter(btn => {
const style = window.getComputedStyle(btn);
return style.display !== 'none' && style.visibility !== 'hidden';
});
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++;
if (container && container.tagName === 'BODY') break;
}
if (container) {
const btns = Array.from(container.querySelectorAll('button'));
btns.forEach(btn => {
const text = btn.innerText.trim();
const style = window.getComputedStyle(btn);
const isVisible = style.display !== 'none' && style.visibility !== 'hidden';
// Scrape each button in the category
for (const btn of foundButtons) {
const eventName = btn.innerText.trim();
if (!eventName || results.some(r => r.name === eventName)) continue;
// Count arrows for chain/date importance
const arrows = (eventName.match(/>|/g) || []).length;
try {
btn.scrollIntoViewIfNeeded ? btn.scrollIntoViewIfNeeded() : null;
await new Promise(r => setTimeout(r, 100));
btn.click();
await new Promise(r => setTimeout(r, 500));
// Look for arrows (regular or heavy)
if (isVisible && (text.includes('>') || text.includes(''))) {
buttons.push(btn);
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) {
const pop = popovers[popovers.length - 1];
const hasOrDivider = pop.querySelector('[class*="divider_or"]') !== null ||
pop.innerText.includes('Randomly either') ||
pop.innerText.toLowerCase().includes(' or ');
const skillLinks = Array.from(pop.querySelectorAll('span, a')).filter(el =>
el.innerText.length > 2 &&
!el.innerText.includes('Energy') &&
(window.getComputedStyle(el).color === 'rgb(102, 107, 255)' ||
el.className.includes('linkcolor'))
);
const skills = [];
skillLinks.forEach(link => {
const sName = link.innerText.trim();
if (sName && !skills.some(s => s.name === sName)) {
skills.push({ name: sName, is_or: hasOrDivider });
}
});
results.push({
name: eventName,
type: cat.type,
skills: skills,
arrows: arrows
});
}
});
document.body.click();
await new Promise(r => setTimeout(r, 100));
} catch (err) {
console.log(`Failed to scrape event ${eventName}: ${err.message}`);
}
}
});
return buttons;
};
const buttons = getChainEventButtons();
console.log(`Found ${buttons.length} chain event buttons`);
if (buttons.length === 0) {
return null;
}
let goldenPerkButton = null;
let maxArrows = 0;
for (const btn of buttons) {
const text = btn.innerText.trim();
// Count both regular and heavy arrows
const arrowCount = (text.match(/>|/g) || []).length;
// If it has three heavy arrows, it's almost certainly the golden perk
if (text.includes('')) {
goldenPerkButton = btn;
break;
}
if (arrowCount > maxArrows) {
maxArrows = arrowCount;
goldenPerkButton = btn;
}
}
if (!goldenPerkButton) {
console.log("No golden perk button found");
return null;
}
const eventName = goldenPerkButton.innerText.trim();
console.log(`Found Golden Perk: ${eventName} (${maxArrows} arrows)`);
try {
// Click to open popover
goldenPerkButton.scrollIntoViewIfNeeded ? goldenPerkButton.scrollIntoViewIfNeeded() : null;
await new Promise(r => setTimeout(r, 100));
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
if golden_perk_data:
cur.execute("""
INSERT INTO support_events (card_id, event_name, event_type)
VALUES (?, ?, ?)
""", (card_id, golden_perk_data['name'], golden_perk_data['type']))
event_id = cur.lastrowid
# 3. Store all found events and identify golden skills
if all_events_data:
# Determine max arrows for Chain and Dates to identify final step
max_arrows = {
'Chain': max([e['arrows'] for e in all_events_data if e['type'] == 'Chain'] + [0]),
'Date': max([e['arrows'] for e in all_events_data if e['type'] == 'Date'] + [0])
}
for skill in golden_perk_data['skills']:
# Normalization helper
def normalize(s):
return s.lower().replace(" hint +1", "").replace(" hint +3", "").replace(" hint +5", "").replace(" hint +", "").strip().replace(" ", " ").replace("-", "").replace("(", "").replace(")", "").replace(" ", "")
for event in all_events_data:
cur.execute("INSERT INTO support_events (card_id, event_name, event_type) VALUES (?, ?, ?)",
(card_id, event['name'], event['type']))
event_id = cur.lastrowid
skill_name = normalize(skill['name'])
# Use extra aggressive name matching against the map values
# (The map keys are already normalized)
is_gold = 0
for k, gold in skill_rarity_map.items():
if normalize(k) == skill_name:
is_gold = 1 if gold else 0
break
# Fallback 1: If it's a chain event and specifically the last one, it's almost certainly gold
if not is_gold and golden_perk_data.get('type') == 'Chain':
# Check for "hint" patterns which usually accompany gold perks in chain events
if "hint +" in skill['name'].lower() or len(golden_perk_data['skills']) <= 2:
is_gold = 1
print(f" ✨ Golden Skill Fallback (Last Chain Event): {skill['name']}")
if is_gold:
print(f"Golden Skill Verified: {skill['name']}")
cur.execute("""
INSERT INTO event_skills (event_id, skill_name, is_gold, is_or)
VALUES (?, ?, ?, ?)
""", (event_id, skill['name'], is_gold, 1 if skill['is_or'] else 0))
skill_count = len(golden_perk_data['skills'])
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)")
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()
n_name = normalize(skill['name'])
is_gold = 0
for k, gold in skill_rarity_map.items():
if normalize(k) == n_name:
is_gold = 1 if gold else 0
break
# Heuristic: If it's the last step of a Chain or Date, it's likely gold
if not is_gold and event['type'] in ['Chain', 'Date']:
if event['arrows'] >= 3 and event['arrows'] == max_arrows[event['type']]:
if len(event['skills']) <= 2 or "hint +" in skill['name'].lower():
is_gold = 1
print(f"Heuristic Gold: {skill['name']} in {event['name']}")
if is_gold: print(f" ✨ Verified Gold: {skill['name']}")
cur.execute("""
INSERT INTO event_skills (event_id, skill_name, is_gold, is_or)
VALUES (?, ?, ?, ?)
""", (event_id, skill['name'], is_gold, 1 if skill['is_or'] else 0))
print(f" Scraped {len(all_events_data)} total events.")
else:
print(f" No Golden Perk found for this card")
print(f" No events found.")
def run_scraper():
""" 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
VERSION: str = "13.0.3"
VERSION: str = "13.0.6"
# Application metadata
APP_NAME: str = "UmamusumeCardManager"