package calendar
import (
"context"
"time"
"github.com/yagihash/fsw-calendar/event"
"github.com/yagihash/fsw-calendar/utils"
"google.golang.org/api/calendar/v3"
)
type Calendar struct {
cs *calendar.Service
calendarID string
tz *time.Location
}
func New(ctx context.Context, calendarID string, tz *time.Location) (*Calendar, error) {
cs, err := calendar.NewService(ctx)
if err != nil {
return nil, err
}
cal := &Calendar{
cs: cs,
calendarID: calendarID,
tz: tz,
}
return cal, nil
}
func (cal *Calendar) GetEvents(y, m, length int) (event.Events, error) {
start := time.Date(y, time.Month(m), 1, 0, 0, 0, 0, cal.tz).Format(time.RFC3339)
end := start
for i, tmpY, tmpM := 0, y, m; i < length; i++ {
tmpY, tmpM = utils.NextMonth(tmpY, tmpM)
end = time.Date(tmpY, time.Month(tmpM), 1, 0, 0, 0, 0, cal.tz).Format(time.RFC3339)
}
events, err := cal.cs.Events.List(cal.calendarID).ShowDeleted(false).
SingleEvents(true).TimeMin(start).TimeMax(end).Do()
if err != nil {
return event.Events{}, err
}
e := event.NewEvents(events.Items)
return e, nil
}
func (cal *Calendar) Insert(e *calendar.Event) error {
_, err := cal.cs.Events.Insert(cal.calendarID, e).Do()
return err
}
func (cal *Calendar) Delete(EventID string) error {
return cal.cs.Events.Delete(cal.calendarID, EventID).Do()
}
package main
import (
"context"
"os"
"time"
"go.uber.org/zap/zapcore"
"go.uber.org/zap"
"google.golang.org/api/calendar/v3"
"github.com/yagihash/fsw-calendar/config"
"github.com/yagihash/fsw-calendar/event"
"github.com/yagihash/fsw-calendar/logger"
)
const (
ExitOK = iota
ExitError
)
func main() {
os.Exit(realMain())
}
func realMain() int {
log, err := logger.New(zapcore.DebugLevel)
if err != nil {
return ExitError
}
defer log.Sync()
log.Info("logger is ready")
c, err := config.Load()
if err != nil {
log.Fatal("failed to load config", zap.Error(err))
return ExitError
}
calendarID, ok := os.LookupEnv("CALENDAR_ID")
if !ok {
log.Error("no calendar_id is specified")
return ExitError
}
jst, err := time.LoadLocation(c.Timezone)
if err != nil {
log.Fatal("failed to load timezone", zap.Error(err))
return ExitError
}
y := time.Now().In(jst).Year()
m := int(time.Now().In(jst).Month())
for i := 0; i < c.Recurrence; i++ {
ctx := context.Background()
cs, err := calendar.NewService(ctx)
if err != nil {
log.Fatal("failed to access google calendar API", zap.Error(err))
return ExitError
}
nextY, nextM := NextMonth(y, m)
events, err := cs.Events.List(calendarID).ShowDeleted(false).SingleEvents(true).
TimeMin(time.Date(y, time.Month(m), 1, 0, 0, 0, 0, jst).Format(time.RFC3339)).
TimeMax(time.Date(nextY, time.Month(nextM), 1, 0, 0, 0, 0, jst).Format(time.RFC3339)).Do()
if err != nil {
log.Error("err", zap.Error(err))
return ExitError
}
log.Info("fetched events", zap.Int("size", len(events.Items)), zap.Int("year", y), zap.Int("month", m))
existingEvents := event.NewEvents(events.Items)
for _, e := range existingEvents {
if err := cs.Events.Delete(calendarID, e.Id).Do(); err != nil {
log.Error("failed to reset event", zap.Error(err), zap.Any("event", e), zap.Int("year", y), zap.Int("month", m))
}
}
y, m = nextY, nextM
}
return ExitOK
}
func NextMonth(y, m int) (int, int) {
var nextY, nextM int
if m == 12 {
nextY = y + 1
nextM = 1
} else {
nextY = y
nextM = m + 1
}
return nextY, nextM
}
package config
import (
"github.com/kelseyhightower/envconfig"
"go.uber.org/zap/zapcore"
)
type Config struct {
Timezone string `envconfig:"TIMEZONE" default:"Asia/Tokyo"`
Recurrence int `envconfig:"RECURRENCE" default:"2"`
LogLevel zapcore.Level `envconfig:"LOG_LEVEL" default:"INFO"`
Hostname string `envconfig:"HOSTNAME" default:"www.fsw.tv"`
Webhook string `envconfig:"SLACK_WEBHOOK"`
}
func Load() (*Config, error) {
var c Config
if err := envconfig.Process("", &c); err != nil {
return nil, err
}
return &c, nil
}
package config
import (
"encoding/json"
"go.uber.org/zap/zapcore"
"github.com/yagihash/fsw-calendar/fetcher/class"
"github.com/yagihash/fsw-calendar/fetcher/course"
)
type Data struct {
CalendarID string `json:"calendar_id"`
Course course.Course `json:"course"`
Class class.Class `json:"class""`
}
func (d *Data) UnmarshalJSON(b []byte) error {
var tmp map[string]string
if err := json.Unmarshal(b, &tmp); err != nil {
return err
}
switch tmp["course"] {
case "rc":
d.Course = course.RC
case "ss":
d.Course = course.SS
default:
d.Course = course.Unknown
}
switch tmp["class"] {
case "ss-4":
d.Class = class.SS4
case "t-4":
d.Class = class.T4
case "ns-4":
d.Class = class.NS4
case "s-4":
d.Class = class.S4
default:
d.Class = class.Unknown
}
d.CalendarID = tmp["calendar_id"]
return nil
}
func (d *Data) MarshalLogObject(e zapcore.ObjectEncoder) error {
e.AddString("calendar_id", d.CalendarID)
e.AddString("course", d.Course.String())
e.AddString("class", d.Class.String())
return nil
}
package event
import (
"fmt"
"strings"
"time"
"github.com/yagihash/fsw-calendar/fetcher"
"google.golang.org/api/calendar/v3"
)
type Event struct {
*calendar.Event
}
func New(date, start, end, title string) *Event {
template := "%sT%s:00+09:00"
return &Event{
&calendar.Event{
Summary: title,
Start: &calendar.EventDateTime{
DateTime: fmt.Sprintf(template, date, strings.TrimPrefix(start, "0")),
},
End: &calendar.EventDateTime{
DateTime: fmt.Sprintf(template, date, strings.TrimPrefix(end, "0")),
},
},
}
}
func NewFromDocEvent(d fetcher.DocEvent) *Event {
return New(d.Date, d.Start, d.End, d.Title)
}
func (e *Event) Equals(c *Event) bool {
es, err := time.Parse(time.RFC3339, e.Start.DateTime)
if err != nil {
panic(err)
}
ee, err := time.Parse(time.RFC3339, e.End.DateTime)
if err != nil {
panic(err)
}
cs, err := time.Parse(time.RFC3339, c.Start.DateTime)
if err != nil {
panic(err)
}
ce, err := time.Parse(time.RFC3339, c.End.DateTime)
if err != nil {
panic(err)
}
return e.Summary == c.Summary && es.Equal(cs) && ee.Equal(ce)
}
package event
import "google.golang.org/api/calendar/v3"
type Events []*Event
func NewEvents(items []*calendar.Event) Events {
events := make([]*Event, len(items))
for i := 0; i < len(items); i++ {
events[i] = &Event{items[i]}
}
return events
}
func (es Events) Diff(another Events) (negative, positive Events) {
for _, e := range another {
if !(es.Has(e) || negative.Has(e)) {
negative = append(negative, e)
}
}
for _, e := range es {
if !(another.Has(e) || positive.Has(e)) {
positive = append(positive, e)
}
}
return
}
func (es Events) Has(b *Event) bool {
for _, a := range es {
if a.Equals(b) {
return true
}
}
return false
}
func (es Events) Unique() (unique Events) {
for i, e := range es {
// note: used in the case that the original calendar is broken. no need to ensure uniqueness seriously.
if es[i+1:].Has(e) {
// do nothing
} else {
unique = append(unique, e)
}
}
return
}
package class
type Class struct {
val string
}
var (
Unknown = Class{}
SS4 = Class{val: "ss-4"}
T4 = Class{val: "t-4"}
NS4 = Class{val: "ns-4"}
S4 = Class{val: "s-4"}
)
func (c Class) String() string {
return c.val
}
package course
type Course struct {
val string
}
var (
Unknown = Course{}
RC = Course{val: "rc"} // Racing course
SS = Course{val: "ss"} // Short course
)
func (c Course) String() string {
return c.val
}
package fetcher
import (
"fmt"
"net/http"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/yagihash/fsw-calendar/fetcher/class"
"github.com/yagihash/fsw-calendar/fetcher/course"
"github.com/yagihash/fsw-calendar/utils"
)
var (
urlTmpl = "https://%s/driving/sports/%s/%s/%d/%02d.html"
)
type DocEvent struct {
Date string
Start string
End string
Title string
}
type Client interface {
Get(url string) (*http.Response, error)
}
type Fetcher struct {
hostname string
course course.Course
class class.Class
httpclient Client
}
func New(hostname string, course course.Course, class class.Class, c Client) *Fetcher {
return &Fetcher{
hostname: hostname,
course: course,
class: class,
httpclient: c,
}
}
func (f *Fetcher) FetchDocEvents(y, m, length int) ([]DocEvent, error) {
var rawEvents []DocEvent
for i := 0; i < length; i++ {
url := fmt.Sprintf(urlTmpl, f.hostname, f.course, f.class, y, m)
res, err := f.httpclient.Get(url)
if err != nil {
return rawEvents, fmt.Errorf("failed to fetch raw events from %s: %w", url, err)
}
// Even if the schedule for the next month or later is not public, it is not the error.
if i != 0 && res.StatusCode == http.StatusNotFound {
return rawEvents, nil
}
if res.StatusCode != http.StatusOK {
return rawEvents, fmt.Errorf("got status code %d on %s", res.StatusCode, url)
}
doc, err := goquery.NewDocumentFromReader(res.Body)
if err != nil {
return rawEvents, fmt.Errorf("failed to initialize document reader: %w", err)
}
doc.Find("#table-calendar > tbody > tr.row-rc > td.type > div > p").Each(func(i int, s *goquery.Selection) {
d, _ := s.Parent().Parent().Parent().Attr("data-date")
t := strings.Split(doc.Find("#table-calendar > tbody > tr.row-rc > td.time > div > p").Eq(i).Text(), "~")
rawEvents = append(rawEvents, DocEvent{d, t[0], t[1], s.Text()})
})
y, m = utils.NextMonth(y, m)
_ = res.Body.Close()
}
return rawEvents, nil
}
package function
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"cloud.google.com/go/pubsub"
"go.uber.org/zap"
"github.com/yagihash/fsw-calendar/calendar"
"github.com/yagihash/fsw-calendar/config"
"github.com/yagihash/fsw-calendar/event"
"github.com/yagihash/fsw-calendar/fetcher"
"github.com/yagihash/fsw-calendar/logger"
"github.com/yagihash/fsw-calendar/notify/slack"
)
func Register(ctx context.Context, message *pubsub.Message) (err error) {
c, err := config.Load()
if err != nil {
return err
}
log, err := logger.New(c.LogLevel)
if err != nil {
return err
}
defer func() { _ = log.Sync() }()
log.Debug("logger is ready")
notify := slack.New(c.Webhook)
defer func() {
if err != nil {
_ = notify.Warn(context.Background(), fmt.Sprintf("error: %s", err.Error()))
}
}()
var data config.Data
if err := json.Unmarshal(message.Data, &data); err != nil {
log.Error("failed to unmarshal message", zap.Error(err))
}
log.Info("start processing received data", zap.Any("data", data))
jst, err := time.LoadLocation(c.Timezone)
if err != nil {
log.Error("failed to load timezone", zap.Error(err))
return err
}
y := time.Now().In(jst).Year()
m := int(time.Now().In(jst).Month())
f := fetcher.New(c.Hostname, data.Course, data.Class, http.DefaultClient)
docEvents, err := f.FetchDocEvents(y, m, c.Recurrence)
if err != nil {
log.Error("failed to fetch schedule data", zap.Error(err))
}
log.Debug("loaded schedules", zap.Any("events", docEvents))
var fetchedEvents event.Events
for _, d := range docEvents {
fetchedEvents = append(fetchedEvents, event.NewFromDocEvent(d))
}
cs, err := calendar.New(ctx, data.CalendarID, jst)
if err != nil {
log.Error("failed to initialize calendar service", zap.Error(err))
return err
}
existingEvents, err := cs.GetEvents(y, m, c.Recurrence)
if err != nil {
log.Error("failed to load existing events from google calendar", zap.Error(err))
return err
}
toBeAdded, toBeDeleted := existingEvents.Diff(fetchedEvents)
if len(toBeAdded) == 0 && len(toBeDeleted) == 0 {
log.Debug("no update", zap.Any("existing", existingEvents), zap.Any("fetched", fetchedEvents))
return nil
}
log.Info("need updates", zap.Any("to_be_added", toBeAdded), zap.Any("to_be_deleted", toBeDeleted))
for _, e := range toBeAdded {
if e == nil {
continue
}
if err := cs.Insert(e.Event); err != nil {
log.Error("failed to insert event", zap.Error(err), zap.Any("event", e))
} else {
log.Debug("added new event", zap.Any("event", e))
}
}
for _, e := range toBeDeleted {
if err := cs.Delete(e.Id); err != nil {
log.Error("failed to delete event", zap.Any("event", e))
} else {
log.Debug("deleted stale event", zap.Any("event", e))
}
}
_ = notify.Info(ctx, fmt.Sprintf("updated calendar (%s)", strings.ToUpper(data.Class.String())))
return nil
}
package logger
import (
"cloud.google.com/go/logging"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var logLevel = map[zapcore.Level]string{
zapcore.DebugLevel: logging.Debug.String(),
zapcore.InfoLevel: logging.Info.String(),
zapcore.WarnLevel: logging.Warning.String(),
zapcore.ErrorLevel: logging.Error.String(),
zapcore.DPanicLevel: logging.Critical.String(),
zapcore.PanicLevel: logging.Critical.String(),
zapcore.FatalLevel: logging.Critical.String(),
}
func EncodeLevel(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
switch l {
case zapcore.DebugLevel:
enc.AppendString(logging.Debug.String())
case zapcore.InfoLevel:
enc.AppendString(logging.Info.String())
case zapcore.WarnLevel:
enc.AppendString(logging.Warning.String())
case zapcore.ErrorLevel:
enc.AppendString(logging.Error.String())
default:
enc.AppendString(logging.Critical.String())
}
}
func New(level zapcore.Level) (*zap.Logger, error) {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "time"
encoderConfig.LevelKey = "severity"
encoderConfig.MessageKey = "message"
encoderConfig.EncodeLevel = EncodeLevel
encoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder
cfg := zap.NewProductionConfig()
cfg.Level.SetLevel(level)
cfg.EncoderConfig = encoderConfig
return cfg.Build()
}
package slack
import (
"context"
"fmt"
goslack "github.com/slack-go/slack"
)
const (
notifierType = "slack"
)
type Slack struct {
webhook string
}
func New(webhook string) *Slack {
return &Slack{webhook}
}
func (s *Slack) Type() string {
return notifierType
}
func (s *Slack) Info(ctx context.Context, message string) error {
msg := messageInfo(message)
return s.post(ctx, msg)
}
func (s *Slack) Warn(ctx context.Context, message string) error {
msg := messageWarn(message)
return s.post(ctx, msg)
}
func (s *Slack) post(ctx context.Context, msg *goslack.WebhookMessage) error {
if err := goslack.PostWebhookContext(ctx, s.webhook, msg); err != nil {
return fmt.Errorf("failed to call webhook: %w", err)
}
return nil
}
func messageInfo(message string) *goslack.WebhookMessage {
return &goslack.WebhookMessage{
Attachments: []goslack.Attachment{
{
Color: "good",
Fallback: "fsw-calendar info notification",
Text: message,
MarkdownIn: []string{"text"},
},
},
}
}
func messageWarn(message string) *goslack.WebhookMessage {
return &goslack.WebhookMessage{
Attachments: []goslack.Attachment{
{
Color: "warning",
Fallback: "fsw-calendar warning notification",
Text: message,
MarkdownIn: []string{"text"},
},
},
}
}
package utils
func NextMonth(y, m int) (int, int) {
var nextY, nextM int
if m == 12 {
nextY = y + 1
nextM = 1
} else {
nextY = y
nextM = m + 1
}
return nextY, nextM
}