Compare commits
5 Commits
6914b620f1
...
f33de1073f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f33de1073f | ||
|
|
98a22e2593 | ||
| 7269b2d68f | |||
| 78d9fab189 | |||
| a6deeeaebf |
@@ -2,3 +2,9 @@ path: data/odkazy.xlsx
|
|||||||
|
|
||||||
screenshotter:
|
screenshotter:
|
||||||
target_path: data/screenshots/
|
target_path: data/screenshots/
|
||||||
|
|
||||||
|
resolver:
|
||||||
|
api_key: 733f6882605be2de8980bbd074091ee4
|
||||||
|
|
||||||
|
img_classifier:
|
||||||
|
target_path: "data/screenshots/"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "beaky"
|
name = "beaky"
|
||||||
version = "0.0.1"
|
version = "0.1.0"
|
||||||
description = "Scan tickets and decide"
|
description = "Scan tickets and decide"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -13,14 +13,17 @@ dependencies = [
|
|||||||
"pandas==3.0.1",
|
"pandas==3.0.1",
|
||||||
"openpyxl>=3.1.0",
|
"openpyxl>=3.1.0",
|
||||||
"PyYaml==6.0.3",
|
"PyYaml==6.0.3",
|
||||||
"playwright==1.58.0"
|
"playwright==1.58.0",
|
||||||
|
"requests>=2.32.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.2",
|
||||||
"ruff==0.15.5",
|
"ruff==0.15.5",
|
||||||
"pytz"
|
"pytz",
|
||||||
|
"types-requests",
|
||||||
|
"types-PyYAML",
|
||||||
# "playwright==1.58.0" # only dev because it cant be installed in a pipeline, just locally
|
# "playwright==1.58.0" # only dev because it cant be installed in a pipeline, just locally
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ lint.select = ["E", "F", "I"]
|
|||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
strict = true
|
strict = true
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["test"]
|
testpaths = ["test"]
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import yaml
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from beaky.config import Config
|
from beaky.config import Config
|
||||||
|
from beaky.link_classifier.classifier import LinkClassifier
|
||||||
|
from beaky.resolvers.resolver import _B, _GRAY, _GREEN, _R, _RED, _YELLOW, TicketResolver, TicketVerdict
|
||||||
from beaky.scanner.scanner import Links
|
from beaky.scanner.scanner import Links
|
||||||
from beaky.screenshotter.screenshotter import Screenshotter
|
from beaky.screenshotter.screenshotter import Screenshotter
|
||||||
from beaky.link_classifier.classifier import LinkClassifier
|
|
||||||
|
|
||||||
|
_VERDICT_COLOR = {
|
||||||
|
TicketVerdict.TRUTHFUL: _GREEN,
|
||||||
|
TicketVerdict.NOT_TRUTHFUL: _RED,
|
||||||
|
TicketVerdict.POSSIBLY_TRUTHFUL: _YELLOW,
|
||||||
|
TicketVerdict.UNKNOWN: _GRAY,
|
||||||
|
}
|
||||||
|
|
||||||
def load_config(path: str) -> Config | None:
|
def load_config(path: str) -> Config | None:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
@@ -22,7 +29,8 @@ def load_config(path: str) -> Config | None:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(prog="beaky")
|
parser = argparse.ArgumentParser(prog="beaky")
|
||||||
parser.add_argument("--config", help="Path to config file.", default="config/application.yml")
|
parser.add_argument("--config", help="Path to config file.", default="config/application.yml")
|
||||||
parser.add_argument("mode", choices=["screenshotter", "parser", "class"], help="Mode of operation.")
|
parser.add_argument("--id", type=int, help="Resolve a single ticket by id (only used with resolve mode).")
|
||||||
|
parser.add_argument("mode", choices=["screenshotter", "parser", "class", "resolve"], help="Mode of operation.")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
config = load_config(args.config)
|
config = load_config(args.config)
|
||||||
@@ -58,5 +66,23 @@ def main() -> None:
|
|||||||
for k, v in vars(bet).items():
|
for k, v in vars(bet).items():
|
||||||
print(f" {k}: {v}")
|
print(f" {k}: {v}")
|
||||||
|
|
||||||
|
if args.mode == "resolve":
|
||||||
|
classifier = LinkClassifier()
|
||||||
|
resolver = TicketResolver(config.resolver)
|
||||||
|
links = [l for l in data.links if l.id == args.id] if args.id is not None else data.links
|
||||||
|
if args.id is not None and not links:
|
||||||
|
print(f"ERROR: ticket id {args.id} not found")
|
||||||
|
return
|
||||||
|
for link in links:
|
||||||
|
print(f"\n=== Classifying ticket {link.id} ===")
|
||||||
|
ticket = classifier.classify(link)
|
||||||
|
for bet in ticket.bets:
|
||||||
|
print(f" [{type(bet).__name__}] {bet.team1Name} vs {bet.team2Name} | {bet.date.date()} | {bet.league}")
|
||||||
|
|
||||||
|
print(f"\n--- Resolving ticket {link.id} ---")
|
||||||
|
resolved = resolver.resolve(ticket)
|
||||||
|
color = _VERDICT_COLOR.get(resolved.verdict, "")
|
||||||
|
print(f"\n {color}{_B}VERDICT: {resolved.verdict.value.upper()}{_R}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
from pydantic.dataclasses import dataclass
|
from pydantic.dataclasses import dataclass
|
||||||
|
|
||||||
|
from beaky.image_classifier.config import ImgClassifierConfig
|
||||||
|
from beaky.resolvers.config import ResolverConfig
|
||||||
from beaky.screenshotter.config import ScreenshotterConfig
|
from beaky.screenshotter.config import ScreenshotterConfig
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
path: str
|
path: str
|
||||||
screenshotter: ScreenshotterConfig
|
screenshotter: ScreenshotterConfig
|
||||||
|
resolver: ResolverConfig
|
||||||
|
img_classifer: ImgClassifierConfig
|
||||||
|
|||||||
@@ -26,38 +26,38 @@ class Bet(ABC):
|
|||||||
date: datetime
|
date: datetime
|
||||||
league: str
|
league: str
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def resolve(self): pass
|
def resolve(self) -> None: pass
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WinDrawLose(Bet):
|
class WinDrawLose(Bet):
|
||||||
"""Výsledek zápasu 1X2"""
|
"""Výsledek zápasu 1X2"""
|
||||||
betType: Literal["X", "0", "1", "2"] = "0"
|
betType: Literal["X", "0", "1", "2"] = "0"
|
||||||
def resolve(self):
|
def resolve(self) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Advance(Bet):
|
class Advance(Bet):
|
||||||
"""What team advances to next round"""
|
"""What team advances to next round"""
|
||||||
def resolve(self):
|
def resolve(self) -> None:
|
||||||
raise NotImplementedError("Vyser si voko vine")
|
raise NotImplementedError("Vyser si voko vine")
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WinDrawLoseDouble(Bet):
|
class WinDrawLoseDouble(Bet):
|
||||||
"""Výsledek zápasu - double"""
|
"""Výsledek zápasu - double"""
|
||||||
betType: Literal["01", "12", "02"] = "01"
|
betType: Literal["01", "12", "02"] = "01"
|
||||||
def resolve(self):
|
def resolve(self) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WinLose(Bet):
|
class WinLose(Bet):
|
||||||
"""Výsledek zápasu bez remízy"""
|
"""Výsledek zápasu bez remízy"""
|
||||||
betType: Literal["1", "2"] = "1"
|
betType: Literal["1", "2"] = "1"
|
||||||
def resolve(self):
|
def resolve(self) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BothTeamScored(Bet):
|
class BothTeamScored(Bet):
|
||||||
def resolve(self):
|
def resolve(self) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -65,7 +65,7 @@ class GoalAmount(Bet):
|
|||||||
"""Počet gólů v zápasu — over/under total goals"""
|
"""Počet gólů v zápasu — over/under total goals"""
|
||||||
line: float = 0.0 # goal line, e.g. 2.5
|
line: float = 0.0 # goal line, e.g. 2.5
|
||||||
over: bool = True # True = more than line, False = less than line
|
over: bool = True # True = more than line, False = less than line
|
||||||
def resolve(self):
|
def resolve(self) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -73,16 +73,18 @@ class GoalHandicap(Bet):
|
|||||||
"""Goal handicap for a specific team — add handicap_amount to team's score, team wins = you win"""
|
"""Goal handicap for a specific team — add handicap_amount to team's score, team wins = you win"""
|
||||||
team_bet: Literal["1", "2"] = "1" # which team the handicap is applied to
|
team_bet: Literal["1", "2"] = "1" # which team the handicap is applied to
|
||||||
handicap_amount: float = 0.0 # e.g. +1.5 or -0.5
|
handicap_amount: float = 0.0 # e.g. +1.5 or -0.5
|
||||||
def resolve(self):
|
def resolve(self) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UnknownTicket(Bet):
|
class UnknownTicket(Bet):
|
||||||
"""Bet type that could not be classified"""
|
"""Bet type that could not be classified"""
|
||||||
raw_text: str = ""
|
raw_text: str = ""
|
||||||
def resolve(self):
|
def resolve(self) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Ticket:
|
class Ticket:
|
||||||
id: int
|
id: int
|
||||||
|
|||||||
@@ -1,7 +1,183 @@
|
|||||||
from datetime import datetime
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from beaky.datamodels.ticket import Ticket
|
import pytesseract
|
||||||
|
|
||||||
|
from beaky.datamodels.ticket import (
|
||||||
|
Advance,
|
||||||
|
Bet,
|
||||||
|
BetType,
|
||||||
|
BothTeamScored,
|
||||||
|
GoalAmount,
|
||||||
|
GoalHandicap,
|
||||||
|
Ticket,
|
||||||
|
UnknownTicket,
|
||||||
|
WinDrawLose,
|
||||||
|
WinDrawLoseDouble,
|
||||||
|
WinLose,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def f(path:str, date:datetime) -> list[Ticket]:
|
def img_to_text(path: str) -> str:
|
||||||
...
|
"""Given a path to an image, return the text contained in that image.
|
||||||
|
Bypasses PIL and lets Tesseract read the file directly.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = pytesseract.image_to_string(path)
|
||||||
|
return text.strip()
|
||||||
|
except pytesseract.TesseractNotFoundError:
|
||||||
|
print("Error: Tesseract executable not found on your system.")
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing {path}: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def classify(text: str) -> Bet:
|
||||||
|
"""Given text extracted from an image and a date, return a Bet object that is
|
||||||
|
relevant to that text."""
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return UnknownTicket(
|
||||||
|
ticketType=BetType.UNKNOWN,
|
||||||
|
team1Name="N/A",
|
||||||
|
team2Name="N/A",
|
||||||
|
date=datetime.datetime.now(),
|
||||||
|
league="N/A",
|
||||||
|
raw_text="No text extracted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Defaults & Normalization
|
||||||
|
text_lower = text.lower()
|
||||||
|
date_obj = datetime.datetime.now()
|
||||||
|
team1, team2 = "Unknown", "Unknown"
|
||||||
|
league = "Unknown"
|
||||||
|
|
||||||
|
# 2. Heuristic extraction of Teams (Looking for "Team A - Team B" patterns)
|
||||||
|
lines = [line.strip() for line in text.split("\n") if line.strip()]
|
||||||
|
for line in lines:
|
||||||
|
if " - " in line or " vs " in line or " v " in line:
|
||||||
|
# Avoid splitting on hyphens in dates or numbers
|
||||||
|
if not re.search(r"\d\s*-\s*\d", line):
|
||||||
|
parts = re.split(r" - | vs | v ", line)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
team1, team2 = parts[0].strip(), parts[1].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
# 3. Heuristic extraction of Date (Looking for DD.MM. YYYY HH:MM)
|
||||||
|
date_match = re.search(r"(\d{1,2}\.\s*\d{1,2}\.?\s*(?:\d{2,4})?)\s*(\d{1,2}:\d{2})?", text)
|
||||||
|
if date_match:
|
||||||
|
try:
|
||||||
|
# Fallback to current year if missing, basic parse attempt
|
||||||
|
date_str = f"{date_match.group(1).replace(' ', '')} {date_match.group(2) or '00:00'}"
|
||||||
|
if len(date_str.split(".")[2]) <= 5: # Missing year
|
||||||
|
date_str = date_str.replace(" ", f"{datetime.datetime.now().year} ")
|
||||||
|
date_obj = datetime.datetime.strptime(date_str, "%d.%m.%Y %H:%M")
|
||||||
|
except Exception:
|
||||||
|
pass # Keep default if parsing fails
|
||||||
|
|
||||||
|
# 4. Classification Logic based on keywords
|
||||||
|
base_args = {"team1Name": team1, "team2Name": team2, "date": date_obj, "league": league}
|
||||||
|
|
||||||
|
# Advance / Postup
|
||||||
|
if any(kw in text_lower for kw in ["postup", "postoupí", "advance"]):
|
||||||
|
return Advance(ticketType=BetType.ADVANCED, **base_args)
|
||||||
|
|
||||||
|
# Both Teams to Score / Oba dají gól
|
||||||
|
if any(kw in text_lower for kw in ["oba dají gól", "btts", "oba týmy dají gól"]):
|
||||||
|
return BothTeamScored(ticketType=BetType.BOTH_TEAM_SCORED, **base_args)
|
||||||
|
|
||||||
|
# Goal Amount (Over/Under)
|
||||||
|
if any(kw in text_lower for kw in ["počet gólů", "více než", "méně než", "over", "under"]):
|
||||||
|
# Attempt to find the goal line (e.g., 2.5, 3.5)
|
||||||
|
line_match = re.search(r"(\d+\.\d+)", text)
|
||||||
|
line_val = float(line_match.group(1)) if line_match else 2.5
|
||||||
|
is_over = any(kw in text_lower for kw in ["více", "over", "+"])
|
||||||
|
|
||||||
|
return GoalAmount(ticketType=BetType.GOAL_AMOUNT, line=line_val, over=is_over, **base_args)
|
||||||
|
|
||||||
|
# Goal Handicap
|
||||||
|
if any(kw in text_lower for kw in ["handicap", "hcp"]):
|
||||||
|
hcp_match = re.search(r"([+-]?\d+\.\d+)", text)
|
||||||
|
hcp_val = float(hcp_match.group(1)) if hcp_match else 0.0
|
||||||
|
# Simplistic logic: guess team 1 if not explicitly stated
|
||||||
|
team_bet = "2" if " 2 " in text else "1"
|
||||||
|
|
||||||
|
return GoalHandicap(ticketType=BetType.GOAL_HANDICAP, team_bet=team_bet, handicap_amount=hcp_val, **base_args)
|
||||||
|
|
||||||
|
# Win Draw Lose Double (1X, X2, 12)
|
||||||
|
if any(kw in text_lower for kw in ["1x", "x2", "12", "dvojitá šance", "neprohra"]):
|
||||||
|
bet_type = "01" if "1x" in text_lower else "02" if "x2" in text_lower else "12"
|
||||||
|
return WinDrawLoseDouble(ticketType=BetType.WIN_DRAW_LOSE_DOUBLE, betType=bet_type, **base_args)
|
||||||
|
|
||||||
|
# Win Lose (Draw no bet / Vítěz do rozhodnutí)
|
||||||
|
if any(kw in text_lower for kw in ["bez remízy", "vítěz do rozhodnutí", "konečný vítěz"]):
|
||||||
|
bet_type = "2" if re.search(r"\b2\b", text) else "1"
|
||||||
|
return WinLose(ticketType=BetType.WIN_LOSE, betType=bet_type, **base_args)
|
||||||
|
|
||||||
|
# Win Draw Lose (Standard Match Odds)
|
||||||
|
if any(kw in text_lower for kw in ["zápas", "výsledek zápasu", "1x2"]):
|
||||||
|
# Look for isolated 1, X (or 0), or 2
|
||||||
|
match_pick = re.search(r"\b(1|x|0|2)\b", text_lower)
|
||||||
|
bet_type = match_pick.group(1).upper() if match_pick else "1"
|
||||||
|
if bet_type == "X":
|
||||||
|
bet_type = "0"
|
||||||
|
|
||||||
|
return WinDrawLose(ticketType=BetType.WIN_DRAW_LOSE, betType=bet_type, **base_args)
|
||||||
|
|
||||||
|
# Fallback Unknown
|
||||||
|
return UnknownTicket(ticketType=BetType.UNKNOWN, raw_text=text, **base_args)
|
||||||
|
|
||||||
|
|
||||||
|
def img_classify(path: str, ticket_id: int) -> Ticket:
|
||||||
|
"""Given a path to an image and a date, return a list of Tickets that are
|
||||||
|
relevant to that image and date."""
|
||||||
|
# Define valid image extensions to ignore system files or text documents
|
||||||
|
ticket = Ticket(id=ticket_id, bets=[])
|
||||||
|
valid_extensions = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp"}
|
||||||
|
path_obj = Path(path)
|
||||||
|
|
||||||
|
if not path_obj.is_dir():
|
||||||
|
print(f"Error: The path '{path}' is not a valid directory.")
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
# Iterate through all files in the folder
|
||||||
|
for file_path in path_obj.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in valid_extensions:
|
||||||
|
# 1. Extract the text (called separately)
|
||||||
|
extracted_text = img_to_text(str(file_path))
|
||||||
|
print(extracted_text)
|
||||||
|
|
||||||
|
# 2. Classify based on the extracted text (called separately)
|
||||||
|
result = classify(extracted_text)
|
||||||
|
|
||||||
|
# 3. Add the resulting tickets to our main list
|
||||||
|
# Support classifier returning either a single Bet or a list of Bet
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
for r in result:
|
||||||
|
print(
|
||||||
|
r.date,
|
||||||
|
getattr(r, "ticketType", None),
|
||||||
|
r.team1Name,
|
||||||
|
r.team2Name,
|
||||||
|
r.league,
|
||||||
|
)
|
||||||
|
ticket.bets.extend(result)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
result.date,
|
||||||
|
getattr(result, "ticketType", None),
|
||||||
|
result.team1Name,
|
||||||
|
result.team2Name,
|
||||||
|
result.league,
|
||||||
|
)
|
||||||
|
ticket.bets.append(result)
|
||||||
|
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
img_classify("./data/screenshots/", ticket_id=1)
|
||||||
|
|||||||
6
src/beaky/image_classifier/config.py
Normal file
6
src/beaky/image_classifier/config.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic.dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImgClassifierConfig:
|
||||||
|
target_path: str
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from playwright.sync_api import Page, sync_playwright
|
from playwright.sync_api import Page, sync_playwright
|
||||||
|
|
||||||
from beaky.datamodels.ticket import (
|
from beaky.datamodels.ticket import (
|
||||||
|
Bet,
|
||||||
|
BetType,
|
||||||
BothTeamScored,
|
BothTeamScored,
|
||||||
GoalAmount,
|
GoalAmount,
|
||||||
GoalHandicap,
|
GoalHandicap,
|
||||||
Ticket,
|
Ticket,
|
||||||
BetType,
|
|
||||||
UnknownTicket,
|
UnknownTicket,
|
||||||
WinDrawLose,
|
WinDrawLose,
|
||||||
WinDrawLoseDouble,
|
WinDrawLoseDouble,
|
||||||
WinLose,
|
WinLose,
|
||||||
Bet
|
|
||||||
)
|
)
|
||||||
from beaky.scanner.scanner import Link
|
from beaky.scanner.scanner import Link
|
||||||
|
|
||||||
@@ -37,24 +38,24 @@ def _parse_teams(title: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _classify_bet(bet_text: str, team1: str, team2: str, date: datetime, league: str) -> Bet:
|
def _classify_bet(bet_text: str, team1: str, team2: str, date: datetime, league: str) -> Bet:
|
||||||
common = dict(team1Name=team1, team2Name=team2, date=date, league=league)
|
common: dict[str, Any] = dict(team1Name=team1, team2Name=team2, date=date, league=league)
|
||||||
|
|
||||||
# WinDrawLose double: "Výsledek zápasu - dvojtip: 10"
|
# WinDrawLose double: "Výsledek zápasu - dvojtip: 10"
|
||||||
m = re.search(r"Výsledek zápasu - dvojtip:\s*(\d+)", bet_text)
|
m = re.search(r"Výsledek zápasu - dvojtip:\s*(\d+)", bet_text)
|
||||||
if m:
|
if m:
|
||||||
# normalize order: "10" -> "01", "02" -> "02", "12" -> "12"
|
# normalize order: "10" -> "01", "02" -> "02", "12" -> "12"
|
||||||
bet_type = "".join(sorted(m.group(1)))
|
bet_type = "".join(sorted(m.group(1)))
|
||||||
return WinDrawLoseDouble(ticketType=BetType.WIN_DRAW_LOSE_DOUBLE, betType=bet_type, **common)
|
return WinDrawLoseDouble(ticketType=BetType.WIN_DRAW_LOSE_DOUBLE, betType=bet_type, **common) # type: ignore[arg-type]
|
||||||
|
|
||||||
# WinLose (no draw): "Výsledek bez remízy: 1"
|
# WinLose (no draw): "Výsledek bez remízy: 1"
|
||||||
m = re.search(r"bez rem[ií]zy:\s*([12])", bet_text)
|
m = re.search(r"bez rem[ií]zy:\s*([12])", bet_text)
|
||||||
if m:
|
if m:
|
||||||
return WinLose(ticketType=BetType.WIN_LOSE, betType=m.group(1), **common)
|
return WinLose(ticketType=BetType.WIN_LOSE, betType=m.group(1), **common) # type: ignore[arg-type]
|
||||||
|
|
||||||
# WinDrawLose: "Výsledek zápasu: 1"
|
# WinDrawLose: "Výsledek zápasu: 1"
|
||||||
m = re.search(r"Výsledek zápasu:\s*([012X])\s*$", bet_text.strip())
|
m = re.search(r"Výsledek zápasu:\s*([012X])\s*$", bet_text.strip())
|
||||||
if m:
|
if m:
|
||||||
return WinDrawLose(ticketType=BetType.WIN_DRAW_LOSE, betType=m.group(1), **common)
|
return WinDrawLose(ticketType=BetType.WIN_DRAW_LOSE, betType=m.group(1), **common) # type: ignore[arg-type]
|
||||||
|
|
||||||
# BothTeamScored: "Každý z týmů dá gól v zápasu: Ano"
|
# BothTeamScored: "Každý z týmů dá gól v zápasu: Ano"
|
||||||
if "dá gól" in bet_text or "oba týmy" in bet_text.lower():
|
if "dá gól" in bet_text or "oba týmy" in bet_text.lower():
|
||||||
@@ -77,7 +78,7 @@ def _classify_bet(bet_text: str, team1: str, team2: str, date: datetime, league:
|
|||||||
return UnknownTicket(ticketType=BetType.UNKNOWN, raw_text=bet_text, **common)
|
return UnknownTicket(ticketType=BetType.UNKNOWN, raw_text=bet_text, **common)
|
||||||
sign = 1.0 if m.group(1) == "+" else -1.0
|
sign = 1.0 if m.group(1) == "+" else -1.0
|
||||||
handicap = sign * float(m.group(2))
|
handicap = sign * float(m.group(2))
|
||||||
return GoalHandicap(ticketType=BetType.GOAL_HANDICAP, team_bet=team_bet, handicap_amount=handicap, **common)
|
return GoalHandicap(ticketType=BetType.GOAL_HANDICAP, team_bet=team_bet, handicap_amount=handicap, **common) # type: ignore[arg-type]
|
||||||
|
|
||||||
return UnknownTicket(ticketType=BetType.UNKNOWN, raw_text=bet_text, **common)
|
return UnknownTicket(ticketType=BetType.UNKNOWN, raw_text=bet_text, **common)
|
||||||
|
|
||||||
|
|||||||
0
src/beaky/resolvers/__init__.py
Normal file
0
src/beaky/resolvers/__init__.py
Normal file
6
src/beaky/resolvers/config.py
Normal file
6
src/beaky/resolvers/config.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic.dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolverConfig:
|
||||||
|
api_key: str
|
||||||
354
src/beaky/resolvers/resolver.py
Normal file
354
src/beaky/resolvers/resolver.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from beaky.datamodels.ticket import (
|
||||||
|
Bet,
|
||||||
|
BothTeamScored,
|
||||||
|
GoalAmount,
|
||||||
|
GoalHandicap,
|
||||||
|
Ticket,
|
||||||
|
UnknownTicket,
|
||||||
|
WinDrawLose,
|
||||||
|
WinDrawLoseDouble,
|
||||||
|
WinLose,
|
||||||
|
)
|
||||||
|
from beaky.resolvers.config import ResolverConfig
|
||||||
|
|
||||||
|
_API_BASE = "https://v3.football.api-sports.io"
|
||||||
|
|
||||||
|
# Fortuna league strings (lowercased substring match) -> api-football league ID
|
||||||
|
_LEAGUE_MAP: dict[str, int] = {
|
||||||
|
# European cups
|
||||||
|
"liga mistrů": 2,
|
||||||
|
"champions league": 2,
|
||||||
|
"evropská liga": 3,
|
||||||
|
"europa league": 3,
|
||||||
|
"konferenční liga": 848,
|
||||||
|
"conference league": 848,
|
||||||
|
# Top flights
|
||||||
|
"1. anglie": 39,
|
||||||
|
"1. belgie": 144,
|
||||||
|
"1. česko": 345,
|
||||||
|
"1. dánsko": 119,
|
||||||
|
"1. francie": 61,
|
||||||
|
"1. itálie": 135,
|
||||||
|
"1. itálie - ženy": 794,
|
||||||
|
"1. německo": 78,
|
||||||
|
"1. nizozemsko": 88,
|
||||||
|
"1. polsko": 106,
|
||||||
|
"1. portugalsko": 94,
|
||||||
|
"1. rakousko": 218,
|
||||||
|
"1. rumunsko": 283,
|
||||||
|
"1. skotsko": 179,
|
||||||
|
"1. slovensko": 332,
|
||||||
|
"1. španělsko": 140,
|
||||||
|
"1. wales": 771,
|
||||||
|
# Second divisions
|
||||||
|
"2. anglie": 40,
|
||||||
|
"2. česko": 346,
|
||||||
|
"2. francie": 62,
|
||||||
|
"2. itálie": 136,
|
||||||
|
"2. německo": 79,
|
||||||
|
"2. nizozemsko": 89,
|
||||||
|
"2. rakousko": 219,
|
||||||
|
"2. slovensko": 333,
|
||||||
|
"2. španělsko": 141,
|
||||||
|
# Third divisions
|
||||||
|
"3. francie": 63,
|
||||||
|
"3. česko msfl": 349,
|
||||||
|
"3. česko čfl": 348,
|
||||||
|
# Fourth divisions
|
||||||
|
"4. česko - sk. a": 350,
|
||||||
|
"4. česko - sk. b": 351,
|
||||||
|
"4. česko - sk. c": 352,
|
||||||
|
"4. česko - sk. d": 353,
|
||||||
|
"4. česko - sk. e": 354,
|
||||||
|
"4. česko - sk. f": 686,
|
||||||
|
# Women
|
||||||
|
"1. česko - ženy": 669,
|
||||||
|
"fortuna=liga ženy": 669,
|
||||||
|
# Domestic cups
|
||||||
|
"anglie - fa cup": 45,
|
||||||
|
"anglie - efl cup": 48,
|
||||||
|
"česko - pohár": 347,
|
||||||
|
}
|
||||||
|
|
||||||
|
_DATE_WINDOW = 3 # days either side of the bet date to search
|
||||||
|
|
||||||
|
# ANSI color helpers
|
||||||
|
_R = "\033[0m"
|
||||||
|
_B = "\033[1m"
|
||||||
|
_DIM= "\033[2m"
|
||||||
|
_GREEN = "\033[32m"
|
||||||
|
_RED = "\033[31m"
|
||||||
|
_YELLOW = "\033[33m"
|
||||||
|
_CYAN = "\033[36m"
|
||||||
|
_GRAY = "\033[90m"
|
||||||
|
|
||||||
|
_OUTCOME_COLOR = {
|
||||||
|
"win": _GREEN,
|
||||||
|
"lose": _RED,
|
||||||
|
"void": _YELLOW,
|
||||||
|
"unknown": _GRAY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TicketVerdict(str, Enum):
|
||||||
|
TRUTHFUL = "truthful"
|
||||||
|
NOT_TRUTHFUL = "not truthful"
|
||||||
|
POSSIBLY_TRUTHFUL = "possibly truthful — unresolvable bets remain, check manually"
|
||||||
|
UNKNOWN = "unknown — could not resolve enough bets to decide"
|
||||||
|
|
||||||
|
|
||||||
|
class BetOutcome(str, Enum):
|
||||||
|
WIN = "win"
|
||||||
|
LOSE = "lose"
|
||||||
|
VOID = "void" # stake returned (e.g. WinLose on draw, integer goal line hit)
|
||||||
|
UNKNOWN = "unknown" # fixture not found or unclassified bet
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedBet:
|
||||||
|
bet: Bet
|
||||||
|
outcome: BetOutcome
|
||||||
|
fixture_id: int | None = None
|
||||||
|
# Confidence breakdown (each component 0.0–1.0):
|
||||||
|
# name_match — how well team names matched (SequenceMatcher score)
|
||||||
|
# date_proximity — 1.0 exact date, linear decay to 0.0 at _DATE_WINDOW days away
|
||||||
|
# league_found — 1.0 static map hit, 0.7 API fallback, 0.3 not found
|
||||||
|
# match_finished — 1.0 if fixture status is terminal, 0.0 otherwise
|
||||||
|
confidence: float = 0.0
|
||||||
|
name_match: float = 0.0
|
||||||
|
date_proximity: float = 0.0
|
||||||
|
league_found: float = 0.0
|
||||||
|
match_finished: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedTicket:
|
||||||
|
ticket_id: int
|
||||||
|
bets: list[ResolvedBet] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def verdict(self) -> TicketVerdict:
|
||||||
|
resolvable = [b for b in self.bets if not isinstance(b.bet, UnknownTicket)]
|
||||||
|
unresolvable = [b for b in self.bets if isinstance(b.bet, UnknownTicket)]
|
||||||
|
if not resolvable:
|
||||||
|
return TicketVerdict.UNKNOWN
|
||||||
|
if any(b.outcome == BetOutcome.LOSE for b in resolvable):
|
||||||
|
return TicketVerdict.NOT_TRUTHFUL
|
||||||
|
if any(b.outcome == BetOutcome.UNKNOWN for b in resolvable):
|
||||||
|
return TicketVerdict.UNKNOWN
|
||||||
|
if unresolvable:
|
||||||
|
return TicketVerdict.POSSIBLY_TRUTHFUL
|
||||||
|
return TicketVerdict.TRUTHFUL
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str, headers: dict[str, str], params: dict[str, str | int], retries: int = 3, backoff: float = 60.0) -> requests.Response:
|
||||||
|
for attempt in range(retries):
|
||||||
|
resp = requests.get(url, headers=headers, params=params)
|
||||||
|
if resp.status_code == 429:
|
||||||
|
wait = backoff * (attempt + 1)
|
||||||
|
print(f" !! rate limited — waiting {wait:.0f}s before retry ({attempt + 1}/{retries})")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
return resp
|
||||||
|
print(f" !! still rate limited after {retries} retries, giving up")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
class TicketResolver:
|
||||||
|
def __init__(self, config: ResolverConfig):
|
||||||
|
self._headers = {"x-apisports-key": config.api_key}
|
||||||
|
# Cache maps (center_date_str, league_id | None) -> list of fixture dicts
|
||||||
|
self._fixture_cache: dict[tuple[str, int | None], list[dict[str, Any]]] = {}
|
||||||
|
# Cache maps league name -> (league_id, confidence)
|
||||||
|
self._league_cache: dict[str, tuple[int | None, float]] = {}
|
||||||
|
|
||||||
|
def resolve(self, ticket: Ticket) -> ResolvedTicket:
|
||||||
|
result = ResolvedTicket(ticket_id=ticket.id)
|
||||||
|
for bet in ticket.bets:
|
||||||
|
result.bets.append(self._resolve_bet(bet))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _resolve_bet(self, bet: Bet) -> ResolvedBet:
|
||||||
|
bet_type = type(bet).__name__
|
||||||
|
print(f"\n {_B}{_CYAN}┌─ [{bet_type}]{_R} {_B}{bet.team1Name} vs {bet.team2Name}{_R}"
|
||||||
|
f" {_DIM}{bet.date.strftime('%Y-%m-%d')} | {bet.league}{_R}")
|
||||||
|
|
||||||
|
if isinstance(bet, UnknownTicket):
|
||||||
|
print(f" {_GRAY}│ skipping — not implemented: {bet.raw_text!r}{_R}")
|
||||||
|
print(f" {_GRAY}└─ UNKNOWN{_R}")
|
||||||
|
return ResolvedBet(bet=bet, outcome=BetOutcome.UNKNOWN)
|
||||||
|
|
||||||
|
fixture, name_match, date_prox, league_conf = self._find_fixture(bet)
|
||||||
|
if fixture is None:
|
||||||
|
print(f" {_GRAY}└─ UNKNOWN — no fixture found{_R}")
|
||||||
|
return ResolvedBet(bet=bet, outcome=BetOutcome.UNKNOWN, league_found=league_conf)
|
||||||
|
|
||||||
|
home_name = fixture["teams"]["home"]["name"]
|
||||||
|
away_name = fixture["teams"]["away"]["name"]
|
||||||
|
finished = _is_finished(fixture)
|
||||||
|
confidence = round((name_match + date_prox + league_conf + finished) / 4, 3)
|
||||||
|
outcome = _evaluate_bet(bet, fixture) if finished == 1.0 else BetOutcome.UNKNOWN
|
||||||
|
|
||||||
|
goals = fixture["goals"]
|
||||||
|
color = _OUTCOME_COLOR.get(outcome.value, _GRAY)
|
||||||
|
print(f" {_DIM}│ matched #{fixture['fixture']['id']}: {home_name} vs {away_name}"
|
||||||
|
f" | {goals['home']}:{goals['away']} | {fixture['fixture']['status']['short']}"
|
||||||
|
f" | confidence {confidence} (name={name_match:.2f} date={date_prox:.2f} league={league_conf} finished={finished}){_R}")
|
||||||
|
print(f" {color}{_B}└─ {outcome.value.upper()}{_R}")
|
||||||
|
|
||||||
|
return ResolvedBet(
|
||||||
|
bet=bet,
|
||||||
|
outcome=outcome,
|
||||||
|
fixture_id=fixture["fixture"]["id"],
|
||||||
|
confidence=confidence,
|
||||||
|
name_match=round(name_match, 3),
|
||||||
|
date_proximity=round(date_prox, 3),
|
||||||
|
league_found=league_conf,
|
||||||
|
match_finished=finished,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_fixture(self, bet: Bet) -> tuple[dict[str, Any] | None, float, float, float]:
|
||||||
|
"""Returns (fixture, name_match, date_proximity, league_confidence)."""
|
||||||
|
center = bet.date.date()
|
||||||
|
date_str = center.strftime("%Y-%m-%d")
|
||||||
|
league_id, league_conf = self._resolve_league(bet.league)
|
||||||
|
cache_key = (date_str, league_id)
|
||||||
|
|
||||||
|
if cache_key not in self._fixture_cache:
|
||||||
|
date_from = (center - timedelta(days=_DATE_WINDOW)).strftime("%Y-%m-%d")
|
||||||
|
date_to = (center + timedelta(days=_DATE_WINDOW)).strftime("%Y-%m-%d")
|
||||||
|
params: dict[str, str | int] = {"from": date_from, "to": date_to}
|
||||||
|
if league_id is not None:
|
||||||
|
params["league"] = league_id
|
||||||
|
params["season"] = center.year if center.month >= 7 else center.year - 1
|
||||||
|
print(f" {_GRAY}│ GET /fixtures {params}{_R}")
|
||||||
|
resp = _get(f"{_API_BASE}/fixtures", headers=self._headers, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._fixture_cache[cache_key] = resp.json().get("response", [])
|
||||||
|
print(f" {_GRAY}│ {len(self._fixture_cache[cache_key])} fixtures returned (cached){_R}")
|
||||||
|
else:
|
||||||
|
print(f" {_GRAY}│ /fixtures (±{_DATE_WINDOW}d of {date_str}, league={league_id}) served from cache{_R}")
|
||||||
|
|
||||||
|
fixture, name_match, date_prox = _best_fixture_match(
|
||||||
|
self._fixture_cache[cache_key], bet.team1Name, bet.team2Name, center
|
||||||
|
)
|
||||||
|
return fixture, name_match, date_prox, league_conf
|
||||||
|
|
||||||
|
def _resolve_league(self, league_name: str) -> tuple[int | None, float]:
|
||||||
|
key = league_name.lower().strip()
|
||||||
|
if key in self._league_cache:
|
||||||
|
return self._league_cache[key]
|
||||||
|
|
||||||
|
for pattern, league_id in _LEAGUE_MAP.items():
|
||||||
|
if pattern in key:
|
||||||
|
print(f" {_GRAY}│ league {league_name!r} -> id={league_id} (static map){_R}")
|
||||||
|
self._league_cache[key] = (league_id, 1.0)
|
||||||
|
return league_id, 1.0
|
||||||
|
|
||||||
|
# Fall back to API search — lower confidence since first result is taken unverified
|
||||||
|
print(f" {_GRAY}│ GET /leagues search={league_name!r}{_R}")
|
||||||
|
resp = _get(f"{_API_BASE}/leagues", headers=self._headers, params={"search": league_name[:20]})
|
||||||
|
results = resp.json().get("response", [])
|
||||||
|
if results:
|
||||||
|
league_id = results[0]["league"]["id"]
|
||||||
|
league_found_name = results[0]["league"]["name"]
|
||||||
|
print(f" {_GRAY}│ matched {league_found_name!r} id={league_id} (API fallback, confidence=0.7){_R}")
|
||||||
|
self._league_cache[key] = (league_id, 0.7)
|
||||||
|
return league_id, 0.7
|
||||||
|
|
||||||
|
print(f" {_GRAY}│ no league found, searching fixtures by date only (confidence=0.3){_R}")
|
||||||
|
self._league_cache[key] = (None, 0.3)
|
||||||
|
return None, 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def _similarity(a: str, b: str) -> float:
|
||||||
|
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def _date_proximity(fixture: dict[str, Any], center: date) -> float:
|
||||||
|
"""1.0 on exact date, linear decay to 0.0 at _DATE_WINDOW days away."""
|
||||||
|
fixture_date = datetime.fromisoformat(fixture["fixture"]["date"].replace("Z", "+00:00")).date()
|
||||||
|
days_off = abs((fixture_date - center).days)
|
||||||
|
return max(0.0, 1.0 - days_off / _DATE_WINDOW)
|
||||||
|
|
||||||
|
|
||||||
|
def _best_fixture_match(fixtures: list[dict[str, Any]], team1: str, team2: str, center: date) -> tuple[dict[str, Any] | None, float, float]:
|
||||||
|
"""Returns (best_fixture, name_score, date_proximity) or (None, 0, 0) if no good match."""
|
||||||
|
best, best_combined, best_name, best_date = None, 0.0, 0.0, 0.0
|
||||||
|
for f in fixtures:
|
||||||
|
home = f["teams"]["home"]["name"]
|
||||||
|
away = f["teams"]["away"]["name"]
|
||||||
|
name_score = max(
|
||||||
|
_similarity(team1, home) + _similarity(team2, away),
|
||||||
|
_similarity(team1, away) + _similarity(team2, home),
|
||||||
|
) / 2
|
||||||
|
date_prox = _date_proximity(f, center)
|
||||||
|
# Name similarity is the primary signal; date proximity is a tiebreaker
|
||||||
|
combined = name_score * 0.8 + date_prox * 0.2
|
||||||
|
if combined > best_combined:
|
||||||
|
best_combined = combined
|
||||||
|
best_name = name_score
|
||||||
|
best_date = date_prox
|
||||||
|
best = f
|
||||||
|
# Require minimum name similarity — date alone cannot rescue a bad name match
|
||||||
|
return (best, best_name, best_date) if best_name > 0.5 else (None, best_name, best_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_finished(fixture: dict[str, Any]) -> float:
|
||||||
|
status = fixture.get("fixture", {}).get("status", {}).get("short", "")
|
||||||
|
return 1.0 if status in ("FT", "AET", "PEN", "AWD", "WO") else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_bet(bet: Bet, fixture: dict[str, Any]) -> BetOutcome:
|
||||||
|
goals = fixture.get("goals", {})
|
||||||
|
home = goals.get("home")
|
||||||
|
away = goals.get("away")
|
||||||
|
|
||||||
|
if home is None or away is None:
|
||||||
|
return BetOutcome.UNKNOWN
|
||||||
|
|
||||||
|
if isinstance(bet, WinDrawLose):
|
||||||
|
bet_draw = bet.betType in ("X", "0")
|
||||||
|
if bet_draw:
|
||||||
|
return BetOutcome.WIN if home == away else BetOutcome.LOSE
|
||||||
|
actual = "1" if home > away else ("0" if home == away else "2")
|
||||||
|
return BetOutcome.WIN if actual == bet.betType else BetOutcome.LOSE
|
||||||
|
|
||||||
|
if isinstance(bet, WinDrawLoseDouble):
|
||||||
|
actual = "1" if home > away else ("0" if home == away else "2")
|
||||||
|
return BetOutcome.WIN if actual in bet.betType else BetOutcome.LOSE
|
||||||
|
|
||||||
|
if isinstance(bet, WinLose):
|
||||||
|
if home == away:
|
||||||
|
return BetOutcome.VOID
|
||||||
|
actual = "1" if home > away else "2"
|
||||||
|
return BetOutcome.WIN if actual == bet.betType else BetOutcome.LOSE
|
||||||
|
|
||||||
|
if isinstance(bet, BothTeamScored):
|
||||||
|
return BetOutcome.WIN if home > 0 and away > 0 else BetOutcome.LOSE
|
||||||
|
|
||||||
|
if isinstance(bet, GoalAmount):
|
||||||
|
total = home + away
|
||||||
|
if total == bet.line:
|
||||||
|
return BetOutcome.VOID
|
||||||
|
won = total > bet.line if bet.over else total < bet.line
|
||||||
|
return BetOutcome.WIN if won else BetOutcome.LOSE
|
||||||
|
|
||||||
|
if isinstance(bet, GoalHandicap):
|
||||||
|
h_home = home + (bet.handicap_amount if bet.team_bet == "1" else 0.0)
|
||||||
|
h_away = away + (bet.handicap_amount if bet.team_bet == "2" else 0.0)
|
||||||
|
if h_home == h_away:
|
||||||
|
return BetOutcome.VOID
|
||||||
|
actual_winner = "1" if h_home > h_away else "2"
|
||||||
|
return BetOutcome.WIN if actual_winner == bet.team_bet else BetOutcome.LOSE
|
||||||
|
|
||||||
|
return BetOutcome.UNKNOWN
|
||||||
@@ -21,6 +21,7 @@ class Link:
|
|||||||
url: str
|
url: str
|
||||||
date: Optional[datetime] = None
|
date: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class Links:
|
class Links:
|
||||||
def __init__(self, path: str | Config):
|
def __init__(self, path: str | Config):
|
||||||
if isinstance(path, Config):
|
if isinstance(path, Config):
|
||||||
@@ -123,10 +124,6 @@ class Links:
|
|||||||
return len(self.links)
|
return len(self.links)
|
||||||
|
|
||||||
|
|
||||||
# Backwards-compatible alias in case other modules referenced Linker
|
|
||||||
Linker = Links
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
links_obj = Links("data/odkazy.xlsx")
|
links_obj = Links("data/odkazy.xlsx")
|
||||||
links = links_obj.ret_links()
|
links = links_obj.ret_links()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ class Screenshotter:
|
|||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
|
||||||
def capture_tickets(self, links: list[Link]):
|
def capture_tickets(self, links: list[Link]) -> None:
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
browser = p.chromium.launch(headless=True)
|
browser = p.chromium.launch(headless=True)
|
||||||
context = browser.new_context()
|
context = browser.new_context()
|
||||||
@@ -24,21 +25,19 @@ class Screenshotter:
|
|||||||
|
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
def capture_ticket(self,page, url, target_path, ticket_selector=".betslip-history-detail__left-panel"):
|
def capture_ticket(self, page: Any, url: str, target_path: Path, ticket_selector: str = ".betslip-history-detail__left-panel") -> None:
|
||||||
page.goto(url)
|
page.goto(url)
|
||||||
page.wait_for_selector(ticket_selector, timeout=10000)
|
page.wait_for_selector(ticket_selector, timeout=10000)
|
||||||
page.wait_for_timeout(1000)
|
page.wait_for_timeout(1000)
|
||||||
page.evaluate(f"""
|
page.evaluate(f"""
|
||||||
let el = document.querySelector('{ticket_selector}');
|
let el = document.querySelector('{ticket_selector}');
|
||||||
if (el) {{
|
if (el) {{
|
||||||
// Roztáhneme samotný kontejner se zápasy
|
|
||||||
let wrapper = el.querySelector('.betslip-selections');
|
let wrapper = el.querySelector('.betslip-selections');
|
||||||
if (wrapper) {{
|
if (wrapper) {{
|
||||||
wrapper.style.setProperty('height', 'auto', 'important');
|
wrapper.style.setProperty('height', 'auto', 'important');
|
||||||
wrapper.style.setProperty('overflow', 'visible', 'important');
|
wrapper.style.setProperty('overflow', 'visible', 'important');
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// A teď projdeme všechny nadřazené prvky až k <body>
|
|
||||||
while (el && el !== document.body) {{
|
while (el && el !== document.body) {{
|
||||||
el.style.setProperty('height', 'auto', 'important');
|
el.style.setProperty('height', 'auto', 'important');
|
||||||
el.style.setProperty('max-height', 'none', 'important');
|
el.style.setProperty('max-height', 'none', 'important');
|
||||||
@@ -62,4 +61,16 @@ class Screenshotter:
|
|||||||
}});
|
}});
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Resize viewport if the element extends beyond the bottom edge.
|
||||||
|
# The modal is vertically centered, so increasing the viewport by X shifts the element
|
||||||
|
# down by X/2. To compensate: new_height = 2 * bottom - current_height.
|
||||||
|
bbox = page.locator(ticket_selector).bounding_box()
|
||||||
|
bottom = bbox["y"] + bbox["height"]
|
||||||
|
vp_h = page.viewport_size["height"]
|
||||||
|
if bottom > vp_h:
|
||||||
|
page.set_viewport_size({"width": page.viewport_size["width"], "height": int(2 * bottom - vp_h) + 10})
|
||||||
|
# Wait for the browser to reflow after style changes before screenshotting
|
||||||
|
page.wait_for_timeout(500)
|
||||||
page.locator(ticket_selector).screenshot(path=target_path)
|
page.locator(ticket_selector).screenshot(path=target_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user