├── .gitignore ├── myfitnesspal ├── main_test.go ├── main.go └── diary.go ├── client.go ├── client_test.go ├── types.go ├── login.go ├── README.md ├── food_diary_test.go └── food_diary.go /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore binaries 2 | /myfitnesspal/myfitnesspal 3 | 4 | # ignore temp files 5 | /*.env 6 | 7 | -------------------------------------------------------------------------------- /myfitnesspal/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestCompiles(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package myfitnesspal 2 | 3 | import "net/http" 4 | 5 | const ( 6 | Codebase = "https://www.myfitnesspal.com/" 7 | LoginUrl = Codebase + "account/login" 8 | FoodDiaryUrl = Codebase + "food/diary/" 9 | ) 10 | 11 | type Client struct { 12 | client *http.Client 13 | username string 14 | } 15 | 16 | func New(username, password string) (*Client, error) { 17 | client, err := login(username, password) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return &Client{ 23 | client: client, 24 | username: username, 25 | }, nil 26 | } 27 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package myfitnesspal 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | func TestDateFormat(t *testing.T) { 12 | Convey("Given a time.Time instance", t, func() { 13 | now := time.Date(2014, 4, 5, 6, 0, 0, 0, time.Local) 14 | 15 | Convey("When I call #ToDate", func() { 16 | dateString := now.Format("2006-01-02") 17 | 18 | Convey("Then I expect a well formatted date string", func() { 19 | So(dateString, ShouldEqual, "2014-04-05") 20 | }) 21 | }) 22 | }) 23 | } 24 | 25 | func TestItoa(t *testing.T) { 26 | Convey("Given a number => 1,234", t, func() { 27 | i := 1234 28 | 29 | Convey("When I call #Itoa", func() { 30 | str := strconv.Itoa(i) 31 | 32 | Convey("When I expect the number to have been parsed successfully", func() { 33 | So(str, ShouldEqual, "1234") 34 | }) 35 | }) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /myfitnesspal/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/codegangsta/cli" 8 | ) 9 | 10 | type Options struct { 11 | Username string 12 | Password string 13 | } 14 | 15 | func Opts(c *cli.Context) *Options { 16 | return &Options{ 17 | Username: c.String("username"), 18 | Password: c.String("password"), 19 | } 20 | } 21 | 22 | var flags = []cli.Flag{ 23 | cli.StringFlag{"username", "", "myfitnesspal username", "MYFITNESSPAL_USERNAME"}, 24 | cli.StringFlag{"password", "", "myfitnesspal password", "MYFITNESSPAL_PASSWORD"}, 25 | } 26 | 27 | func main() { 28 | app := cli.NewApp() 29 | app.Name = "myfitnesspal" 30 | app.Usage = "cli interface to myfitnesspal" 31 | app.Author = "Matt Ho" 32 | app.Commands = []cli.Command{ 33 | diaryCommand, 34 | } 35 | app.Run(os.Args) 36 | } 37 | 38 | func check(err error) { 39 | if err != nil { 40 | log.Fatalln(err) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /myfitnesspal/diary.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/codegangsta/cli" 9 | "github.com/savaki/myfitnesspal" 10 | ) 11 | 12 | var diaryCommand = cli.Command{ 13 | Name: "food-diary", 14 | Usage: "retrieve food diary information", 15 | Flags: append(flags, []cli.Flag{ 16 | cli.StringFlag{"date", time.Now().Format(myfitnesspal.DateFormat), "the date to retrieve YYYY-MM-DD e.g. 2015-02-01", ""}, 17 | }...), 18 | Action: diaryAction, 19 | } 20 | 21 | func diaryAction(c *cli.Context) { 22 | opts := Opts(c) 23 | 24 | client, err := myfitnesspal.New(opts.Username, opts.Password) 25 | check(err) 26 | 27 | timeString := c.String("date") 28 | date, err := time.Parse(myfitnesspal.DateFormat, timeString) 29 | check(err) 30 | 31 | entry, err := client.FoodDiary(date) 32 | check(err) 33 | 34 | data, err := json.MarshalIndent(entry, "", " ") 35 | check(err) 36 | 37 | fmt.Println(string(data)) 38 | } 39 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package myfitnesspal 2 | 3 | import "fmt" 4 | 5 | const ( 6 | DateFormat = "2006-01-02" 7 | ) 8 | 9 | var ( 10 | ErrNotLoggedIn = fmt.Errorf("not logged in") 11 | ) 12 | 13 | type Macros struct { 14 | Section string `json:"-"` 15 | Label string `json:"label"` 16 | Calories int `json:"calories"` 17 | Carbs int `json:"carbs"` 18 | Fat int `json:"fat"` 19 | Protein int `json:"protein"` 20 | Sodium int `json:"sodium"` 21 | Sugar int `json:"sugar"` 22 | } 23 | 24 | type DiaryEntry struct { 25 | Breakfast MacrosArray `json:"breakfast,omitempty"` 26 | Lunch MacrosArray `json:"lunch,omitempty"` 27 | Dinner MacrosArray `json:"dinner,omitempty"` 28 | Snacks MacrosArray `json:"snacks,omitempty"` 29 | 30 | Totals *Macros `json:"totals"` 31 | Goal *Macros `json:"goal"` 32 | Remaining *Macros `json:"remaining"` 33 | } 34 | 35 | type MacrosArray []*Macros 36 | 37 | func (m MacrosArray) Totals() *Macros { 38 | macros := &Macros{} 39 | 40 | if m == nil || len(m) == 0 { 41 | return macros 42 | } 43 | 44 | macros.Section = m[0].Section 45 | macros.Label = m[0].Section + " Totals" 46 | for _, item := range m { 47 | macros.Calories = macros.Calories + item.Calories 48 | macros.Carbs = macros.Carbs + item.Carbs 49 | macros.Fat = macros.Fat + item.Fat 50 | macros.Protein = macros.Protein + item.Protein 51 | macros.Sodium = macros.Sodium + item.Sodium 52 | macros.Sugar = macros.Sugar + item.Sugar 53 | } 54 | 55 | return macros 56 | } 57 | 58 | func (m MacrosArray) Find(section, label string) *Macros { 59 | items := m.FindAll(section) 60 | 61 | for _, item := range items { 62 | if item.Label == label { 63 | return item 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (m MacrosArray) FindAll(section string) MacrosArray { 71 | results := MacrosArray{} 72 | 73 | for _, item := range m { 74 | if item.Section == section { 75 | results = append(results, item) 76 | } 77 | } 78 | 79 | return results 80 | } 81 | -------------------------------------------------------------------------------- /login.go: -------------------------------------------------------------------------------- 1 | package myfitnesspal 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/cookiejar" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/PuerkitoBio/goquery" 11 | "golang.org/x/net/publicsuffix" 12 | ) 13 | 14 | func login(username, password string) (*http.Client, error) { 15 | client, err := newHttpClient() 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | resp, err := client.Get(LoginUrl) 21 | if err != nil { 22 | return nil, err 23 | } 24 | defer resp.Body.Close() 25 | 26 | doc, err := goquery.NewDocumentFromReader(resp.Body) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | var authenticityToken string 32 | var utf8 string 33 | 34 | doc.Find("input[name=authenticity_token]").First().Each(func(i int, s *goquery.Selection) { 35 | authenticityToken, _ = s.Attr("value") 36 | }) 37 | 38 | doc.Find("input[name=utf8]").First().Each(func(i int, s *goquery.Selection) { 39 | utf8, _ = s.Attr("value") 40 | }) 41 | 42 | params := url.Values{} 43 | params.Set("utf8", utf8) 44 | params.Set("authenticity_token", authenticityToken) 45 | params.Set("username", username) 46 | params.Set("password", password) 47 | params.Set("remember_me", "1") 48 | resp, err = client.Post(LoginUrl, "application/x-www-form-urlencoded", strings.NewReader(params.Encode())) 49 | if err != nil { 50 | return nil, err 51 | } 52 | defer resp.Body.Close() 53 | 54 | if !isLoggedIn(resp.Body) { 55 | return nil, ErrNotLoggedIn 56 | } 57 | 58 | return client, nil 59 | } 60 | 61 | func isLoggedIn(r io.Reader) bool { 62 | doc, err := goquery.NewDocumentFromReader(r) 63 | if err != nil { 64 | return false 65 | } 66 | 67 | var loggedIn bool 68 | doc.Find(".user-2").Each(func(i int, s *goquery.Selection) { 69 | title, _ := s.Attr("title") 70 | loggedIn = len(title) > 0 71 | }) 72 | 73 | return loggedIn 74 | } 75 | 76 | func newHttpClient() (*http.Client, error) { 77 | options := cookiejar.Options{ 78 | PublicSuffixList: publicsuffix.List, 79 | } 80 | jar, err := cookiejar.New(&options) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return &http.Client{Jar: jar}, nil 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # myfitnesspal 2 | 3 | [![GoDoc](https://godoc.org/github.com/savaki/myfitnesspal?status.svg)](https://godoc.org/github.com/savaki/myfitnesspal) 4 | 5 | [![Build Status](https://snap-ci.com/savaki/myfitnesspal/branch/master/build_image)](https://snap-ci.com/savaki/myfitnesspal/branch/master) 6 | 7 | A client library and command line tool for myfitnesspal. 8 | 9 | ## Installation 10 | 11 | ``` 12 | go get github.com/savaki/myfitnesspal/myfitnesspal 13 | ``` 14 | 15 | ## Command Line Usage 16 | 17 | In addition to being a go library, myfitnesspal can be used from the command line to retrieve data from myfitnesspal.com in json format. 18 | 19 | ### MyFitnessPal Authentication 20 | 21 | All myfitnesspal command line requests need to be authenticated. You can either authenticate by tacking on ```--username``` and ``--password`` to each request as follows: 22 | 23 | ``` 24 | myfitnesspal food-diary --username YOUR-USERNAME --password YOUR_PASSWORD 25 | -- 26 | ``` 27 | 28 | Or by setting environment variables: 29 | 30 | ``` 31 | export MYFITNESSPAL_USERNAME=YOUR-USERNAME 32 | export MYFITNESSPAL_PASSWORD=YOUR-PASSWORD 33 | ``` 34 | 35 | ## Food Diary 36 | 37 | ### Retrieve my macro intake for today 38 | 39 | Assuming you've set your username and password in the environment (see MyFitnessPal Authentication above). 40 | 41 | ``` 42 | myfitnesspal food-diary 43 | ``` 44 | 45 | Will return something like: 46 | 47 | ``` 48 | { 49 | "breakfast": [ 50 | { 51 | "label": "Breakfast Burrito", 52 | "calories": 450, 53 | "carbs": 250, 54 | "fat": 150, 55 | "protein": 50, 56 | "sodium": 600, 57 | "sugar": 0 58 | } 59 | ], 60 | "totals": { 61 | "label": "Totals", 62 | "calories": 450, 63 | "carbs": 250, 64 | "fat": 150, 65 | "protein": 50, 66 | "sodium": 600, 67 | "sugar": 0 68 | }, 69 | "goal": { 70 | "label": "Your Daily Goal", 71 | "calories": 450, 72 | "carbs": 250, 73 | "fat": 150, 74 | "protein": 50, 75 | "sodium": 600, 76 | "sugar": 0 77 | }, 78 | "remaining": { 79 | "label": "Remaining", 80 | "calories": 0, 81 | "carbs": 0, 82 | "fat": 0, 83 | "protein": 0, 84 | "sodium": 0, 85 | "sugar": 0 86 | } 87 | } 88 | ``` 89 | 90 | ### Retrieve my macro intake for a specific date 91 | 92 | To retrieve my intake on for a specific date, like ```Feb 1st, 2015```, I can type: 93 | 94 | ``` 95 | myfitnesspal food-diary --date 2015-02-01 96 | ``` 97 | 98 | ## Future Plans 99 | 100 | * Looking for a feature, but don't see it? Ping me and I'll see about adding it. 101 | 102 | -------------------------------------------------------------------------------- /food_diary_test.go: -------------------------------------------------------------------------------- 1 | package myfitnesspal 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestFoodDiary(t *testing.T) { 11 | Convey("Given the food diary page", t, func() { 12 | html := ` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
Totals100200300400500600
Your Daily Goal 27 | 1,234 29 | 2,345 31 | 3,456 33 | 4,567 35 | 5,678 37 | 6,789
Remaining112233445566
` 51 | 52 | Convey("When I call #parseFoodDiary", func() { 53 | entry, err := parseFoodDiary(strings.NewReader(html)) 54 | So(err, ShouldBeNil) 55 | So(entry, ShouldNotBeNil) 56 | 57 | Convey("Then I expect #Totals to be set correctly", func() { 58 | So(entry.Totals, ShouldNotBeNil) 59 | So(entry.Totals.Calories, ShouldEqual, 100) 60 | So(entry.Totals.Carbs, ShouldEqual, 200) 61 | So(entry.Totals.Fat, ShouldEqual, 300) 62 | So(entry.Totals.Protein, ShouldEqual, 400) 63 | So(entry.Totals.Sodium, ShouldEqual, 500) 64 | So(entry.Totals.Sugar, ShouldEqual, 600) 65 | }) 66 | 67 | Convey("Then I expect #Goal to be set correctly", func() { 68 | So(entry.Goal, ShouldNotBeNil) 69 | So(entry.Goal.Calories, ShouldEqual, 1234) 70 | So(entry.Goal.Carbs, ShouldEqual, 2345) 71 | So(entry.Goal.Fat, ShouldEqual, 3456) 72 | So(entry.Goal.Protein, ShouldEqual, 4567) 73 | So(entry.Goal.Sodium, ShouldEqual, 5678) 74 | So(entry.Goal.Sugar, ShouldEqual, 6789) 75 | }) 76 | 77 | Convey("Then I expect #Remaining to be set correctly", func() { 78 | So(entry.Remaining, ShouldNotBeNil) 79 | So(entry.Remaining.Calories, ShouldEqual, 11) 80 | So(entry.Remaining.Carbs, ShouldEqual, 22) 81 | So(entry.Remaining.Fat, ShouldEqual, 33) 82 | So(entry.Remaining.Protein, ShouldEqual, 44) 83 | So(entry.Remaining.Sodium, ShouldEqual, 55) 84 | So(entry.Remaining.Sugar, ShouldEqual, 66) 85 | }) 86 | }) 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /food_diary.go: -------------------------------------------------------------------------------- 1 | package myfitnesspal 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/PuerkitoBio/goquery" 12 | ) 13 | 14 | func (c *Client) FoodDiary(date time.Time) (*DiaryEntry, error) { 15 | // set the desired date 16 | dateString := date.Format(DateFormat) 17 | params := url.Values{} 18 | params.Set("date", dateString) 19 | 20 | // construct the request url 21 | uri, err := url.Parse(FoodDiaryUrl + c.username) 22 | if err != nil { 23 | return nil, err 24 | } 25 | uri.RawQuery = params.Encode() 26 | 27 | // execute the request 28 | resp, err := c.client.Get(uri.String()) 29 | if err != nil { 30 | return nil, err 31 | } 32 | defer resp.Body.Close() 33 | 34 | return parseFoodDiary(resp.Body) 35 | } 36 | 37 | func parseFoodDiary(r io.Reader) (*DiaryEntry, error) { 38 | doc, err := goquery.NewDocumentFromReader(r) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | entries := MacrosArray{} 44 | var section string = "" 45 | doc.Find("tr").Each(func(i int, s *goquery.Selection) { 46 | class, _ := s.Attr("class") 47 | if class == "meal_header" { 48 | section = findCellTexts(s)[0] 49 | return 50 | } else if class == "total" { 51 | section = "Totals" 52 | } 53 | 54 | if entry, err := parseMacros(s); err == nil { 55 | // bottom is the summary row, we don't want this 56 | // label is blank for filler columns that don't contain data 57 | if class != "bottom" && entry.Label != "" { 58 | entry.Section = section 59 | entries = append(entries, entry) 60 | } 61 | } 62 | }) 63 | 64 | return &DiaryEntry{ 65 | Breakfast: entries.FindAll("Breakfast"), 66 | Lunch: entries.FindAll("Lunch"), 67 | Dinner: entries.FindAll("Dinner"), 68 | Snacks: entries.FindAll("Snacks"), 69 | Totals: entries.Find("Totals", "Totals"), 70 | Goal: entries.Find("Totals", "Your Daily Goal"), 71 | Remaining: entries.Find("Totals", "Remaining"), 72 | }, nil 73 | } 74 | 75 | func findCellTexts(s *goquery.Selection) []string { 76 | return s.Find("td").Map(func(i int, s *goquery.Selection) string { 77 | return strings.TrimSpace(s.Text()) 78 | }) 79 | } 80 | 81 | func parseMacros(s *goquery.Selection) (macros *Macros, err error) { 82 | values := findCellTexts(s) 83 | 84 | atoi := func(str string) int { 85 | strWithoutCommas := strings.Replace(str, ",", "", -1) 86 | i, e := strconv.Atoi(strWithoutCommas) 87 | if err != nil { 88 | err = e 89 | } 90 | return i 91 | } 92 | 93 | if len(values) < 7 { 94 | err = fmt.Errorf("no macros found") 95 | return 96 | } 97 | 98 | macros = &Macros{ 99 | Label: values[0], 100 | Calories: atoi(values[1]), 101 | Carbs: atoi(values[2]), 102 | Fat: atoi(values[3]), 103 | Protein: atoi(values[4]), 104 | Sodium: atoi(values[5]), 105 | Sugar: atoi(values[6]), 106 | } 107 | 108 | return 109 | } 110 | --------------------------------------------------------------------------------