Files
flashscore/parser.go
2026-07-21 03:49:47 +03:00

1595 lines
36 KiB
Go
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package flashscore
import (
"encoding/json"
"fmt"
"html"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
xhtml "golang.org/x/net/html"
"gordenko.dev/dima/flashscore/model"
"gordenko.dev/dima/pretty"
)
// Парсит кастомный формат Flashscore. На выходе список матчей,
// без группировок по турнирам, раундам
/*
func parseListOfScheduledMatches(content string, sportID int) (result []ScheduledMatch, err error) {
matches := strings.Split(content, SymbolTilda)
for _, match := range matches {
match = strings.TrimSpace(match)
if match == "" {
continue
}
pairs := strings.Split(match, SymbolNot)
sm := ScheduledMatch{
SportID: sportID,
}
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
kv := strings.Split(pair, SymbolDivide)
if len(kv) != 2 {
err = fmt.Errorf("Wrong key/value: %s", pair)
return
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
switch key {
case "AA":
//
sm.MatchID = value
case "AD":
var timestamp int64
timestamp, err = strconv.ParseInt(value, 10, 64)
if err != nil {
err = fmt.Errorf("Parse start time: %s; unixtime=%s", err, value)
return
}
sm.StartTime = time.Unix(timestamp, 0)
}
}
if sm.MatchID != "" && !sm.StartTime.IsZero() {
// Помимо матчей встречаются чемпионаты, поэтому эти поля могут быть пустыми
result = append(result, sm)
}
}
return
}
*/
//ZA zone: champ name extended
//ZL "champID"
/*
AA: 2XhOe000
AD: 1595772000
ADE: 1595772000
AB: 1
CR: 1
AC: 1
CX: Southampton
ER: Round 38
RW: 0
AX: 0
BX: -1
WQ:
WM: SOU
AE: Southampton
JA: fu16YetP
WU: southampton
WN: SHU
AF: Sheffield Utd
JB: bDRnslpP
WV: sheffield-utd
AW: 1
AN: n
Расшифровка:
ZA - champ name
AA - ID матча, нужен чтобы получить статистику по матчу
AD / ADE - время начала матча в UTC
CX - home team
AF - away team
WU - home team url part (нужно ли ?)
WV - away team url part (нужно ли ?)
ER - раунд (можно не парсить, но сгруппировать)
*/
//var reRoundNumber = regexp.MustCompile(`(?i)Round\s+(\d+)`)
// parseEncodedFixtures - парсит строку (html unescaped)
//func parseEncodedFixtures(content string) (rounds []FootballRound, err error) {
func parseMatchesResults(trs *goquery.Selection) (results []model.TeamMatch, err error) {
for idx := range trs.Nodes {
var m model.TeamMatch
m, err = parseMatchResultTr(trs.Eq(idx))
if err != nil {
err = fmt.Errorf("parseTr: %s", err)
return
}
results = append(results, m)
}
return
}
var reMatchIDinMatchResultTr = regexp.MustCompile(`(?i)open\('g_\d+_([^']+)'`)
func parseMatchResultTr(trSelection *goquery.Selection) (m model.TeamMatch, err error) {
onclickContent, exists := trSelection.Attr("onclick")
if !exists {
err = fmt.Errorf("onclick attr not found in match result tr")
return
}
groups := reMatchIDinMatchResultTr.FindStringSubmatch(onclickContent)
if len(groups) != 2 {
err = fmt.Errorf("MatchID not found in onclick attr of match result tr")
return
}
m.MatchID = groups[1]
//fmt.Printf("onclick: %s, exists: %t\n", val, exists)
//spanSelection := trSelection.Find("span")
tdSelection := trSelection.Find("td")
// Достаем дату
spanList := tdSelection.Eq(0).Find("span")
if spanList == nil {
//
return
}
//timestampStr := spanList.Get(0).FirstChild.Data
//fmt.Printf("Date: %s\n", timestampStr)
/*
m.StartTime, err = strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
err = fmt.Errorf("Parse timestamp string: %s; timestamp=%s", err, timestampStr)
return
}
*/
var score string
score, err = parseTdContent(tdSelection.Eq(4))
if err != nil {
err = fmt.Errorf("Get score: %s", err)
return
}
m.HomeGoals, m.AwayGoals, err = parseScore(score)
if err != nil {
err = fmt.Errorf("parseScore: %s; score=%s", err, score)
return
}
// ВАЖНО!
m.IsMatchScoreLoaded = true
return
}
func parseTdContent(tdSelection *goquery.Selection) (team string, err error) {
// Достаем home
spanList := tdSelection.Find("span")
if spanList.Length() == 0 {
var tagContent string
tagContent, err = getTagContent(tdSelection)
if err != nil {
err = fmt.Errorf("getTagContent: %s", err)
return
}
err = fmt.Errorf("Tag <span> not found in <td>: %s", tagContent)
return
}
strongList := spanList.Find("strong")
if strongList.Length() == 0 {
team = spanList.Get(0).FirstChild.Data
} else {
/*
firstStrong := strongList.Get(0) ///.FirstChild.Data
if firstStrong == nil {
panic("firstS strong = nil")
}
*/
team = strongList.Get(0).FirstChild.Data
}
return
}
func parsePeriodTeamGoals(doc *goquery.Document, period int, side string) (goals int, err error) {
spanTeamGoals := doc.Find(fmt.Sprintf("span.p%d_%s", period, side))
if spanTeamGoals.Length() == 0 {
errmsg := fmt.Sprintf(`%s team goals for period %d not found (tag <span class="p%d_%s">)`,
strings.ToUpper(side), period, period, side)
err = fmt.Errorf(errmsg)
return
}
textNode := spanTeamGoals.Get(0).FirstChild
if textNode == nil {
err = fmt.Errorf("Teams goals <span> doesn't have a child node")
return
}
goalsStr := strings.TrimSpace(textNode.Data)
goals, err = strconv.Atoi(goalsStr)
if err != nil {
err = fmt.Errorf("Parse team goals: %s; text=%q", err, goalsStr)
return
}
return
}
func getTagContent(tagSelection *goquery.Selection) (_ string, err error) {
txt, err := tagSelection.Html()
if err != nil {
return
}
return html.UnescapeString(txt), nil
}
func parseScore(score string) (homeGoals, awayGoals int, err error) {
list := strings.Split(score, ":")
if len(list) != 2 {
err = fmt.Errorf("Wrong score string")
return
}
homeGoals, err = strconv.Atoi(strings.TrimSpace(list[0]))
if err != nil {
return
}
awayGoals, err = strconv.Atoi(strings.TrimSpace(list[1]))
if err != nil {
return
}
return
}
var reDirtyTeamID = regexp.MustCompile(`'/team([^']+)'`)
func parseTeam(divList *goquery.Selection) (team model.Team, err error) {
if divList.Length() == 0 {
err = fmt.Errorf("Team div not found")
return
}
aList := divList.First().Find("a.participant-imglink")
if aList.Length() == 0 {
err = fmt.Errorf("a.participant-imglink not found")
return
}
a := aList.Get(0)
textNode := a.FirstChild
if textNode == nil {
err = fmt.Errorf("a.participant-imglink have not content")
return
}
if textNode.Type != xhtml.TextNode {
err = fmt.Errorf("First child of a.participant-imglink must be TextNode, not %s",
nodeTypes[textNode.Type])
return
}
team.CanonicalName = strings.TrimSpace(textNode.Data)
onclick, exists := aList.Eq(0).Attr("onclick")
if !exists {
err = fmt.Errorf("a.participant-imglink have not 'onclick' attr")
return
}
groups := reDirtyTeamID.FindStringSubmatch(onclick)
if len(groups) != 2 {
err = fmt.Errorf("TeamID in 'onclick' attr not found")
return
}
str := groups[1]
// На всякий случай обрезаем слэш в конце
str = strings.TrimSuffix(str, "/")
team.TeamID = str
//parts := strings.Split(str, "/")
// Последняя секция - это teamID
//team.TeamID = parts[len(parts)-1]
return
}
func parseZoneOnTeamPage(doc *goquery.Document, sportID int) (zone model.TeamZone, err error) {
h2List := doc.Find("h2.tournament")
if h2List.Length() == 0 {
err = fmt.Errorf("Tag h2.tournament not found")
return
}
aList := h2List.First().Find("a")
if aList.Length() == 0 {
err = fmt.Errorf("Tags <a> not found in h2.tournament tag")
return
}
for i := 0; i < aList.Length(); i++ {
a := aList.Eq(i)
href, exists := a.Attr("href")
if !exists {
continue
}
href = strings.TrimSpace(href)
prefix, ok := sportPrefixes[sportID]
if !ok {
panic(fmt.Sprintf("Bug: sportID %d hasn't sport prefix", sportID))
}
href = strings.TrimSuffix(href, "/")
if href == prefix {
continue
}
if !strings.HasPrefix(href, prefix) {
continue
}
if href == "" {
continue
}
// Мы нашли zoneID
// Достаем имя зоны
textNode := aList.Get(i).FirstChild
if textNode == nil {
err = fmt.Errorf("Zone name not found in <a> content")
return
}
if textNode.Type != xhtml.TextNode {
err = fmt.Errorf("FirstChild of tag <a> must be TextNode, not %s",
nodeTypes[textNode.Type])
return
}
zone = model.TeamZone{
ZoneID: href,
Name: strings.TrimSpace(textNode.Data),
SportID: sportID,
}
return
}
//err = fmt.Errorf("ZoneID not found in <a> list")
return
}
// Футбол, парсим HTML код
func parseFootballTeamScoreByPeriods(doc *goquery.Document) (m teamScoreByPeriods, hasScoreByPeriods bool, err error) {
// Счет 1го тайма
m.P1HomeGoals, err = parsePeriodTeamGoals(doc, 1, "home")
if err != nil {
return
}
m.P1AwayGoals, err = parsePeriodTeamGoals(doc, 1, "away")
if err != nil {
return
}
// Счет 2го тайма
m.P2HomeGoals, err = parsePeriodTeamGoals(doc, 2, "home")
if err != nil {
return
}
m.P2AwayGoals, err = parsePeriodTeamGoals(doc, 2, "away")
if err != nil {
return
}
hasScoreByPeriods = true
return
}
// ODDS
// Футбол, парсим HTML код
func parseOddsTotal(doc *goquery.Document) (totals []Total, err error) {
// 2.5 => *Total
totalMap := make(map[string]*Total)
divTotal := doc.Find("#block-under-over-ft")
if divTotal.Length() == 0 {
err = fmt.Errorf("#block-under-over-ft not found")
return
}
tdList := divTotal.First().Find("td.bookmaker")
if tdList.Length() == 0 {
err = fmt.Errorf("List of td.bookmaker in #block-under-over-ft not found")
return
}
for idx := 0; idx < tdList.Length(); idx++ {
//tdList.Each(func(idx int, tdSelection *goquery.Selection) {
var offer TotalOffer
//.Get(0).Data //.Html()
//fmt.Printf("%s\n", txt)
//fmt.Printf("%v\n", tdSelection)
//tdSelection.Get(0)
//tdSelection.Next()
tdSelection := tdList.Eq(idx)
offer.Bookmaker, err = getBookmaker(tdSelection)
if err != nil {
return
}
priceTd := tdSelection.Next()
var totalValue string
totalValue, err = getTotalValue(priceTd)
if err != nil {
err = fmt.Errorf("get total value: %s", err)
return
}
priceTd = priceTd.Next()
var isRemovedByBookmaker bool
isRemovedByBookmaker, offer.Over, err = getTotalPrice(priceTd)
if err != nil {
err = fmt.Errorf("get over price: %s", err)
return
}
if isRemovedByBookmaker {
continue
}
priceTd = priceTd.Next()
isRemovedByBookmaker, offer.Under, err = getTotalPrice(priceTd)
if err != nil {
err = fmt.Errorf("get under price: %s", err)
return
}
if isRemovedByBookmaker {
continue
}
total, ok := totalMap[totalValue]
if !ok {
total = &Total{
Total: totalValue,
}
totalMap[totalValue] = total
}
total.Offers = append(total.Offers, offer)
}
for _, ptotal := range totalMap {
totals = append(totals, *ptotal)
}
return
}
func getTotalValue(tdSelection *goquery.Selection) (total string, err error) {
if tdSelection == nil {
err = fmt.Errorf("<td> total tag selection is nil")
return
}
tdNode := tdSelection.Get(0)
textNode := tdNode.FirstChild
if textNode == nil {
err = fmt.Errorf("Empty total's <td>")
return
}
if textNode.Type != xhtml.TextNode {
err = fmt.Errorf("First child of total's <td> must be TextNode, not %s",
nodeTypes[textNode.Type])
return
}
total = strings.TrimSpace(textNode.Data)
return
}
// Цена может быть перечеркнута, значит ее нужно выкинуть из результата
func getTotalPrice(tdSelection *goquery.Selection) (isRemovedByBookmaker bool, price float64, err error) {
if tdSelection == nil {
err = fmt.Errorf("<td> total price tag selection is nil")
return
}
spanSelection := tdSelection.Find("span")
if spanSelection.Length() == 0 {
err = fmt.Errorf("<span> not found in <td>")
return
}
if spanSelection.First().HasClass("not-published") {
isRemovedByBookmaker = true
return
}
span := spanSelection.Get(0)
textNode := span.FirstChild
if textNode == nil {
err = fmt.Errorf("Empty total's price <span>")
return
}
if textNode.Type != xhtml.TextNode {
err = fmt.Errorf("First child of total's price <span> must be TextNode, not %s",
nodeTypes[textNode.Type])
return
}
priceStr := strings.TrimSpace(textNode.Data)
if priceStr == "-" {
isRemovedByBookmaker = true
return
}
price, err = strconv.ParseFloat(priceStr, 64)
if err != nil {
err = fmt.Errorf("Parse price: %s; priceStr=%q", err, priceStr)
return
}
return
}
func parseHandballMatchScore(str string) (homeGoals, awayGoals int, err error) {
//fmt.Printf("SCORE: %s\n", str)
list, err := ParseFlashscoreEncoding(str)
if err != nil {
return
}
// DE home с учетом ОТ
// DF away с учетом ОТ
// DG home основное время
// DH away основное время
var isHomeGoalsFound, isAwayGoalsFound bool
for _, m := range list {
if m["DG"] != "" {
homeGoals, err = strconv.Atoi(m["DG"])
if err != nil {
err = fmt.Errorf("Atoi: %s; string=%s", err, m["DG"])
return
}
isHomeGoalsFound = true
//fmt.Printf("DH: %s, goals: %d\n", m["DH"], awayGoals)
}
if m["DH"] != "" {
awayGoals, err = strconv.Atoi(m["DH"])
if err != nil {
err = fmt.Errorf("Atoi: %s; string=%s", err, m["DH"])
return
}
isAwayGoalsFound = true
//fmt.Printf("DG: %s, goals: %d\n", m["DG"], homeGoals)
}
}
if isHomeGoalsFound && isAwayGoalsFound {
return
}
err = fmt.Errorf("Home goals or/and Away goals not found")
return
}
func parseHandballTeamScoreByPeriods(str string) (stat teamScoreByPeriods, hasScoreByPeriods bool, err error) {
// AC÷3¬ вероятно статус
// BA÷15¬ p1 home
// BB÷10¬ p1 away
// ~BC÷15¬ p2 home
// BD÷14¬ p2 away
// ~A1÷¬~
var isP1HomeFound, isP1AwayFound, isP2HomeFound, isP2AwayFound bool
blocks := strings.Split(str, SymbolTilda)
for _, block := range blocks {
block = strings.TrimSpace(block)
if block == "" {
continue
}
pairs := strings.Split(block, SymbolNot)
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
kv := strings.Split(pair, SymbolDivide)
if len(kv) != 2 {
err = fmt.Errorf("Wrong key/value: %s", pair)
return
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
switch key {
case "BA":
stat.P1HomeGoals, err = strconv.Atoi(value)
if err != nil {
err = fmt.Errorf("Atoi: %s; string=%s", err, value)
return
}
isP1HomeFound = true
case "BB":
stat.P1AwayGoals, err = strconv.Atoi(value)
if err != nil {
err = fmt.Errorf("Atoi: %s; string=%s", err, value)
return
}
isP1AwayFound = true
case "BC":
stat.P2HomeGoals, err = strconv.Atoi(value)
if err != nil {
err = fmt.Errorf("Atoi: %s; string=%s", err, value)
return
}
isP2HomeFound = true
case "BD":
stat.P2AwayGoals, err = strconv.Atoi(value)
if err != nil {
err = fmt.Errorf("Atoi: %s; string=%s", err, value)
return
}
isP2AwayFound = true
}
}
}
if isP1HomeFound && isP1AwayFound && isP2HomeFound && isP2AwayFound {
hasScoreByPeriods = true
return
}
// В этом месте нет ошибки, но hasScoreByPeriods = false
return
}
type PeriodScore struct {
PeriodNumber int
HomeScore int
AwayScore int
}
func parseTennisTeamScoreByPeriods(str string) (periods []PeriodScore, hasScoreByPeriods bool, err error) {
// Счет 2:1, по сетам: 7-6(7-2) 4-6 6-2
// Тильда ~ вероятно разделяет периоды
// AC÷3¬ вероятно статус
// BA÷7¬ s1 home
// DA÷7¬ s1 tiebreak home
// BB÷6¬ s1 away
// DB÷2¬ s1 tiebreak away
// RC÷1:05¬
// ~
// BC÷4¬ s2 home
// BD÷6¬ s2 away
// RD÷0:50¬
// ~
// BE÷6¬ s3 home
// BF÷2¬ s3 away
// RE÷0:43¬
// ~
// RB÷2:38¬
// ~
// PSPH÷WC¬
// PSPA÷11¬
// A1÷¬~
// Счет 3:2, 3-6 7-6(7-5) 7-6(7-4) 0-6 6-3
// AC÷3¬
// BA÷3¬
// BB÷6¬
// RC÷0:36¬ продолжительность периода
// ~
// BC÷7¬
// DC÷7¬
// BD÷6¬
// DD÷5¬
// RD÷1:18¬
// ~
// BE÷7¬
// DE÷7¬
// BF÷6¬
// DF÷4¬
// RE÷1:08¬
// ~
// BG÷0¬ s4 home
// BH÷6¬ s4 away
// RF÷0:29¬
// ~
// BI÷6¬ s5 home
// BJ÷3¬ s5 away
// RG÷0:38¬
// ~
// RB÷4:09¬ продолжительность матча
// ~
// PSPH÷20¬
// PSPA÷12¬
// A1÷¬~
blocks := strings.Split(str, SymbolTilda)
for _, block := range blocks {
block = strings.TrimSpace(block)
if block == "" {
continue
}
var (
period PeriodScore
periodSignature string
)
pairs := strings.Split(block, SymbolNot)
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
kv := strings.Split(pair, SymbolDivide)
if len(kv) != 2 {
err = fmt.Errorf("Wrong key/value: %s", pair)
return
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
switch key {
case "BA", "BC", "BE", "BG", "BI":
period.HomeScore, err = strconv.Atoi(value)
if err != nil {
err = fmt.Errorf("Atoi: %s; string=%s", err, value)
return
}
// записываем в начало, чтобы независимо от порядка полей в исходной
// строке получить одинаковую подпись
periodSignature = key + periodSignature
case "BB", "BD", "BF", "BH", "BJ":
period.AwayScore, err = strconv.Atoi(value)
if err != nil {
err = fmt.Errorf("Atoi: %s; string=%s", err, value)
return
}
// дописываем в конец, чтобы независимо от порядка полей в исходной
// строке получить одинаковую подпись
periodSignature += key
}
}
switch periodSignature {
case "BABB":
period.PeriodNumber = 1
case "BCBD":
period.PeriodNumber = 2
case "BEBF":
period.PeriodNumber = 3
case "BGBH":
period.PeriodNumber = 4
case "BIBJ":
period.PeriodNumber = 5
default:
if periodSignature != "" {
// Что-то нашло но не то, что нужно (например только home),
// поэтому обработку прерываем
return
}
}
if period.PeriodNumber > 0 {
periods = append(periods, period)
}
}
if len(periods) > 0 {
// Периоды найдены, обработчка не была прервана, значит все ок
hasScoreByPeriods = true
}
return
}
//type FlashscoreEncodedData
func ParseFlashscoreEncoding(str string) (_ []map[string]string, err error) {
var list []map[string]string
blocks := strings.Split(str, SymbolTilda)
for _, block := range blocks {
block = strings.TrimSpace(block)
if block == "" {
continue
}
pairs := strings.Split(block, SymbolNot)
m := make(map[string]string)
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
kv := strings.Split(pair, SymbolDivide)
if len(kv) != 2 {
err = fmt.Errorf("Wrong key/value: %s", pair)
return
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
m[key] = value
}
list = append(list, m)
}
return list, nil
}
func parseFlashscoreEncodedOdds(str string) (odds Odds, err error) {
decoded, err := ParseFlashscoreEncoding(str)
if err != nil {
return
}
var (
market string
period string
total *Total
totals = make(map[string]*Total)
h1Totals = make(map[string]*Total)
)
for _, m := range decoded {
if market == "" {
// Ищем рынок
market = getMarket(m["OAI"])
} else if period == "" {
// Рынок есть - ищем период
if m["OAI"] != "" {
// Нужный период не нашли, но встретили новый рынок
market = getMarket(m["OAI"])
period = ""
} else if m["OBI"] != "" {
period = getPeriod(m["OBI"])
}
} else {
// Есть и рынок и период
switch market {
case "1x2":
// Накапливаем offers букмекеров пока не встретим OAI или OBI
if m["OAI"] != "" {
// Встретили новый рынок
market = getMarket(m["OAI"])
period = ""
} else if m["OBI"] != "" {
// Встретили новый период
period = getPeriod(m["OBI"])
} else {
// Ищем предложения букмекеров
if m["OG"] == "0" {
// Перечеркнутое предложение
continue
}
if m["XA"] != "" && m["XB"] != "" && m["XC"] != "" {
winner, perr := parseFlashscoreEncodedWinner3Way(m)
if perr != nil {
// log
fmt.Println(perr)
} else {
odds.Winner = append(odds.Winner, winner)
}
}
}
case "12":
// Накапливаем offers букмекеров пока не встретим OAI или OBI
if m["OAI"] != "" {
// Встретили новый рынок
market = getMarket(m["OAI"])
period = ""
} else if m["OBI"] != "" {
// Встретили новый период
period = getPeriod(m["OBI"])
} else {
// Ищем предложения букмекеров
if m["OG"] == "0" {
// Перечеркнутое предложение
continue
}
if m["XB"] != "" && m["XC"] != "" {
winner, perr := parseFlashscoreEncodedWinner2Way(m)
if perr != nil {
// log
fmt.Println(perr)
} else {
odds.Winner2Way = append(odds.Winner2Way, winner)
}
}
}
case "total":
switch period {
case "ft":
// Накапливаем offers букмекеров пока не встретим OAI или OBI
if m["OAI"] != "" {
// Встретили новый рынок
market = getMarket(m["OAI"])
period = ""
total = nil
//totalValue = ""
} else if m["OBI"] != "" {
period = getPeriod(m["OBI"])
total = nil
} else if m["OCT"] == "Total" || m["OCT"] == "Games" {
// Total - handball, football; Games - tennis
totalValue := m["OC"]
total = totals[totalValue]
if total == nil {
total = &Total{
Total: totalValue,
}
totals[totalValue] = total
}
} else {
if m["XB"] != "" && m["XC"] != "" {
isRemovedByBookmaker, offer, perr := parseFlashscoreEncodedTotalOffer(m)
if perr != nil {
// log
fmt.Println(perr)
} else {
if !isRemovedByBookmaker {
if total != nil {
total.Offers = append(total.Offers, offer)
}
}
}
}
}
case "1hf", "set1":
// Накапливаем offers букмекеров пока не встретим OAI или OBI
if m["OAI"] != "" {
// Встретили новый рынок
market = getMarket(m["OAI"])
period = ""
total = nil
//totalValue = ""
} else if m["OBI"] != "" {
period = getPeriod(m["OBI"])
total = nil
} else if m["OCT"] == "Total" || m["OCT"] == "Games" {
// Total - handball, football; Games - tennis
totalValue := m["OC"]
total = totals[totalValue]
if total == nil {
total = &Total{
Total: totalValue,
}
h1Totals[totalValue] = total
}
} else {
if m["XB"] != "" && m["XC"] != "" {
isRemovedByBookmaker, offer, perr := parseFlashscoreEncodedTotalOffer(m)
if perr != nil {
// log
fmt.Println(perr)
} else {
if !isRemovedByBookmaker {
if total != nil {
total.Offers = append(total.Offers, offer)
}
}
}
}
}
default:
panic(fmt.Sprintf("Bug: unknown total period %q", period))
}
}
}
}
for _, ptotal := range totals {
odds.FullTimeTotal = append(odds.FullTimeTotal, *ptotal)
}
for _, ptotal := range h1Totals {
odds.H1Total = append(odds.H1Total, *ptotal)
}
return
}
// little helper
func getMarket(code string) string {
switch code {
// football, handball
case "1x2":
return "1x2"
// football, handball, tennis
case "under-over":
return "total"
// tennis
case "moneyline":
return "12"
default:
return ""
}
}
// little helper
func getPeriod(code string) string {
switch code {
// football, handball, tennis
case "ft":
return "ft"
// football, handball
case "1hf":
return "1hf"
// tennis
case "set1":
return "set1"
default:
return ""
}
}
func parseFlashscoreEncodedWinner3Way(m map[string]string) (winner Winner3Way, err error) {
winner.Bookmaker, err = strconv.Atoi(m["OE"])
if err != nil {
err = fmt.Errorf("Parse bookmakerID: %s; string=%s", err, m["OE"])
return
}
winner.Home, err = parseFlashscoreEncodedPrice(m["XA"])
if err != nil {
return
}
winner.Away, err = parseFlashscoreEncodedPrice(m["XC"])
if err != nil {
return
}
winner.Draw, err = parseFlashscoreEncodedPrice(m["XB"])
if err != nil {
return
}
return
}
func parseFlashscoreEncodedWinner2Way(m map[string]string) (winner Winner2Way, err error) {
winner.Bookmaker, err = strconv.Atoi(m["OE"])
if err != nil {
err = fmt.Errorf("Parse bookmakerID: %s; string=%s", err, m["OE"])
return
}
winner.Home, err = parseFlashscoreEncodedPrice(m["XB"])
if err != nil {
return
}
winner.Away, err = parseFlashscoreEncodedPrice(m["XC"])
if err != nil {
return
}
return
}
func parseFlashscoreEncodedTotalOffer(m map[string]string) (isRemovedByBookmaker bool, offer TotalOffer, err error) {
offer.Bookmaker, err = strconv.Atoi(m["OE"])
if err != nil {
err = fmt.Errorf("Parse bookmakerID: %s; string=%s", err, m["OE"])
return
}
offer.Over, err = parseFlashscoreEncodedPrice(m["XB"])
if err != nil {
return
}
offer.Under, err = parseFlashscoreEncodedPrice(m["XC"])
if err != nil {
return
}
// Перечеркнутое предложение
if m["OG"] == "0" {
isRemovedByBookmaker = true
}
return
}
// Функция пасит простые поля вида "1.44" и более сложные вида "2.24[u]2.39"
func parseFlashscoreEncodedPrice(str string) (price float64, err error) {
str = strings.TrimSpace(str)
list := strings.Split(str, "]")
// Последний элемент
str = list[len(list)-1]
price, err = strconv.ParseFloat(str, 64)
if err != nil {
err = fmt.Errorf("Parse price: %s; string=%s", err, str)
return
}
return
}
///////////////
// little helper
func getH2HTab(tabName string) string {
tabName = strings.ToUpper(tabName)
if strings.Contains(tabName, "OVERALL") {
return "overall"
}
return ""
}
// little helper
func getH2HList(listName, homeTeamName, awayTeamName string) string {
// Приводим все строки к единому регистру
listName = strings.ToUpper(listName)
homeTeamName = strings.ToUpper(homeTeamName)
awayTeamName = strings.ToUpper(awayTeamName)
// 3 списка, примеры:
//
// Last matches: HSG WETZLAR
// Last matches: MT MELSUNGEN
// Head-to-head matches: HSG WETZLAR - MT MELSUNGEN
//fmt.Printf("listName: '%s'; home='%s'; away='%s'\n", listName, homeTeamName, awayTeamName)
// 'HEAD-TO-HEAD MATCHES' если h2h матчей нет
if strings.Contains(listName, homeTeamName) {
if strings.Contains(listName, awayTeamName) {
// Обе команды
return "h2h"
}
// Только home
return "home"
}
if strings.Contains(listName, awayTeamName) {
// Только away
return "away"
}
return ""
}
func parseFlashscoreEncodedH2H(str, homeTeamName, awayTeamName string) (homeTeamMatches, awayTeamMatches, h2hMatches []model.TeamMatch, err error) {
decoded, err := ParseFlashscoreEncoding(str)
if err != nil {
return
}
pretty.PPrintln("DECODED", decoded)
var (
// Tabs на H2H странице: Overall, HSG Wetzlar - Home, MT Melsungen - Away
tab string
// Списки последних матчей: хозяев, гостей, h2h
list string
)
for _, m := range decoded {
if tab == "" {
// Ищем Tab
tab = getH2HTab(m["KA"])
} else if list == "" {
// Рынок есть - ищем период
if m["KA"] != "" {
// Нужный период не нашли, но встретили новый рынок
tab = getH2HTab(m["KA"])
list = ""
} else if m["KB"] != "" {
list = getH2HList(m["KB"], homeTeamName, awayTeamName)
}
} else {
// Есть и рынок и список
if m["KA"] != "" {
// Нужный период не нашли, но встретили новый рынок
tab = getH2HTab(m["KA"])
list = ""
} else if m["KB"] != "" {
list = getH2HList(m["KB"], homeTeamName, awayTeamName)
} else {
if m["KC"] != "" && m["KP"] != "" {
//startTime, _ := strconv.ParseInt(m["KC"], 10, 64)
match := model.TeamMatch{
MatchID: m["KP"],
//ChampID: m["KJ"] + " - " + m["KK"], Чисто для дебага можно выводить
}
switch list {
case "home":
homeTeamMatches = append(homeTeamMatches, match)
case "away":
awayTeamMatches = append(awayTeamMatches, match)
case "h2h":
h2hMatches = append(h2hMatches, match)
default:
panic(fmt.Sprintf("Bug: unknown Tab's listName %q", list))
}
}
}
}
}
return
}
//ZA zone: champ name extended
//ZL "champID"
/*
AA: 2XhOe000
AD: 1595772000
ADE: 1595772000
AB: 1
CR: 1
AC: 1
CX: Southampton
ER: Round 38
RW: 0
AX: 0
BX: -1
WQ:
WM: SOU
AE: Southampton
JA: fu16YetP
WU: southampton
WN: SHU
AF: Sheffield Utd
JB: bDRnslpP
WV: sheffield-utd
AW: 1
AN: n
Расшифровка:
ZA - champ name
AA - ID матча, нужен чтобы получить статистику по матчу
AD / ADE - время начала матча в UTC
CX - home team
AF - away team
WU - home team url part (нужно ли ?)
WV - away team url part (нужно ли ?)
ER - раунд (можно не парсить, но сгруппировать)
*/
func parseFlashscoreEncodedAllGames(str string, sportID int) (matches []ScheduledMatch, err error) {
decoded, err := ParseFlashscoreEncoding(str)
if err != nil {
return
}
var (
champID string
zone model.TeamZone
fullChampName string
)
for _, m := range decoded {
zl := m["ZL"] // если поле непустое - словарь описывает чемпионат, а не матч
if zl != "" {
champID = strings.TrimSpace(zl)
champID = strings.TrimSuffix(champID, "/")
// Пример /football/vietnam/v-league-1
parts := strings.Split(champID, "/")
if len(parts) < 3 {
continue
}
zone.ZoneID = strings.Join(parts[:3], "/")
parts = strings.Split(m["ZA"], ":")
zone.Name = strings.TrimSpace(strings.Title(strings.ToLower(parts[0])))
zone.SportID = sportID
fullChampName = m["ZA"]
} else {
//if m["AA"] != "" && m["AF"] != "" && m["CX"] != "" {
if m["AA"] != "" && m["AD"] != "" {
startTime, _ := strconv.ParseInt(m["AD"], 10, 64)
match := ScheduledMatch{
MatchID: m["AA"],
StartTime: time.Unix(startTime, 0),
ChampID: champID,
Zone: zone,
SportID: sportID,
FullChampName: fullChampName,
}
matches = append(matches, match)
}
}
}
return
}
// Служебные структуры для метода parseTeamsOnMatchPage
type jsObjectTeam struct {
TeamID string `json:"id"`
Name string `json:"name"`
}
type jsObjectParticipantsData struct {
Home []jsObjectTeam `json:"home"`
Away []jsObjectTeam `json:"away"`
}
type jsVarEnvironment struct {
ParticipantsData jsObjectParticipantsData `json:"participantsData"`
}
// parseTeamsOnMatchPageInJsVar - метод извлекает имена и TeamID команд из тега <script>.
// Гандбольные матчи хранят эти данные в json в теге <script>.
func parseTeamsOnMatchPageInJsVar(doc *goquery.Document) (home, away model.Team, err error) {
var jsonVar []byte
//doc.Find("script[type='text/javascript']").EachWithBreak(func(idx int, x *goquery.Selection) bool {
doc.Find("script").EachWithBreak(func(idx int, x *goquery.Selection) bool {
txt := x.Text()
txt = strings.TrimSpace(txt)
prefixes := []string{
"window.environment = ",
"var environment = ",
}
suffix := ";"
for _, prefix := range prefixes {
if strings.HasPrefix(txt, prefix) {
txt = strings.TrimPrefix(txt, prefix)
txt = strings.TrimSuffix(txt, suffix)
jsonVar = []byte(txt)
// JS код нашли - прерываем цикл
return false
}
}
// JS код не нашли - переходим к следующему SCRIPT тегу
return true
})
//fmt.Printf("%q\n\n", jsVar[:20])
var env jsVarEnvironment
err = json.Unmarshal([]byte(jsonVar), &env)
if err != nil {
return
}
h := env.ParticipantsData.Home[0]
a := env.ParticipantsData.Away[0]
home.TeamID = h.TeamID
home.CanonicalName = h.Name
away.TeamID = a.TeamID
away.CanonicalName = a.Name
return
}
/*
//var reChampID = regexp.MustCompile(`'/football/([^/]+)/([^/]+)/'`)
var reChampID = regexp.MustCompile(`'/([^/]+)/([^/]+)/([^/]+)/'`)
func parseTeamMatchInfo(doc *goquery.Document, sportID int) (zone model.TeamZone, champ model.TeamChamp, home, away model.Team, err error) {
spanList := doc.Find(".description__country")
if spanList.Length() == 0 {
err = fmt.Errorf("Tag .description__country not found")
return
}
span := spanList.Get(0)
dirtyZone := span.FirstChild
if dirtyZone == nil {
err = fmt.Errorf("Tag .description__country have not children")
return
}
if dirtyZone.Type != xhtml.TextNode {
err = fmt.Errorf("First child of tag .description__country must be TextNode, not %s",
nodeTypes[dirtyZone.Type])
return
}
txt := strings.TrimSpace(dirtyZone.Data)
txt = strings.TrimSuffix(txt, ":")
if txt == "" {
err = fmt.Errorf("Empty zone name found in tag .description__country")
return
}
txt = strings.ToLower(txt)
txt = strings.Title(txt)
zone.Name = txt
zone.SportID = sportID
a := dirtyZone.NextSibling
if a == nil {
err = fmt.Errorf("Tag <a> with champ info not found in the tag .description__country")
return
}
var onclick string
for _, attr := range a.Attr {
if strings.ToLower(attr.Key) == "onclick" {
onclick = attr.Val
break
}
}
if onclick == "" {
err = fmt.Errorf("Attr 'onclick' not found in the tag <a> in the .description__country tag")
return
}
// window.open('/football/england/premier-league/'); return false;
groups := reChampID.FindStringSubmatch(onclick)
if len(groups) != 4 {
err = fmt.Errorf("ChampID and ZoneID not found in 'onclick' attr: %s", onclick)
return
}
zone.ZoneID = fmt.Sprintf("/%s/%s", groups[1], groups[2])
champ.ChampID = fmt.Sprintf("/%s/%s/%s", groups[1], groups[2], groups[3])
// Не забываем!
champ.ZoneID = zone.ZoneID
if a.FirstChild == nil {
err = fmt.Errorf("Empty champ name in <a> tag")
return
}
dirtyChamp := a.FirstChild
if dirtyChamp.Type != xhtml.TextNode {
err = fmt.Errorf("Tag <a> content must be TextNode, not %s",
nodeTypes[dirtyChamp.Type])
return
}
parts := strings.Split(dirtyChamp.Data, "-")
// Возможный чемпионат 'Club Friendly', где нет раунда, поэтому нужна проверка
if len(parts) > 1 {
// Последний элемент - это раунд. Выкидываем
parts = parts[:len(parts)-1]
}
champ.Name = strings.TrimSpace(strings.Join(parts, "-"))
// ВАЖНО!
// Зону для команды узнаем на странице команды!
home, err = parseTeam(doc.Find(".tname-home"))
if err != nil {
err = fmt.Errorf("Parse home team: %s", err)
return
}
away, err = parseTeam(doc.Find(".tname-away"))
if err != nil {
err = fmt.Errorf("Parse away team: %s", err)
return
}
return
}
*/