diff --git a/README.md b/README.md index 747190c..ed92b42 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Dota2 scheduled matches tracker -![Main](./screenshots/main.gif) +![Main](./screenshots/main-with-details.gif) ## Installation @@ -25,7 +25,7 @@ Or download the binary from [release page](https://github.com/vuon9/d2m/releases ❯ d2m ``` -## Key features +## Features - Filter matches by time, status: - Upcoming - Live @@ -34,11 +34,10 @@ Or download the binary from [release page](https://github.com/vuon9/d2m/releases - Today - Yesterday - From today +- View details of teams - Open Twitch streaming link in browser - -## Tasks - -- Async handle for the details, to ensure can handle async call well - - make a call to a function which has sleep 3, return text when end - - handle loading during the sleeping time - - show the returned text +- Some display icons to help you quickly identify the status: + - Team has info: ◆ + - Team has no info: ◇ + - Live match has streaming page: ▶ + - Live match has no streaming page: ▷ diff --git a/cmd/d2m.go b/cmd/d2m.go index 4b93800..9cf4baa 100644 --- a/cmd/d2m.go +++ b/cmd/d2m.go @@ -2,11 +2,68 @@ package cmd import ( "context" + "encoding/json" + "fmt" + "log" + "os" - "github.com/vuon9/d2m/internal/app" + "github.com/urfave/cli/v2" + iapp "github.com/vuon9/d2m/internal/app" + "github.com/vuon9/d2m/pkg/api/liquipedia" ) func Execute() { - prog := app.NewApp() - prog.Run(context.Background()) + app := &cli.App{ + Name: "d2m", + Action: func(*cli.Context) error { + prog := iapp.NewApp() + return prog.Run(context.Background()) + }, + Commands: []*cli.Command{ + { + Name: "test", + Subcommands: []*cli.Command{ + { + Name: "list", + Action: func(cCtx *cli.Context) error { + client := liquipedia.NewClient() + matches, err := client.GetScheduledMatches(cCtx.Context) + if err != nil { + return err + } + + for i, m := range matches { + log.Printf("%d. %s vs %s", i+1, m.Team1().TeamProfileLink, m.Team2().TeamProfileLink) + } + + return nil + }, + }, + { + Name: "details", + Action: func(cCtx *cli.Context) error { + client := liquipedia.NewClient() + team, err := client.GetTeamDetailsPage(cCtx.Context, "https://liquipedia.net/dota2/OG") + if err != nil { + return err + } + + bTeam, err := json.MarshalIndent(team, "", " ") + if err != nil { + return err + } + + fmt.Println(string(bTeam)) + + return nil + }, + }, + }, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } } diff --git a/go.mod b/go.mod index cb4ce22..a284d79 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,15 @@ require ( github.com/BurntSushi/toml v1.2.1 github.com/gocolly/colly v1.2.0 github.com/samber/lo v1.37.0 + github.com/urfave/cli/v2 v2.25.0 ) require ( github.com/atotto/clipboard v0.1.4 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 // indirect golang.org/x/sync v0.1.0 // indirect ) diff --git a/go.sum b/go.sum index f3ce39e..143e1e8 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87ini github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -68,6 +70,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= @@ -79,6 +83,10 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= +github.com/urfave/cli/v2 v2.25.0 h1:ykdZKuQey2zq0yin/l7JOm9Mh+pg72ngYMeB0ABn6q8= +github.com/urfave/cli/v2 v2.25.0/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/internal/app/cli.go b/internal/app/cli.go index bbb9501..bb3c007 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -3,42 +3,21 @@ package app import ( "context" - "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/vuon9/d2m/pkg/api" + "github.com/vuon9/d2m/pkg/api/liquipedia" ) -type app struct { - tracker Tracker -} +type App struct{} -type Apper interface { - Run(ctx context.Context) error +func NewApp() *App { + return &App{} } -func NewApp() Apper { - return &app{ - tracker: NewTracker(), - } -} +var apiClient api.Clienter = liquipedia.NewClient() // RunProgram prints matches as table on terminal -func (a *app) Run(ctx context.Context) error { - matches, err := a.tracker.GetMatches(ctx) - if err != nil { - return err - } - - // for _, match := range matches { - // fmt.Println(match.Team1().FullName, match.Team2().FullName, match.Start) - // fmt.Println(match.Team1().TeamProfileLink, match.Team2().TeamProfileLink) - // fmt.Println() - // } - - items := make([]list.Item, 0) - for _, match := range matches { - items = append(items, match) - } - +func (a *App) Run(ctx context.Context) error { f, err := tea.LogToFile("debug.log", "debug") if err != nil { return err @@ -46,7 +25,7 @@ func (a *app) Run(ctx context.Context) error { defer f.Close() - prog := tea.NewProgram(newModel(items), tea.WithAltScreen()) + prog := tea.NewProgram(newModel(), tea.WithAltScreen()) if _, err := prog.Run(); err != nil { return err } diff --git a/internal/app/details.go b/internal/app/details.go index d504174..8557ef4 100644 --- a/internal/app/details.go +++ b/internal/app/details.go @@ -1,7 +1,10 @@ package app import ( + "context" + "errors" "fmt" + "sync" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" @@ -11,8 +14,10 @@ import ( ) type detailsModel struct { - spinner spinner.Model - match *api.Match + spinner spinner.Model + match *api.Match + fetchIsDone bool + lastErr error } func newDetailsModel(match *api.Match) tea.Model { @@ -24,20 +29,78 @@ func newDetailsModel(match *api.Match) tea.Model { return m } +func fetchTeams(match *api.Match) func() tea.Msg { + return func() tea.Msg { + urls := []string{} + + for _, team := range match.Teams { + if team.TeamProfileLink == "" { + continue + } + + urls = append(urls, team.TeamProfileLink) + } + + wg := sync.WaitGroup{} + teams := make([]*api.Team, 0) + + var lastErr error + + for _, url := range urls { + wg.Add(1) + + go func(url string) { + defer wg.Done() + team, err := apiClient.GetTeamDetailsPage(context.TODO(), url) + if err != nil { + lastErr = errors.New(fmt.Sprintf("Error while fetching team details: %s, url: %s", err.Error(), url)) + return + } + + teams = append(teams, team) + }(url) + } + + wg.Wait() + + if lastErr != nil { + return lastErr + } + + return teams + } +} + func (m *detailsModel) resetSpinner() { m.spinner = spinner.New() + m.spinner.Spinner = spinner.Dot m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) } func (m *detailsModel) Init() tea.Cmd { - return m.spinner.Tick + return tea.Batch( + spinner.Tick, + fetchTeams(m.match), + ) } func (m *detailsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { + default: + return m, nil } + case []*api.Team: + m.fetchIsDone = true + m.match.Teams = msg + + return m, nil + case error: + m.fetchIsDone = true + m.lastErr = msg + + return m, nil case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) @@ -48,66 +111,72 @@ func (m *detailsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *detailsModel) View() string { - // sp := "\n" - // sp += m.spinner.View() - - teamDetails := "\n" - teamDetails += fmt.Sprintf("Tournament logo: %s, Page URL: %s\n", m.match.Tournament.Urls.Logo, m.match.Tournament.Urls.Page) - - for _, team := range m.match.Teams { - - teamDetails += fmt.Sprintf("Team: %s, short name: %s, player roster: \n", team.FullName, team.ShortName) - for _, pl := range team.PlayerRoster { - teamDetails += fmt.Sprintf("ID: %s\n", pl.ID) - teamDetails += fmt.Sprintf("Name: %s\n", pl.Name) - teamDetails += fmt.Sprintf("JoinDate: %s\n", pl.JoinDate) - teamDetails += fmt.Sprintf("LeaveDate: %s\n", pl.LeaveDate) - teamDetails += fmt.Sprintf("NewTeam: %s\n", pl.NewTeam) - teamDetails += fmt.Sprintf("Position: %d\n", pl.Position) - teamDetails += fmt.Sprintf("ActiveStatus: %d\n", pl.ActiveStatus) - teamDetails += fmt.Sprintf("IsCaptain: %t\n", pl.IsCaptain) - - } + if !m.fetchIsDone { + return m.spinner.View() + " Fetching teams" } - // matchTitle := "\n" + m.match.GeneralTitle() - sp := lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Render("\n " + string(teamDetails)) - - return sp -} - -// TODO: Remove?? -func newTableModel() table.Model { - columns := []table.Column{ - {Title: "Player", Width: 10}, - {Title: "Hero", Width: 10}, - {Title: "Team", Width: 10}, + if m.lastErr != nil { + return m.lastErr.Error() } - rows := []table.Row{ - {"player1", "hero1", "Liquid"}, - {"player2", "hero2", "OG"}, + playerTableCols := []table.Column{ + { + Title: "No.", + Width: 3, + }, + { + Title: "In-game ID", + Width: 15, + }, + { + Title: "Name", + Width: 20, + }, + { + Title: "Position", + Width: 10, + }, + { + Title: "Join Date", + Width: 10, + }, } - tableView := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(7), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) + teamDetails := headerStyle.Render("Player Roster - Active") + "\n\n" + + activePlayers := []table.Row{} + for i, t := range m.match.Teams { + teamDetails += fmt.Sprintf("Team %d: %s\n", i+1, t.FullName) + for _, p := range t.PlayerRoster { + activePlayers = append(activePlayers, table.Row{ + fmt.Sprintf("%d", i+1), + p.ID, + p.Name, + p.Position.String(), + p.JoinDate, + }) + } - tableView.SetStyles(s) + t := table.New( + table.WithColumns(playerTableCols), + table.WithRows(activePlayers), + table.WithHeight(9), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + teamDetails += t.View() + "\n" + } - return tableView + return lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Render("\n " + teamDetails) } diff --git a/internal/app/match.go b/internal/app/filter.go similarity index 54% rename from internal/app/match.go rename to internal/app/filter.go index f40b177..7af7468 100644 --- a/internal/app/match.go +++ b/internal/app/filter.go @@ -1,48 +1,12 @@ package app import ( - "context" "time" "github.com/charmbracelet/bubbles/list" "github.com/vuon9/d2m/pkg/api" - "github.com/vuon9/d2m/pkg/api/liquipedia" ) -type Tracker interface { - GetMatches(ctx context.Context) ([]*api.Match, error) -} - -type tracker struct { - client api.Clienter -} - -var _ Tracker = (*tracker)(nil) - -func NewTracker() *tracker { - return &tracker{ - client: liquipedia.NewClient(), - } -} - -func (d *tracker) GetMatches(ctx context.Context) ([]*api.Match, error) { - matches, err := d.client.GetScheduledMatches(ctx) - if err != nil { - return nil, err - } - - // loop through matches and sort them by ascending date - for i := 0; i < len(matches); i++ { - for j := i + 1; j < len(matches); j++ { - if matches[i].Start.After(matches[j].Start) { - matches[i], matches[j] = matches[j], matches[i] - } - } - } - - return matches, nil -} - type matchFilter uint8 const ( @@ -56,15 +20,10 @@ const ( Coming ) -func filterMatches(items []list.Item, mf matchFilter) []list.Item { +func filterMatches(items []*api.Match, mf matchFilter) []list.Item { var filteredItems []list.Item for _, match := range items { - match, ok := match.(*api.Match) - if !ok { - continue - } - var isEligible bool switch mf { diff --git a/internal/app/model.go b/internal/app/model.go index 9e8d4a0..4a70a23 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -1,11 +1,12 @@ package app import ( + "context" "fmt" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/vuon9/d2m/pkg/api" @@ -13,11 +14,11 @@ import ( type ( model struct { + spinner spinner.Model listModel list.Model - tableModel table.Model detailsModel tea.Model - items []list.Item appState appState + items []*api.Match } MatchItem interface { @@ -71,6 +72,11 @@ var ( Background(lipgloss.Color("#25A065")). Padding(0, 2) + headerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")) + + defaultBodyStyle = lipgloss.NewStyle().MarginLeft(2) + statusMessageStyle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#04B575"}). Render @@ -134,17 +140,22 @@ var filterKeys = matchFilterKeys{ Coming: KeyComingMatches, } -func newModel(matches []list.Item) tea.Model { +func newModel() tea.Model { + sp := spinner.New() + sp.Spinner = spinner.Dot + return &model{ - listModel: newListView(matches), - tableModel: newTableModel(), - items: matches, - appState: showListMatch, + spinner: sp, + listModel: newListView(), + appState: showListMatch, } } func (m model) Init() tea.Cmd { - return nil + return tea.Batch( + m.spinner.Tick, + getMatches, + ) } // DoFilterSuccessful is used to filter matches by key @@ -208,6 +219,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) case showListMatch: switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case []*api.Match: + m.items = msg + m.listModel.SetItems(filterMatches(msg, FromToday)) case tea.KeyMsg: // If the list is filtering, we want to skip all the keys in this state if m.listModel.FilterState() == list.Filtering { @@ -253,17 +271,36 @@ func (m model) openStreamingURL() { } func (m model) View() string { - view := m.listModel.View() + title := titleStyle.Render(m.listModel.Title) + "\n\n" + view := title + + if m.appState == showListMatch { + if m.items != nil { + view = m.listModel.View() + } else { + view += m.spinner.View() + " Fetching matches" + view = defaultBodyStyle.Render(view) + } + } + if m.appState == showDetailsMatch { - title := titleStyle.Render(m.listModel.Title) + "\n" view = title + m.detailsModel.View() } return appStyle.Render(view) } -func newListView(matches []list.Item) list.Model { - listView := list.New(filterMatches(matches, FromToday), list.NewDefaultDelegate(), 0, 0) +func getMatches() tea.Msg { + matches, err := apiClient.GetScheduledMatches(context.TODO()) + if err != nil { + return err + } + + return matches +} + +func newListView() list.Model { + listView := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) listView.AdditionalFullHelpKeys = filterKeys.FullHelp listView.Title = "D2M - Dota2 Matches Tracker" diff --git a/pkg/api/client.go b/pkg/api/client.go index a9090ff..6b55d8c 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -4,4 +4,5 @@ import "context" type Clienter interface { GetScheduledMatches(ctx context.Context) ([]*Match, error) -} \ No newline at end of file + GetTeamDetailsPage(ctx context.Context, url string) (*Team, error) +} diff --git a/pkg/api/liquipedia/client.go b/pkg/api/liquipedia/client.go index b4f9fd8..43f77bf 100644 --- a/pkg/api/liquipedia/client.go +++ b/pkg/api/liquipedia/client.go @@ -8,11 +8,18 @@ import ( ) var ( + defaultHeaders http.Header = map[string][]string{ + "User-Agent": {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36"}, + "Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"}, + "Accept-Language": {"en-US,en;q=0.9,vi;q=0.8"}, + "Accept-Encoding": {"gzip, deflate, br"}, + "Cache-Control": {"max-age=0"}, + } + upComingPageUrl = secureDomain + "/dota2/Liquipedia:Upcoming_and_ongoing_matches" ) -type Client struct { -} +type Client struct{} func NewClient() *Client { return &Client{} @@ -24,11 +31,7 @@ func (cre *Client) GetScheduledMatches(ctx context.Context) ([]*api.Match, error return nil, err } - req.Header.Add("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36") - req.Header.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9") - req.Header.Add("Accept-Language", "en-US,en;q=0.9,vi;q=0.8") - req.Header.Add("Accept-Encoding", "gzip, deflate, br") - req.Header.Add("Cache-Control", "max-age=0") + req.Header = defaultHeaders matches := make([]*api.Match, 0) err = crawl(req, "div.matches-list > div:nth-child(2) table.infobox_matches_content > tbody", parseUpComingMatchesPage(&matches)) @@ -36,6 +39,33 @@ func (cre *Client) GetScheduledMatches(ctx context.Context) ([]*api.Match, error return nil, err } + // loop through matches and sort them by ascending date + for i := 0; i < len(matches); i++ { + for j := i + 1; j < len(matches); j++ { + if matches[i].Start.After(matches[j].Start) { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + return matches, nil +} + +func (cre *Client) GetTeamDetailsPage(ctx context.Context, url string) (*api.Team, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header = defaultHeaders + + team := new(api.Team) + team.TeamProfileLink = url + + err = crawl(req, "body", parseTeamProfilePage(team)) + if err != nil { + return nil, err + } + return team, nil } diff --git a/pkg/api/liquipedia/parser.go b/pkg/api/liquipedia/parser.go index f43e42e..0328faf 100644 --- a/pkg/api/liquipedia/parser.go +++ b/pkg/api/liquipedia/parser.go @@ -101,22 +101,20 @@ func parseUpComingMatchesPage(matches *[]*api.Match) colly.HTMLCallback { if lo.Contains([]api.MatchStatus{api.StatusFinished, api.StatusLive}, match.Status) { rawScores := strings.Split(versus, ":") - score0, _ := strconv.ParseInt(strings.TrimSpace(rawScores[0]), 10, 64) - score1, _ := strconv.ParseInt(strings.TrimSpace(rawScores[1]), 10, 64) - team0.Score = int(score0) - team1.Score = int(score1) + team0.Score, _ = strconv.Atoi(strings.TrimSpace(rawScores[0])) + team1.Score, _ = strconv.Atoi(strings.TrimSpace(rawScores[1])) } } e.ForEach("tr > td.match-filler", func(_ int, el *colly.HTMLElement) { match.Tournament.Name = el.ChildText("div:nth-child(1) > div:nth-child(1) a") - el.ForEach("div:nth-child(1) span.league-icon-small-image", func(i int, h *colly.HTMLElement) { - match.Tournament.Urls.Page = secureDomain + el.ChildAttr("a", "href") - match.Tournament.Urls.Logo = el.ChildAttr("img", "src") + el.ForEach("div:nth-child(1) span.league-icon-small-image", func(_ int, h *colly.HTMLElement) { + match.Tournament.Urls.Page = secureDomain + h.ChildAttr("a", "href") + match.Tournament.Urls.Logo = h.ChildAttr("img", "src") }) - el.ForEach("span > span.timer-object", func(i int, h *colly.HTMLElement) { + el.ForEach("span > span.timer-object", func(_ int, h *colly.HTMLElement) { // Get start time of match dataStartTimestamp := h.Attr("data-timestamp") startTimestamp, _ := strconv.ParseInt(dataStartTimestamp, 10, 64) @@ -151,9 +149,58 @@ func parseLiveMatchDetailsPage(ctx context.Context, req *http.Request) ([]*api.L return nil, nil } -func parseTeamProfilePage(ctx context.Context, req *http.Request) (*api.Team, error) { - // TODO: Implement - return nil, nil +func parseTeamProfilePage(team *api.Team) colly.HTMLCallback { + type playerTableSelector struct { + tableSelector string + activeStatus api.PlayerStatus + } + + schemas := []playerTableSelector{ + { + activeStatus: api.Active, + tableSelector: "h3:has(span#Active) + div.table-responsive > table.roster-card tr.Player", + }, + { + activeStatus: api.Inactive, + tableSelector: "h3:has(span#Inactive) + div.table-responsive > table.roster-card tr.Player", + }, + { + activeStatus: api.Former, + // Only take the active former table, because there are many inactive former player tables + tableSelector: "h3:has(span#Former) + div.active .table-responsive > table.roster-card tr.Player", + }, + { + activeStatus: api.StandIn, + tableSelector: "h3:has(span#StandIn) + div.table-responsive > table.roster-card tr.Player", + }, + } + + return func(h *colly.HTMLElement) { + team.FullName = h.ChildText("h1#firstHeading span") + + for _, pps := range schemas { + h.ForEach(pps.tableSelector, func(_ int, h *colly.HTMLElement) { + team.PlayerRoster = append(team.PlayerRoster, &api.Player{ + ID: h.ChildText("td.ID a"), + Name: h.ChildText("td.Name"), + Position: func() api.Position { + rawP := h.ChildText("td.Position") + if rawP == "" { + return api.PosUnknown + } + + rawP = rawP[len(rawP)-1:] + p, _ := strconv.ParseInt(rawP, 10, 64) + return api.Position(p) + }(), + JoinDate: sanitizeDateOfPlayerRosterTable(h, "td.Position + td.Date i"), + LeaveDate: sanitizeDateOfPlayerRosterTable(h, "td.Date + td.Date i"), + ActiveStatus: pps.activeStatus, + ProfilePageURL: secureDomain + h.ChildAttr("td.ID a", "href"), + }) + }) + } + } } func parseTournamentPage(ctx colly.Context, req *http.Request) (*api.Tournament, error) { diff --git a/pkg/api/liquipedia/utils.go b/pkg/api/liquipedia/utils.go index 756d8c2..b7ab6a1 100644 --- a/pkg/api/liquipedia/utils.go +++ b/pkg/api/liquipedia/utils.go @@ -45,3 +45,8 @@ func isValidTeamURL(potentialURL string) bool { func buildStreamPageLink(channelName string) string { return fmt.Sprintf("%s/dota2/Special:Stream/twitch/%s", secureDomain, channelName) } + +// Remove ref link's text [1] [11] by separating the string by "[" and taking the first element +func sanitizeDateOfPlayerRosterTable(h *colly.HTMLElement, selector string) string { + return strings.Split(h.ChildText(selector), "[")[0] +} diff --git a/pkg/api/player.go b/pkg/api/player.go index e58f9db..bf85c72 100644 --- a/pkg/api/player.go +++ b/pkg/api/player.go @@ -2,8 +2,13 @@ package api type PlayerStatus uint8 +func (ps PlayerStatus) String() string { + return [...]string{"Active", "Inactive", "Former", "StandIn"}[ps] +} + const ( - Active PlayerStatus = iota + Unknown PlayerStatus = iota + Active Inactive Former StandIn @@ -11,22 +16,27 @@ const ( type Position uint8 +func (p Position) String() string { + return [...]string{"Unknown", "1", "2", "3", "4", "5"}[p] +} + const ( - Pos1 Position = iota + PosUnknown Position = iota + Pos1 Pos2 Pos3 Pos4 Pos5 - Sub ) type Player struct { - ID string `json:"gameID"` - Name string `json:"name"` - JoinDate string `json:"joinDate"` - LeaveDate string `json:"leaveDate"` - NewTeam string `json:"newTeam"` - Position Position `json:"position"` - ActiveStatus PlayerStatus `json:"isActive"` - IsCaptain bool `json:"isCaptain"` + ID string `json:"gameID"` + Name string `json:"name"` + JoinDate string `json:"joinDate"` + LeaveDate string `json:"leaveDate"` + NewTeam string `json:"newTeam"` + Position Position `json:"position"` + ActiveStatus PlayerStatus `json:"isActive"` + IsCaptain bool `json:"isCaptain"` + ProfilePageURL string `json:"profilePageURL"` } diff --git a/screenshots/main-with-details.gif b/screenshots/main-with-details.gif new file mode 100644 index 0000000..7b37078 Binary files /dev/null and b/screenshots/main-with-details.gif differ diff --git a/screenshots/main.gif b/screenshots/main.gif deleted file mode 100644 index 268dd76..0000000 Binary files a/screenshots/main.gif and /dev/null differ