From efbe64d1530f37d44eaa9cd482363a6f924b362a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nojus=20Gudinavi=C4=8Dius?= Date: Thu, 27 Feb 2020 19:42:35 +0200 Subject: [PATCH] Add contests, register and unregister commands --- cf.go | 6 +++ client/contests.go | 119 +++++++++++++++++++++++++++++++++++++++++++ client/register.go | 96 ++++++++++++++++++++++++++++++++++ client/unregister.go | 104 +++++++++++++++++++++++++++++++++++++ client/util.go | 70 +++++++++++++++++++++++++ cmd/args.go | 47 +++++++++-------- cmd/cmd.go | 6 +++ cmd/contests.go | 67 ++++++++++++++++++++++++ cmd/register.go | 10 ++++ cmd/unregister.go | 10 ++++ cookiejar/jar.go | 9 ++++ 11 files changed, 522 insertions(+), 22 deletions(-) create mode 100644 client/contests.go create mode 100644 client/register.go create mode 100644 client/unregister.go create mode 100644 client/util.go create mode 100644 cmd/contests.go create mode 100644 cmd/register.go create mode 100644 cmd/unregister.go diff --git a/cf.go b/cf.go index 44290328..b9ac38ab 100644 --- a/cf.go +++ b/cf.go @@ -41,6 +41,9 @@ Usage: cf race [...] cf pull [ac] [...] cf clone [ac] [] + cf contests + cf register [...] + cf unregister [...] cf upgrade Options: @@ -107,6 +110,9 @@ Examples: cf pull Pull the latest codes of current problem into current path. cf clone xalanq Clone all codes of xalanq. + cf contests List upcoming contests + cf register 1270 Register for contest "Good Bye 2019" + cf unregister 1270 Unregister from a contest "Good Bye 2019" cf upgrade Upgrade the "cf" to the latest version from GitHub. File: diff --git a/client/contests.go b/client/contests.go new file mode 100644 index 00000000..a2e5bdd3 --- /dev/null +++ b/client/contests.go @@ -0,0 +1,119 @@ +package client + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +// ContestInfo contests information +type ContestInfo struct { + ID string + Name string + Start string + Length string + State string + Registration string + Registered bool +} + +func getRegistrationStatus(cell *goquery.Selection) (string, bool) { + participants := cell.Find(".contestParticipantCountLinkMargin") + participantCount := participants.Text() + participants.Remove() + if cell.Find(".welldone").Length() != 0 { + text := cleanText(cell.Text()) + return text + "\n" + participantCount, true + } + countdown := cell.Find(".countdown") + text := "" + if strings.Contains(cell.Text(), "»") { + text = "Registering" + parent := countdown.Parent() + countdownText := countdown.Text() + countdown.Remove() + note := cleanText(parent.Text()) + if strings.Contains(cell.Text(), "*") { + countdownText += "*" + } + return fmt.Sprintf("%s %s\n%s %s", text, participantCount, note, countdownText), false + } + return cleanText(cell.Text()), false +} + +func getState(cell *goquery.Selection) string { + cell.Find("a").Remove() + countdown := cell.Find(".countdown").Text() + cell.Find(".countdown").Remove() + text := cleanText(cell.Text()) + + return text + "\n" + countdown +} + +func findContests(body io.ReadCloser, utcOffset string) ([]ContestInfo, error) { + doc, err := goquery.NewDocumentFromReader(body) + if err != nil { + return nil, err + } + table := doc.Find(".datatable").First().Find("tbody") + rows := table.Find("tr").Slice(1, goquery.ToEnd) + contests := []ContestInfo{} + rows.Each(func(i int, row *goquery.Selection) { + contest := ContestInfo{} + contest.ID, _ = row.Attr("data-contestid") + row.Find("td").Each(func(j int, cell *goquery.Selection) { + switch j { + case 0: + cell.Find("a").Remove() + name := cleanText(cell.Text()) + contest.Name = strings.Replace(name, " (", "\n(", 1) + case 2: + contest.Start = parseWhen(cell.Find(".format-time").Text(), utcOffset) + case 3: + duration := cleanText(cell.Text()) + if strings.Count(duration, ":") == 2 { + duration = strings.TrimSuffix(duration, ":00") + } + contest.Length = duration + case 4: + contest.State = getState(cell) + case 5: + contest.Registration, contest.Registered = getRegistrationStatus(cell) + } + }) + contests = append(contests, contest) + }) + if err != nil { + return nil, err + } + return contests, nil +} + +// StatisContest get upcoming contests +func (c *Client) GetContests() (contests []ContestInfo, err error) { + URL := c.host + "/contests?complete=true" + resp, err := c.client.Get(URL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("Status code error: %d %s", resp.StatusCode, resp.Status) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if _, err = findHandle(body); err != nil { + return + } + utcOffset, err := findCfOffset(body) + if err != nil { + return nil, err + } + return findContests(ioutil.NopCloser(bytes.NewReader(body)), utcOffset) +} diff --git a/client/register.go b/client/register.go new file mode 100644 index 00000000..65e81232 --- /dev/null +++ b/client/register.go @@ -0,0 +1,96 @@ +package client + +import ( + "bytes" + "errors" + "fmt" + "io/ioutil" + "net/url" + "strings" + + "github.com/PuerkitoBio/goquery" + "github.com/fatih/color" + "github.com/xalanq/cf-tool/util" +) + +// Register for a contest +func (c *Client) Register(contestID string) error { + URL := fmt.Sprintf("%v/contestRegistration/%v", c.host, contestID) + resp, err := c.client.Get(URL) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + if _, err = findHandle(body); err != nil { + return err + } + if msg := findCodeforcesMessage(body); msg != "" { + return errors.New(msg) + } + doc, err := goquery.NewDocumentFromReader(ioutil.NopCloser(bytes.NewReader(body))) + if err != nil { + return err + } + color.HiCyan(findTitle(doc)) + if !agreesToTerms(doc) { + return errors.New("You cannot participate without agreeing to the terms") + } + formData, err := getFormData(doc, c) + if err != nil { + return err + } + resp, err = c.client.PostForm(URL, formData) + if err != nil { + return err + } + defer resp.Body.Close() + body, err = ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + fmt.Println(findCodeforcesMessage(body)) + return nil +} + +func findTitle(doc *goquery.Document) string { + title := doc.Find("h2").Text() + return cleanText(title) +} + +func agreesToTerms(doc *goquery.Document) bool { + label := cleanText(doc.Find("label[for=registrationTerms]").Text()) + terms := cleanText(doc.Find("#registrationTerms").Text()) + terms = strings.ReplaceAll(terms, "\n*", "\n *") + color.Green(label) + fmt.Println(terms) + return util.YesOrNo("Do you argree to the terms? (y/n)") +} + +func getFormData(doc *goquery.Document, c *Client) (url.Values, error) { + form := doc.Find(".contestRegistration").First() + data := url.Values{} + var err error + form.Find("input").Each(func(i int, input *goquery.Selection) { + key, k := input.Attr("name") + value, v := input.Attr("value") + if key != "" { + if !k || !v { + err = errors.New("Unable to get form data") + return + } + data.Set(key, value) + } + }) + if data.Get("_tta") == "" { + tta, err := getTta(c) + if err != nil { + return data, nil + } + data.Set("_tta", tta) + } + return data, err +} diff --git a/client/unregister.go b/client/unregister.go new file mode 100644 index 00000000..6cbd40ea --- /dev/null +++ b/client/unregister.go @@ -0,0 +1,104 @@ +package client + +import ( + "bytes" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +// Unregister from a contest +func (c *Client) Unregister(contestID string) error { + resp, err := getRegistrantsPage(c, contestID, 1) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + if _, err = findHandle(body); err != nil { + return err + } + if msg := findCodeforcesMessage(body); msg != "" { + return errors.New(msg) + } + doc, err := goquery.NewDocumentFromReader(ioutil.NopCloser(bytes.NewReader(body))) + if err != nil { + return err + } + formData, err := getUnregisterFormData(doc, c, contestID) + if err != nil { + return err + } + URL := fmt.Sprintf("%v/data/contestRegistration/%v", c.host, contestID) + resp, err = c.client.PostForm(URL, formData) + if err != nil { + return err + } + defer resp.Body.Close() + body, err = ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + if strings.Contains(string(body), `{"success":"true"}`) { + fmt.Println("Succesfully unregistered from the contest") + } else { + return errors.New("Can't unregister. Possible reason: you made at least one action in the contest") + } + return err +} + +func getUnregisterFormData(doc *goquery.Document, c *Client, contestID string) (url.Values, error) { + pageCount := getPageCount(doc) + for page := 1; page <= pageCount; page++ { + if page != 1 { + resp, err := getRegistrantsPage(c, contestID, page) + if err != nil { + return nil, err + } + defer resp.Body.Close() + doc, err = goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, err + } + } + user := doc.Find(".deleteParty").First() + participantID, ok := user.Attr("participantid") + if !ok { + continue + } + data := url.Values{} + token := getCsrfToken(doc) + data.Add("participantId", participantID) + data.Add("action", "deleteParty") + data.Add("csrf_token", token) + return data, nil + } + return nil, errors.New("You are not registered in this contest") +} + +func getRegistrantsPage(c *Client, contestID string, page int) (r *http.Response, err error) { + URL := fmt.Sprintf("%v/contestRegistrants/%v/friends/true/page/%d", c.host, contestID, page) + resp, err := c.client.Get(URL) + if err != nil { + return nil, err + } + return resp, nil +} + +func getPageCount(doc *goquery.Document) int { + count, ok := doc.Find(".page-index").Last().Attr("pageindex") + if ok { + c, _ := strconv.Atoi(count) + return c + } + return 1 +} diff --git a/client/util.go b/client/util.go new file mode 100644 index 00000000..979b6770 --- /dev/null +++ b/client/util.go @@ -0,0 +1,70 @@ +package client + +import ( + "errors" + "regexp" + "strconv" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +func cleanText(s string) string { + s = strings.Trim(s, " \n") + space := regexp.MustCompile(`\ +`) + s = space.ReplaceAllString(s, " ") + return s +} + +func findCodeforcesMessage(body []byte) string { + str := `\n\s{8}Codeforces\.showMessage\("(.+)"\);\s{8}Codeforces\.reformatTimes\(\)` + reg := regexp.MustCompile(str) + tmp := reg.FindStringSubmatch(string(body)) + if tmp != nil { + return strings.ReplaceAll(tmp[1], "
", "\n") + } + return "" +} + +func getCsrfToken(doc *goquery.Document) string { + token, _ := doc.Find("meta[name='X-Csrf-Token']").Attr("content") + if len(token) == 32 { + return token + } + token, _ = doc.Find("span.csrf-token").Attr("data-csrf") + if len(token) == 32 { + return token + } + return "" +} + +func getTta(c *Client) (string, error) { + cookie, err := c.Jar.GetEntry("codeforces.com", "/", "39ce7") + if err != nil { + return "", errors.New("Unable to get required cookie") + } + return decodeTta(cookie.Value), nil +} + +func decodeTta(cookie string) string { + var result int + for i := 0; i < len(cookie); i++ { + result = (result + (i+1)*(i+2)*int(cookie[i])) % 1009 + if i%3 == 0 { + result++ + } + if i%2 == 0 { + result *= 2 + } + if i > 0 { + result -= int(cookie[i/2]) / 2 * (result % 5) + } + for result < 0 { + result += 1009 + } + for result >= 1009 { + result -= 1009 + } + } + return strconv.Itoa(result) +} diff --git a/cmd/args.go b/cmd/args.go index 9e9c8b6b..c553a8fb 100644 --- a/cmd/args.go +++ b/cmd/args.go @@ -13,28 +13,31 @@ import ( // ParsedArgs parsed arguments type ParsedArgs struct { - Info client.Info - File string - Specifier []string `docopt:""` - Alias string `docopt:""` - Accepted bool `docopt:"ac"` - All bool `docopt:"all"` - Handle string `docopt:""` - Version string `docopt:"{version}"` - Config bool `docopt:"config"` - Submit bool `docopt:"submit"` - List bool `docopt:"list"` - Parse bool `docopt:"parse"` - Gen bool `docopt:"gen"` - Test bool `docopt:"test"` - Watch bool `docopt:"watch"` - Open bool `docopt:"open"` - Stand bool `docopt:"stand"` - Sid bool `docopt:"sid"` - Race bool `docopt:"race"` - Pull bool `docopt:"pull"` - Clone bool `docopt:"clone"` - Upgrade bool `docopt:"upgrade"` + Info client.Info + File string + Specifier []string `docopt:""` + Alias string `docopt:""` + Accepted bool `docopt:"ac"` + All bool `docopt:"all"` + Handle string `docopt:""` + Version string `docopt:"{version}"` + Config bool `docopt:"config"` + Submit bool `docopt:"submit"` + List bool `docopt:"list"` + Parse bool `docopt:"parse"` + Gen bool `docopt:"gen"` + Test bool `docopt:"test"` + Watch bool `docopt:"watch"` + Open bool `docopt:"open"` + Stand bool `docopt:"stand"` + Sid bool `docopt:"sid"` + Race bool `docopt:"race"` + Pull bool `docopt:"pull"` + Clone bool `docopt:"clone"` + Upgrade bool `docopt:"upgrade"` + Contests bool `docop:"contests"` + Register bool `docop:"register"` + Unregister bool `docop:"unregister"` } // Args global variable diff --git a/cmd/cmd.go b/cmd/cmd.go index 5dd29c92..8183ca46 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -49,6 +49,12 @@ func Eval(opts docopt.Opts) error { return Pull() } else if Args.Clone { return Clone() + } else if Args.Contests { + return Contests() + } else if Args.Register { + return Register() + } else if Args.Unregister { + return Unregister() } else if Args.Upgrade { return Upgrade() } diff --git a/cmd/contests.go b/cmd/contests.go new file mode 100644 index 00000000..ebdfd42b --- /dev/null +++ b/cmd/contests.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "io" + "os" + "strings" + + "github.com/fatih/color" + "github.com/olekukonko/tablewriter" + "github.com/xalanq/cf-tool/client" +) + +// Contests command +func Contests() error { + cln := client.Instance + contests, err := cln.GetContests() + if err != nil { + if err = loginAgain(cln, err); err == nil { + contests, err = cln.GetContests() + } + } + if err != nil { + return err + } + output := io.Writer(os.Stdout) + table := tablewriter.NewWriter(output) + table.SetHeader([]string{"ID", "Name", "Start", "Length", "State", "Registration"}) + table.SetAlignment(tablewriter.ALIGN_CENTER) + table.SetRowLine(true) + table.SetRowSeparator("─") + table.SetColumnSeparator("│") + table.SetCenterSeparator("┼") + table.SetAutoWrapText(false) + + colorCell := func(s string, doColor bool) string { + if doColor { + return colorText(s, color.GreenString) + } + return s + } + for _, contest := range contests { + ok := contest.Registered + table.Append([]string{ + colorCell(contest.ID, ok), + colorCell(contest.Name, ok), + colorCell(contest.Start, ok), + colorCell(contest.Length, ok), + colorCell(contest.State, ok), + colorCell(contest.Registration, ok), + }) + + } + table.Render() + return nil +} + +func colorText(text string, f func(s string, a ...interface{}) string) string { + lines := strings.Split(text, "\n") + out := "" + for i := 0; i < len(lines); i++ { + out += f(lines[i]) + if i != len(lines)-1 { + out += "\n" + } + } + return out +} diff --git a/cmd/register.go b/cmd/register.go new file mode 100644 index 00000000..5da8b81d --- /dev/null +++ b/cmd/register.go @@ -0,0 +1,10 @@ +package cmd + +import ( + "github.com/xalanq/cf-tool/client" +) + +// Register command +func Register() error { + return client.Instance.Register(Args.Info.ContestID) +} diff --git a/cmd/unregister.go b/cmd/unregister.go new file mode 100644 index 00000000..d0059999 --- /dev/null +++ b/cmd/unregister.go @@ -0,0 +1,10 @@ +package cmd + +import ( + "github.com/xalanq/cf-tool/client" +) + +// Unregister command +func Unregister() error { + return client.Instance.Unregister(Args.Info.ContestID) +} diff --git a/cookiejar/jar.go b/cookiejar/jar.go index 62f440ea..c5a2eaa3 100644 --- a/cookiejar/jar.go +++ b/cookiejar/jar.go @@ -119,6 +119,15 @@ func (j *Jar) Copy() *Jar { } } +// GetCookie custom impl +func (j *Jar) GetEntry(domain, path, name string) (entry, error) { + id := fmt.Sprintf("%s;%s;%s", domain, path, name) + if cookie, ok := j.entries[domain][id]; ok { + return cookie, nil + } + return entry{}, errors.New("Cookie not found") +} + // MarshalJSON my impl func (j *Jar) MarshalJSON() ([]byte, error) { return json.Marshal(j.entries)