├── .gitignore ├── cron.yaml ├── app.yaml ├── README.md ├── main.go └── atcoder.go /.gitignore: -------------------------------------------------------------------------------- 1 | test.go 2 | *.html 3 | contest 4 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - description: "new daily summary job" 3 | url: /update 4 | schedule: every 5 minutes synchronized 5 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: go 2 | api_version: go1 3 | 4 | handlers: 5 | - url: /.* 6 | script: _go_app 7 | 8 | threadsafe: true 9 | 10 | automatic_scaling: 11 | min_idle_instances: automatic 12 | max_idle_instances: 1 13 | min_pending_latency: 3000ms 14 | max_pending_latency: automatic 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AtCoder API(informal) 2 | - [AtCoder](https://atcoder.jp/)の全コンテスト情報を取得できます 3 | 4 | ## [Contest](https://atcoder-api.appspot.com/contests) 5 | | Field | Description | 6 | | ---------------- | ------------------------------------------------------------ | 7 | | id | String.Contest id. Contest URL is https://beta.atcoder.jp/contests/{id} | 8 | | title | String. Contest title. | 9 | | startTimeSeconds | Integer.Contest start time in unix format. | 10 | | durationSeconds | Integer.Duration of the contest in seconds. | 11 | | ratedRange | String. Contest rated range. | 12 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // package main 2 | 3 | package hello 4 | 5 | import ( 6 | "context" 7 | "io" 8 | "net/http" 9 | "time" 10 | 11 | "cloud.google.com/go/storage" 12 | "github.com/gin-gonic/gin" 13 | 14 | "google.golang.org/appengine" 15 | "google.golang.org/appengine/log" 16 | ) 17 | 18 | type demo struct { 19 | client *storage.Client 20 | bucketName string 21 | bucket *storage.BucketHandle 22 | 23 | w io.Writer 24 | ctx context.Context 25 | // cleanUp is a list of filenames that need cleaning up at the end of the demo. 26 | cleanUp []string 27 | // failed indicates that one or more of the demo steps failed. 28 | failed bool 29 | } 30 | 31 | // 本番用 32 | func init() { 33 | // Todo: AtCoderを最初に1度呼ぶ 34 | r := gin.New() 35 | r.GET("/update", Update) 36 | r.GET("/contests", Json) 37 | http.Handle("/", r) 38 | } 39 | 40 | func Json(context *gin.Context) { 41 | // var r *http.Request = context.Request 42 | // ctx := appengine.NewContext(r) 43 | 44 | atcoder := &AtCoder{} 45 | atcoder.Context = context // 強制的に設定してる。よくない 46 | // atcoder.LoadGob("contests") 47 | // ファイルから読み込む 48 | atcoder.FileIO("read") 49 | context.JSON(http.StatusOK, atcoder.Contests) 50 | } 51 | 52 | func Update(context *gin.Context) { 53 | var r *http.Request = context.Request 54 | ctx := appengine.NewContext(r) 55 | 56 | log.Infof(ctx, "GET!! (nomikura)") // アクセスログ 57 | 58 | atcoder := &AtCoder{} 59 | atcoder.SetContestData(context) 60 | 61 | context.String(http.StatusOK, "Finish update!!"+time.Now().String()) 62 | // responseContests = atcoder.Contests 63 | 64 | } 65 | -------------------------------------------------------------------------------- /atcoder.go: -------------------------------------------------------------------------------- 1 | // package main 2 | 3 | package hello 4 | 5 | import ( 6 | "bytes" 7 | "encoding/gob" 8 | "fmt" 9 | "io/ioutil" 10 | "net/http" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "cloud.google.com/go/storage" 17 | "google.golang.org/appengine/file" 18 | 19 | "github.com/gin-gonic/gin" 20 | "google.golang.org/appengine" 21 | "google.golang.org/appengine/log" 22 | "google.golang.org/appengine/urlfetch" 23 | 24 | "github.com/PuerkitoBio/goquery" 25 | ) 26 | 27 | // AtCoderのコンテスト情報を管理するモジュール 28 | type AtCoder struct { 29 | Contests []AtCoderContest 30 | RawContests []RawAtCoderContest 31 | HttpClient *http.Client 32 | Context *gin.Context 33 | } 34 | 35 | type RawAtCoderContest struct { 36 | ID string 37 | Title string 38 | StartTime string 39 | Duration string 40 | Rated string 41 | } 42 | 43 | // AtCoderのコンテスト情報 44 | type AtCoderContest struct { 45 | ID string `json:"id"` 46 | Title string `json:"title"` 47 | StartTime int64 `json:"startTimeSeconds"` 48 | Duration int64 `json:"durationSeconds"` 49 | Rated string `json:"ratedRange"` 50 | } 51 | 52 | func (atcoder *AtCoder) FileIO(operate string) { 53 | var r *http.Request = atcoder.Context.Request 54 | var w http.ResponseWriter = atcoder.Context.Writer 55 | ctx := appengine.NewContext(r) 56 | 57 | // デフォルトのバケットを指定する(App Engineのコンテストから取得できる) 58 | bucket, err := file.DefaultBucketName(ctx) 59 | if err != nil { 60 | log.Errorf(ctx, "Faild to get default GCS bucket name: %v", err) 61 | } 62 | 63 | // clientをつくる 64 | client, err := storage.NewClient(ctx) 65 | if err != nil { 66 | log.Errorf(ctx, "Faild to create client: %v", err) 67 | } 68 | defer client.Close() 69 | 70 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 71 | 72 | buf := &bytes.Buffer{} 73 | d := &demo{ 74 | w: buf, 75 | ctx: ctx, 76 | client: client, 77 | bucket: client.Bucket(bucket), 78 | bucketName: bucket, 79 | } 80 | 81 | fileName := "demo-testfile-go" 82 | 83 | if operate == "write" { 84 | // コンテストデータをバイナリにエンコード 85 | var binaryData []byte 86 | Encode(atcoder.Contests, &binaryData) 87 | // ファイルに書き込む 88 | d.createFile(fileName, binaryData) 89 | log.Infof(ctx, "Write to file") 90 | } else if operate == "read" { 91 | // ファイルをバイナリで読み込む 92 | var binaryData []byte 93 | d.readFile(fileName, &binaryData) 94 | // バイナリをコンテストデータにデコード 95 | Decode(binaryData, &atcoder.Contests) 96 | log.Infof(ctx, "Read file") 97 | } else { 98 | log.Infof(ctx, "operation is not read and write") 99 | } 100 | 101 | if d.failed { 102 | w.WriteHeader(http.StatusInternalServerError) 103 | buf.WriteTo(w) 104 | } else { 105 | w.WriteHeader(http.StatusOK) 106 | buf.WriteTo(w) 107 | } 108 | } 109 | 110 | //[START write] 111 | func (d *demo) createFile(fileName string, byteDataToWrite []byte) { 112 | wc := d.bucket.Object(fileName).NewWriter(d.ctx) 113 | wc.ContentType = "text/plain" 114 | wc.Metadata = map[string]string{ 115 | "x-goog-meta-foo": "foo", 116 | "x-goog-meta-bar": "bar", 117 | } 118 | d.cleanUp = append(d.cleanUp, fileName) 119 | 120 | // 書き込む 121 | if _, err := wc.Write(byteDataToWrite); err != nil { 122 | d.errorf("createFile: unable to write data to bucket %q, file %q: %v", d.bucketName, fileName, err) 123 | return 124 | } 125 | 126 | // ファイル閉じてるのかな?これがないと書き込めない 127 | if err := wc.Close(); err != nil { 128 | d.errorf("createFile: unable to close bucket %q, file %q: %v", d.bucketName, fileName, err) 129 | return 130 | } 131 | } 132 | 133 | //[END write] 134 | 135 | //[START read] 136 | func (d *demo) readFile(fileName string, data *[]byte) { 137 | // ファイルを開く 138 | rc, err := d.bucket.Object(fileName).NewReader(d.ctx) 139 | if err != nil { 140 | d.errorf("readFile: unable to open file from bucket %q, file %q: %v", d.bucketName, fileName, err) 141 | return 142 | } 143 | defer rc.Close() 144 | 145 | // データを読み込む 146 | slurp, err := ioutil.ReadAll(rc) 147 | if err != nil { 148 | d.errorf("readFile: unable to read data from bucket %q, file %q: %v", d.bucketName, fileName, err) 149 | return 150 | } 151 | 152 | *data = slurp 153 | } 154 | 155 | //[END read] 156 | 157 | // dataをbyteArrayにエンコードする 158 | func Encode(data interface{}, byteArray *[]byte) { 159 | buffer := new(bytes.Buffer) 160 | encoder := gob.NewEncoder(buffer) 161 | err := encoder.Encode(data) 162 | if err != nil { 163 | // log.Print("encode: ", err) 164 | } 165 | 166 | *byteArray = buffer.Bytes() 167 | } 168 | 169 | // byteArrayをdataにデコードする(dataはポインタ型) 170 | func Decode(byteArray []byte, data interface{}) { 171 | buffer := bytes.NewBuffer(byteArray) 172 | dec := gob.NewDecoder(buffer) 173 | err := dec.Decode(data) 174 | if err != nil { 175 | // log.Print("decode: ", err) 176 | } 177 | } 178 | 179 | func (d *demo) errorf(format string, args ...interface{}) { 180 | d.failed = true 181 | fmt.Fprintln(d.w, fmt.Sprintf(format, args...)) 182 | log.Errorf(d.ctx, format, args...) 183 | } 184 | 185 | func (atcoder *AtCoder) SetContestData(context *gin.Context) { 186 | // atcoder.HttpClient = client 187 | atcoder.Context = context 188 | 189 | // 予定されたコンテストの生データを取得 190 | atcoder.GetFutureContest() 191 | 192 | // 過去のコンテストの生データを取得 193 | atcoder.GetPastContest() 194 | 195 | // 5. 生のコンテストデータをパースする 196 | for _, rawContest := range atcoder.RawContests { 197 | atcoder.Contests = append(atcoder.Contests, ParseSum(rawContest)) 198 | } 199 | 200 | sort.Slice(atcoder.Contests, func(i, j int) bool { return atcoder.Contests[i].StartTime < atcoder.Contests[j].StartTime }) 201 | 202 | // ファイルに書き込む 203 | atcoder.FileIO("write") 204 | // atcoder.StoreGob("contests") 205 | } 206 | 207 | // 予定されたコンテストデータを取得する 208 | func (atcoder *AtCoder) GetFutureContest() { 209 | // var responseWriter http.ResponseWriter = atcoder.Context.Writer 210 | var request *http.Request = atcoder.Context.Request 211 | 212 | context := appengine.NewContext(request) 213 | client := urlfetch.Client(context) 214 | 215 | // 1. GETリクエスト 216 | response, err := client.Get("https://beta.atcoder.jp/contests/?lang=ja") 217 | time.Sleep(2 * time.Second) 218 | if err != nil { 219 | fmt.Print(err) 220 | return 221 | } 222 | 223 | // 2. goqueryを使えるようにする 224 | doc, err := goquery.NewDocumentFromReader(response.Body) 225 | if err != nil { 226 | fmt.Print(err) 227 | } 228 | 229 | // 3. 予定されたコンテストのテーブルを取得 230 | var tableSelection *goquery.Selection 231 | doc.Find("h3").Each(func(i int, s *goquery.Selection) { 232 | if h3 := s.Text(); h3 == "予定されたコンテスト" { 233 | tableSelection = s.Next() 234 | } 235 | }) 236 | 237 | // 4. 生のコンテストデータを取得 238 | atcoder.GetRawContestFromTable(tableSelection) 239 | 240 | // 5. 生のコンテストデータをパースする 241 | // for _, rawContest := range atcoder.RawContests { 242 | // atcoder.Contests = append(atcoder.Contests, ParseSum(rawContest)) 243 | // } 244 | 245 | } 246 | 247 | // 過去のコンテストデータを取得する 248 | func (atcoder *AtCoder) GetPastContest() { 249 | baseURL := "https://beta.atcoder.jp/contests/archive?lang=ja" 250 | // 準備 251 | var request *http.Request = atcoder.Context.Request 252 | context := appengine.NewContext(request) 253 | client := urlfetch.Client(context) 254 | 255 | numberOfPage, ok := atcoder.GetNumberOfPage(baseURL) 256 | // ページ番号の取得に失敗 257 | if !ok { 258 | log.Infof(context, "Faild to get number of page!!") 259 | return 260 | } 261 | 262 | for page := 1; page <= numberOfPage; page++ { 263 | // 1. GETリクエスト 264 | response, err := client.Get(baseURL + "&page=" + strconv.Itoa(page)) 265 | time.Sleep(2 * time.Second) 266 | if err != nil { 267 | fmt.Print(err) 268 | return 269 | } 270 | 271 | // 2. goqueryを使えるようにする 272 | doc, err := goquery.NewDocumentFromReader(response.Body) 273 | if err != nil { 274 | fmt.Print(err) 275 | } 276 | 277 | // 3. 予定されたコンテストのテーブルを取得 278 | var tableSelection *goquery.Selection = doc.Find("table") 279 | 280 | // 4. 生のコンテストデータを取得 281 | atcoder.GetRawContestFromTable(tableSelection) 282 | 283 | // 5. 生のコンテストデータをパースする 284 | // for _, rawContest := range atcoder.RawContests { 285 | // atcoder.Contests = append(atcoder.Contests, ParseSum(rawContest)) 286 | // } 287 | } 288 | 289 | } 290 | 291 | func (atcoder *AtCoder) GetNumberOfPage(baseURL string) (int, bool) { 292 | var request *http.Request = atcoder.Context.Request 293 | 294 | context := appengine.NewContext(request) 295 | client := urlfetch.Client(context) 296 | 297 | // 1. Getリクエスト 298 | response, err := client.Get(baseURL) 299 | time.Sleep(2 * time.Second) 300 | if err != nil { 301 | fmt.Print(err) 302 | return 0, false 303 | } 304 | 305 | // 2. goqueryを使えるようにする 306 | doc, err := goquery.NewDocumentFromReader(response.Body) 307 | if err != nil { 308 | fmt.Print(err) 309 | return 0, false 310 | } 311 | 312 | // 3. 番号を取得 313 | numberOfPage := 1 314 | doc.Find("ul > li > a").Each(func(i int, s *goquery.Selection) { 315 | href, exists := s.Attr("href") 316 | // aタグにhrefが存在しない 317 | if !exists { 318 | return 319 | } 320 | // hrefに[page=]が含まれない 321 | if !strings.Contains(href, "page=") { 322 | return 323 | } 324 | 325 | // タグの中身を数値にできる 326 | if page, err := strconv.Atoi(s.Text()); err == nil { 327 | // log.Infof(context, "pageNumger(nomikura): %+v", page) 328 | if page > numberOfPage { 329 | numberOfPage = page 330 | } 331 | } 332 | 333 | }) 334 | // log.Infof(context, "pageSize(nomikura): %+v", pageSize) 335 | return numberOfPage, true 336 | } 337 | 338 | // 生のコンテストデータをパースする 339 | func ParseSum(rawContest RawAtCoderContest) AtCoderContest { 340 | // Durationを求める 341 | str := strings.Replace(rawContest.Duration, ":", "h", 1) + "m" 342 | tim, _ := time.ParseDuration(str) 343 | duration := int64(tim.Seconds()) 344 | 345 | // StartTimeを求める 346 | start := rawContest.StartTime 347 | atoi := func(str string) int { 348 | ret, _ := strconv.Atoi(str) 349 | return ret 350 | } 351 | // [2018-09-22 21:00:00+0900]の形式で抜き出した時間を無理矢理Timeオブジェクトにする 352 | year, month, day, hour, minute := atoi(start[:4]), atoi(start[5:7]), atoi(start[8:10]), atoi(start[11:13]), atoi(start[14:16]) 353 | // 取得する時間はJSTなので、日本時間をTimeオブジェクトにするように処理する 354 | jst, _ := time.LoadLocation("Asia/Tokyo") 355 | startTime := time.Date(year, time.Month(month), day, hour, minute, 0, 0, jst) 356 | unix := startTime.Unix() 357 | 358 | return AtCoderContest{ 359 | Title: rawContest.Title, 360 | ID: rawContest.ID, 361 | StartTime: unix, 362 | Duration: duration, 363 | Rated: rawContest.Rated, 364 | } 365 | } 366 | 367 | // 生のコンテストデータをセットする 368 | func (atcoder *AtCoder) GetRawContestFromTable(tableSelection *goquery.Selection) { 369 | tableSelection.Find("div > table > tbody > tr").Each(func(i int, trSelection *goquery.Selection) { 370 | // とりあえず文字列でテーブル情報を取得 371 | var href string 372 | var rawData [4]string 373 | trSelection.Find("td").Each(func(i int, tdSelection *goquery.Selection) { 374 | rawData[i] = tdSelection.Text() 375 | if i == 1 { 376 | href, _ = tdSelection.Find("a").Attr("href") 377 | } 378 | }) 379 | rawContest := RawAtCoderContest{ 380 | StartTime: rawData[0], 381 | Title: rawData[1], 382 | Duration: rawData[2], 383 | Rated: rawData[3], 384 | ID: href[10:], 385 | } 386 | atcoder.RawContests = append(atcoder.RawContests, rawContest) 387 | }) 388 | } 389 | --------------------------------------------------------------------------------