Eugene 5 years ago
parent
commit
8fd7c3894e
100 changed files with 19566 additions and 0 deletions
  1. 56 0
      Dockerfile
  2. 134 0
      Gopkg.lock
  3. 46 0
      Gopkg.toml
  4. 69 0
      app/main.go
  5. 38 0
      app/publisher/publisher.go
  6. 86 0
      app/rss/notify.go
  7. 48 0
      app/rss/notify_test.go
  8. 1431 0
      app/rss/testdata/f1.xml
  9. 1501 0
      app/rss/testdata/f2.xml
  10. 1 0
      vendor/github.com/PuerkitoBio/goquery/.gitattributes
  11. 16 0
      vendor/github.com/PuerkitoBio/goquery/.gitignore
  12. 16 0
      vendor/github.com/PuerkitoBio/goquery/.travis.yml
  13. 12 0
      vendor/github.com/PuerkitoBio/goquery/LICENSE
  14. 179 0
      vendor/github.com/PuerkitoBio/goquery/README.md
  15. 124 0
      vendor/github.com/PuerkitoBio/goquery/array.go
  16. 123 0
      vendor/github.com/PuerkitoBio/goquery/doc.go
  17. 70 0
      vendor/github.com/PuerkitoBio/goquery/expand.go
  18. 163 0
      vendor/github.com/PuerkitoBio/goquery/filter.go
  19. 6 0
      vendor/github.com/PuerkitoBio/goquery/go.mod
  20. 5 0
      vendor/github.com/PuerkitoBio/goquery/go.sum
  21. 39 0
      vendor/github.com/PuerkitoBio/goquery/iteration.go
  22. 574 0
      vendor/github.com/PuerkitoBio/goquery/manipulation.go
  23. 275 0
      vendor/github.com/PuerkitoBio/goquery/property.go
  24. 49 0
      vendor/github.com/PuerkitoBio/goquery/query.go
  25. 698 0
      vendor/github.com/PuerkitoBio/goquery/traversal.go
  26. 141 0
      vendor/github.com/PuerkitoBio/goquery/type.go
  27. 161 0
      vendor/github.com/PuerkitoBio/goquery/utilities.go
  28. 14 0
      vendor/github.com/andybalholm/cascadia/.travis.yml
  29. 24 0
      vendor/github.com/andybalholm/cascadia/LICENSE
  30. 7 0
      vendor/github.com/andybalholm/cascadia/README.md
  31. 3 0
      vendor/github.com/andybalholm/cascadia/go.mod
  32. 835 0
      vendor/github.com/andybalholm/cascadia/parser.go
  33. 622 0
      vendor/github.com/andybalholm/cascadia/selector.go
  34. 15 0
      vendor/github.com/davecgh/go-spew/LICENSE
  35. 145 0
      vendor/github.com/davecgh/go-spew/spew/bypass.go
  36. 38 0
      vendor/github.com/davecgh/go-spew/spew/bypasssafe.go
  37. 341 0
      vendor/github.com/davecgh/go-spew/spew/common.go
  38. 306 0
      vendor/github.com/davecgh/go-spew/spew/config.go
  39. 211 0
      vendor/github.com/davecgh/go-spew/spew/doc.go
  40. 509 0
      vendor/github.com/davecgh/go-spew/spew/dump.go
  41. 419 0
      vendor/github.com/davecgh/go-spew/spew/format.go
  42. 148 0
      vendor/github.com/davecgh/go-spew/spew/spew.go
  43. 22 0
      vendor/github.com/hashicorp/logutils/.gitignore
  44. 354 0
      vendor/github.com/hashicorp/logutils/LICENSE
  45. 36 0
      vendor/github.com/hashicorp/logutils/README.md
  46. 1 0
      vendor/github.com/hashicorp/logutils/go.mod
  47. 81 0
      vendor/github.com/hashicorp/logutils/level.go
  48. 44 0
      vendor/github.com/jessevdk/go-flags/.travis.yml
  49. 26 0
      vendor/github.com/jessevdk/go-flags/LICENSE
  50. 134 0
      vendor/github.com/jessevdk/go-flags/README.md
  51. 27 0
      vendor/github.com/jessevdk/go-flags/arg.go
  52. 16 0
      vendor/github.com/jessevdk/go-flags/check_crosscompile.sh
  53. 59 0
      vendor/github.com/jessevdk/go-flags/closest.go
  54. 465 0
      vendor/github.com/jessevdk/go-flags/command.go
  55. 309 0
      vendor/github.com/jessevdk/go-flags/completion.go
  56. 348 0
      vendor/github.com/jessevdk/go-flags/convert.go
  57. 134 0
      vendor/github.com/jessevdk/go-flags/error.go
  58. 258 0
      vendor/github.com/jessevdk/go-flags/flags.go
  59. 406 0
      vendor/github.com/jessevdk/go-flags/group.go
  60. 491 0
      vendor/github.com/jessevdk/go-flags/help.go
  61. 597 0
      vendor/github.com/jessevdk/go-flags/ini.go
  62. 205 0
      vendor/github.com/jessevdk/go-flags/man.go
  63. 140 0
      vendor/github.com/jessevdk/go-flags/multitag.go
  64. 459 0
      vendor/github.com/jessevdk/go-flags/option.go
  65. 67 0
      vendor/github.com/jessevdk/go-flags/optstyle_other.go
  66. 108 0
      vendor/github.com/jessevdk/go-flags/optstyle_windows.go
  67. 700 0
      vendor/github.com/jessevdk/go-flags/parser.go
  68. 28 0
      vendor/github.com/jessevdk/go-flags/termsize.go
  69. 7 0
      vendor/github.com/jessevdk/go-flags/termsize_nosysioctl.go
  70. 7 0
      vendor/github.com/jessevdk/go-flags/tiocgwinsz_bsdish.go
  71. 7 0
      vendor/github.com/jessevdk/go-flags/tiocgwinsz_linux.go
  72. 7 0
      vendor/github.com/jessevdk/go-flags/tiocgwinsz_other.go
  73. 26 0
      vendor/github.com/mmcdole/gofeed/.gitignore
  74. 16 0
      vendor/github.com/mmcdole/gofeed/.travis.yml
  75. 21 0
      vendor/github.com/mmcdole/gofeed/LICENSE
  76. 254 0
      vendor/github.com/mmcdole/gofeed/README.md
  77. 114 0
      vendor/github.com/mmcdole/gofeed/atom/feed.go
  78. 722 0
      vendor/github.com/mmcdole/gofeed/atom/parser.go
  79. 47 0
      vendor/github.com/mmcdole/gofeed/detector.go
  80. 45 0
      vendor/github.com/mmcdole/gofeed/extensions/dublincore.go
  81. 46 0
      vendor/github.com/mmcdole/gofeed/extensions/extensions.go
  82. 141 0
      vendor/github.com/mmcdole/gofeed/extensions/itunes.go
  83. 84 0
      vendor/github.com/mmcdole/gofeed/feed.go
  84. 19 0
      vendor/github.com/mmcdole/gofeed/internal/shared/charsetconv.go
  85. 196 0
      vendor/github.com/mmcdole/gofeed/internal/shared/dateparser.go
  86. 176 0
      vendor/github.com/mmcdole/gofeed/internal/shared/extparser.go
  87. 196 0
      vendor/github.com/mmcdole/gofeed/internal/shared/parseutils.go
  88. 23 0
      vendor/github.com/mmcdole/gofeed/internal/shared/xmlsanitizer.go
  89. 145 0
      vendor/github.com/mmcdole/gofeed/parser.go
  90. 120 0
      vendor/github.com/mmcdole/gofeed/rss/feed.go
  91. 767 0
      vendor/github.com/mmcdole/gofeed/rss/parser.go
  92. 21 0
      vendor/github.com/mmcdole/gofeed/testdata/parser/atom/atom10_feed_entry_source_authors_multiple.json
  93. 20 0
      vendor/github.com/mmcdole/gofeed/testdata/parser/atom/atom10_feed_entry_source_authors_multiple.xml
  94. 686 0
      vendor/github.com/mmcdole/gofeed/translator.go
  95. 24 0
      vendor/github.com/mmcdole/goxpp/.gitignore
  96. 17 0
      vendor/github.com/mmcdole/goxpp/.travis.yml
  97. 21 0
      vendor/github.com/mmcdole/goxpp/LICENSE
  98. 9 0
      vendor/github.com/mmcdole/goxpp/README.md
  99. 342 0
      vendor/github.com/mmcdole/goxpp/xpp.go
  100. 27 0
      vendor/github.com/pmezard/go-difflib/LICENSE

+ 56 - 0
Dockerfile

@@ -0,0 +1,56 @@
+FROM umputun/baseimage:buildgo-latest as build
+
+ARG COVERALLS_TOKEN
+ARG CI
+ARG TRAVIS
+ARG TRAVIS_BRANCH
+ARG TRAVIS_COMMIT
+ARG TRAVIS_JOB_ID
+ARG TRAVIS_JOB_NUMBER
+ARG TRAVIS_OS_NAME
+ARG TRAVIS_PULL_REQUEST
+ARG TRAVIS_PULL_REQUEST_SHA
+ARG TRAVIS_REPO_SLUG
+ARG TRAVIS_TAG
+ARG DRONE
+ARG DRONE_TAG
+ARG DRONE_COMMIT
+ARG DRONE_BRANCH
+ARG DRONE_PULL_REQUEST
+
+WORKDIR /go/src/github.com/umputun/rss2twitter
+ADD . /go/src/github.com/umputun/rss2twitter
+
+# run tests
+RUN cd app && go test ./...
+
+# linters
+RUN gometalinter --disable-all --deadline=300s --vendor --enable=vet --enable=vetshadow --enable=golint \
+    --enable=staticcheck --enable=ineffassign --enable=errcheck --enable=unconvert \
+    --enable=deadcode  --enable=gosimple --exclude=test --exclude=mock --exclude=vendor ./...
+
+# coverage report
+RUN mkdir -p target && /script/coverage.sh
+
+# submit coverage to coverals if COVERALLS_TOKEN in env
+RUN if [ -z "$COVERALLS_TOKEN" ] ; then \
+    echo "coverall not enabled" ; \
+    else goveralls -coverprofile=.cover/cover.out -service=travis-ci -repotoken $COVERALLS_TOKEN || echo "coverall failed!"; fi
+
+RUN \
+    version=$(git rev-parse --abbrev-ref HEAD)-$(git describe --abbrev=7 --always --tags)-$(date +%Y%m%d-%H:%M:%S) && \
+    echo "version=$version" && \
+    go build -o rss2twitter -ldflags "-X main.revision=${version} -s -w" ./app
+
+
+FROM umputun/baseimage:app-latest
+
+COPY --from=build /go/src/github.com/umputun/rss2twitter/rss2twitter /srv/rss2twitter
+RUN chown -R app:app /srv
+RUN chmod +x /srv/rss2twitter
+
+EXPOSE 7070
+WORKDIR /srv
+
+CMD ["/srv/rss2twitter"]
+ENTRYPOINT ["/init.sh"]

+ 134 - 0
Gopkg.lock

@@ -0,0 +1,134 @@
+# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
+
+
+[[projects]]
+  digest = "1:573fa46f8d413d4bc3f7cc5e86b2c43cb21559f4fb0a19d9874d228e28fdc07c"
+  name = "github.com/PuerkitoBio/goquery"
+  packages = ["."]
+  pruneopts = "UT"
+  revision = "2d2796f41742ece03e8086188fa4db16a3a0b458"
+  version = "v1.5.0"
+
+[[projects]]
+  digest = "1:66b3310cf22cdc96c35ef84ede4f7b9b370971c4025f394c89a2638729653b11"
+  name = "github.com/andybalholm/cascadia"
+  packages = ["."]
+  pruneopts = "UT"
+  revision = "901648c87902174f774fac311d7f176f8647bdaa"
+  version = "v1.0.0"
+
+[[projects]]
+  digest = "1:ffe9824d294da03b391f44e1ae8281281b4afc1bdaa9588c9097785e3af10cec"
+  name = "github.com/davecgh/go-spew"
+  packages = ["spew"]
+  pruneopts = "UT"
+  revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73"
+  version = "v1.1.1"
+
+[[projects]]
+  digest = "1:16ae35b3a854c667baaf55ff5d455c486f7c2baf040a2727f2ef0e4b096b2a95"
+  name = "github.com/hashicorp/logutils"
+  packages = ["."]
+  pruneopts = "UT"
+  revision = "a335183dfd075f638afcc820c90591ca3c97eba6"
+  version = "v1.0.0"
+
+[[projects]]
+  digest = "1:a2cff208d4759f6ba1b1cd228587b0a1869f95f22542ec9cd17fff64430113c7"
+  name = "github.com/jessevdk/go-flags"
+  packages = ["."]
+  pruneopts = "UT"
+  revision = "c6ca198ec95c841fdb89fc0de7496fed11ab854e"
+  version = "v1.4.0"
+
+[[projects]]
+  digest = "1:b0142f883994390cee3fc0efbee8644c91796eb17d6af1bf8268a0961e6c0f3b"
+  name = "github.com/mmcdole/gofeed"
+  packages = [
+    ".",
+    "atom",
+    "extensions",
+    "internal/shared",
+    "rss",
+  ]
+  pruneopts = "UT"
+  revision = "42010a154d249f5f753dfe03a6390b59671e403e"
+  version = "v1.0.0-beta2"
+
+[[projects]]
+  branch = "master"
+  digest = "1:fb880b85ddcd119242ca65175135771539226ab4f004ddf76b6b1220da231ea2"
+  name = "github.com/mmcdole/goxpp"
+  packages = ["."]
+  pruneopts = "UT"
+  revision = "0068e33feabfc0086c7aeb58a9603f91c061c89f"
+
+[[projects]]
+  digest = "1:0028cb19b2e4c3112225cd871870f2d9cf49b9b4276531f03438a88e94be86fe"
+  name = "github.com/pmezard/go-difflib"
+  packages = ["difflib"]
+  pruneopts = "UT"
+  revision = "792786c7400a136282c1664665ae0a8db921c6c2"
+  version = "v1.0.0"
+
+[[projects]]
+  digest = "1:c40d65817cdd41fac9aa7af8bed56927bb2d6d47e4fea566a74880f5c2b1c41e"
+  name = "github.com/stretchr/testify"
+  packages = [
+    "assert",
+    "require",
+  ]
+  pruneopts = "UT"
+  revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686"
+  version = "v1.2.2"
+
+[[projects]]
+  branch = "master"
+  digest = "1:5193d913046443e59093d66a97a40c51f4a5ea4ceba60f3b3ecf89694de5d16f"
+  name = "golang.org/x/net"
+  packages = [
+    "html",
+    "html/atom",
+    "html/charset",
+  ]
+  pruneopts = "UT"
+  revision = "351d144fa1fc0bd934e2408202be0c29f25e35a0"
+
+[[projects]]
+  digest = "1:aa4d6967a3237f8367b6bf91503964a77183ecf696f1273e8ad3551bb4412b5f"
+  name = "golang.org/x/text"
+  packages = [
+    "encoding",
+    "encoding/charmap",
+    "encoding/htmlindex",
+    "encoding/internal",
+    "encoding/internal/identifier",
+    "encoding/japanese",
+    "encoding/korean",
+    "encoding/simplifiedchinese",
+    "encoding/traditionalchinese",
+    "encoding/unicode",
+    "internal/gen",
+    "internal/tag",
+    "internal/utf8internal",
+    "language",
+    "runes",
+    "transform",
+    "unicode/cldr",
+  ]
+  pruneopts = "UT"
+  revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0"
+  version = "v0.3.0"
+
+[solve-meta]
+  analyzer-name = "dep"
+  analyzer-version = 1
+  input-imports = [
+    "github.com/hashicorp/logutils",
+    "github.com/jessevdk/go-flags",
+    "github.com/mmcdole/gofeed",
+    "github.com/stretchr/testify/assert",
+    "github.com/stretchr/testify/require",
+  ]
+  solver-name = "gps-cdcl"
+  solver-version = 1

+ 46 - 0
Gopkg.toml

@@ -0,0 +1,46 @@
+# Gopkg.toml example
+#
+# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html
+# for detailed Gopkg.toml documentation.
+#
+# required = ["github.com/user/thing/cmd/thing"]
+# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
+#
+# [[constraint]]
+#   name = "github.com/user/project"
+#   version = "1.0.0"
+#
+# [[constraint]]
+#   name = "github.com/user/project2"
+#   branch = "dev"
+#   source = "github.com/myfork/project2"
+#
+# [[override]]
+#   name = "github.com/x/y"
+#   version = "2.4.0"
+#
+# [prune]
+#   non-go = false
+#   go-tests = true
+#   unused-packages = true
+
+
+[[constraint]]
+  name = "github.com/hashicorp/logutils"
+  version = "1.0.0"
+
+[[constraint]]
+  name = "github.com/jessevdk/go-flags"
+  version = "1.4.0"
+
+[[constraint]]
+  name = "github.com/mmcdole/gofeed"
+  version = "1.0.0-beta2"
+
+[[constraint]]
+  name = "github.com/stretchr/testify"
+  version = "1.2.2"
+
+[prune]
+  go-tests = true
+  unused-packages = true

+ 69 - 0
app/main.go

@@ -0,0 +1,69 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"os"
+	"time"
+
+	"github.com/umputun/rss2twitter/app/publisher"
+
+	"github.com/hashicorp/logutils"
+	"github.com/jessevdk/go-flags"
+	"github.com/umputun/rss2twitter/app/rss"
+)
+
+var opts struct {
+	Refresh time.Duration `short:"r" long:"refresh" env:"REFRESH" default:"30" description:"refresh interval"`
+	TimeOut time.Duration `short:"t" long:"timeout" env:"TIMEOUT" default:"5" description:"twitter timeout"`
+	Feed    string        `short:"f" long:"feed" env:"FEED" default:"" description:"rss feed url"`
+
+	ConsumerKey    string `long:"consumer-key" env:"CONSUMER_KEY" default:"" description:"twitter consumer key"`
+	ConsumerSecret string `long:"consumer-secret" env:"CONSUMER_SECRET" default:"" description:"twitter consumer secret"`
+	AccessToken    string `long:"access-token" env:"ACCESS_TOKEN" default:"" description:"twitter access token"`
+	AccessSecret   string `long:"access-secret" env:"ACCESS_SECRET" default:"" description:"twitter access secret"`
+
+	Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
+}
+
+var revision = "unknown"
+
+func main() {
+	fmt.Printf("RSS2TWITTER - %s", revision)
+	if _, err := flags.Parse(&opts); err != nil {
+		os.Exit(1)
+	}
+
+	setupLog(opts.Dbg)
+
+	notifier := rss.New(context.Background(), opts.Feed, opts.Refresh)
+	pub := publisher.Twitter{
+		ConsumerKey:    opts.ConsumerKey,
+		ConsumerSecret: opts.ConsumerSecret,
+		AccessToken:    opts.AccessToken,
+		AccessSecret:   opts.AccessSecret,
+	}
+
+	for event := range notifier.Go() {
+		pub.Publish(event, func(r rss.Event) string {
+			return fmt.Sprintf("%s - %s", r.Title, r.Link)
+		})
+	}
+}
+
+func setupLog(dbg bool) {
+	filter := &logutils.LevelFilter{
+		Levels:   []logutils.LogLevel{"DEBUG", "INFO", "WARN", "ERROR"},
+		MinLevel: logutils.LogLevel("INFO"),
+		Writer:   os.Stdout,
+	}
+
+	log.SetFlags(log.Ldate | log.Ltime)
+
+	if dbg {
+		log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile)
+		filter.MinLevel = logutils.LogLevel("DEBUG")
+	}
+	log.SetOutput(filter)
+}

+ 38 - 0
app/publisher/publisher.go

@@ -0,0 +1,38 @@
+package publisher
+
+import (
+	"log"
+	"net/url"
+	"time"
+
+	"github.com/ChimeraCoder/anaconda"
+	"github.com/umputun/rss2twitter/app/rss"
+)
+
+// Interface for publishers
+type Interface interface {
+	Publish(event rss.Event, formatter func(rss.Event) string) error
+}
+
+// Stdout implements publisher.Interface and sends to stdout
+type Stdout struct{}
+
+// Publish to logger
+func (s Stdout) Publish(event rss.Event, formatter func(rss.Event) string) error {
+	log.Printf("[EVENT] %s", formatter(event))
+	return nil
+}
+
+// Twitter implements publisher.Interface and sends to twitter
+type Twitter struct {
+	ConsumerKey, ConsumerSecret string
+	AccessToken, AccessSecret   string
+}
+
+// Publish to twitter
+func (t Twitter) Publish(event rss.Event, formatter func(rss.Event) string) error {
+	api := anaconda.NewTwitterApiWithCredentials(t.AccessToken, t.AccessSecret, t.ConsumerKey, t.ConsumerSecret)
+	api.SetDelay(5 * time.Second)
+	_, err := api.PostTweet(formatter(event), url.Values{})
+	return err
+}

+ 86 - 0
app/rss/notify.go

@@ -0,0 +1,86 @@
+package rss
+
+import (
+	"context"
+	"log"
+	"net/http"
+	"time"
+
+	"github.com/mmcdole/gofeed"
+)
+
+// Notify on RSS change
+type Notify struct {
+	feed     string
+	duration time.Duration
+
+	ctx    context.Context
+	cancel context.CancelFunc
+}
+
+// Event from RSS
+type Event struct {
+	ChanTitle string
+	Title     string
+	Link      string
+	guid      string
+}
+
+// New makes notifier for given rss feed. Checks for new items every duration
+func New(ctx context.Context, feed string, duration time.Duration) *Notify {
+	res := Notify{feed: feed, duration: duration}
+	res.ctx, res.cancel = context.WithCancel(ctx)
+	return &res
+}
+
+// Go starts notifier and returns events channel
+func (n *Notify) Go() <-chan Event {
+	ch := make(chan Event)
+	go func() {
+		defer func() {
+			close(ch)
+			n.cancel()
+		}()
+		fp := gofeed.NewParser()
+		fp.Client = &http.Client{Timeout: time.Second * 5}
+		lastGUID := ""
+		for {
+			feedData, err := fp.ParseURL(n.feed)
+			if err != nil {
+				log.Printf("[WARN] failed to fetch from %s, %s", n.feed, err)
+				time.Sleep(n.duration)
+				continue
+			}
+			event := n.feedEvent(feedData)
+			if lastGUID != event.guid {
+				if lastGUID != "" {
+					ch <- event
+				}
+				lastGUID = event.guid
+			}
+			select {
+			case <-n.ctx.Done():
+				return
+			case <-time.After(n.duration):
+			}
+		}
+	}()
+
+	return ch
+}
+
+// Shutdown notifier
+func (n *Notify) Shutdown() {
+	n.cancel()
+	<-n.ctx.Done()
+}
+
+func (n *Notify) feedEvent(feed *gofeed.Feed) (e Event) {
+	e.ChanTitle = feed.Title
+	if len(feed.Items) > 0 {
+		e.Title = feed.Items[0].Title
+		e.Link = feed.Items[0].Link
+		e.guid = feed.Items[0].GUID
+	}
+	return e
+}

+ 48 - 0
app/rss/notify_test.go

@@ -0,0 +1,48 @@
+package rss
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestNotify(t *testing.T) {
+	var n int32
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fnum := atomic.AddInt32(&n, int32(1))
+		if fnum > 2 {
+			fnum = 2
+		}
+		data, err := ioutil.ReadFile(fmt.Sprintf("testdata/f%d.xml", fnum))
+		require.NoError(t, err)
+		w.WriteHeader(200)
+		w.Write(data)
+	}))
+
+	defer ts.Close()
+	notify := New(context.Background(), ts.URL, time.Millisecond*250)
+	ch := notify.Go()
+	defer notify.Shutdown()
+
+	st := time.Now()
+	e := <-ch
+	t.Logf("%+v", e)
+	assert.Equal(t, Event{ChanTitle: "Радио-Т", Title: "Радио-Т 626",
+		Link: "https://radio-t.com/p/2018/12/01/podcast-626/", guid: "https://radio-t.com/p/2018/12/01//podcast-626/"}, e)
+	assert.True(t, time.Since(st) >= time.Millisecond*250)
+
+	select {
+	case <-ch:
+		t.Fatal("should not get any more")
+	default:
+	}
+}

+ 1431 - 0
app/rss/testdata/f1.xml

@@ -0,0 +1,1431 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
+	<channel>
+		<title>
+			Радио-Т
+		</title>
+		<link>
+			https://radio-t.com
+		</link>
+		<language>
+			ru
+		</language>
+		<copyright>
+			Creative Commons - Attribution, Noncommercial, No Derivative Works 3.0 License.
+		</copyright>
+		<author>
+			Umputun, Bobuk, Gray, Ksenks
+		</author>
+		<subtitle>
+			Еженедельные импровизации на хай–тек темы
+		</subtitle>
+		<description>
+			Разговоры на темы хайтек, высоких компьютерных технологий, гаджетов, облаков, программирования и прочего интересного из мира ИТ.
+		</description>
+		<explicit>
+			no
+		</explicit>
+		<image href="https://radio-t.com/images/cover.jpg" />
+		<keywords>
+			hitech,russian,radiot,tech,news,радио
+		</keywords>
+		<link xmlns:atom10="http://www.w3.org/2005/Atom" rel="self" type="application/rss+xml" href="http://feeds.feedburner.com/radio-t" />
+		<info xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" uri="radio-t" />
+		<link xmlns:atom10="http://www.w3.org/2005/Atom" rel="hub" href="http://pubsubhubbub.appspot.com/" />
+		<copyright>
+			Creative Commons - Attribution, Noncommercial, No Derivative Works 3.0 License.
+		</copyright>
+		<thumbnail url="https://radio-t.com/images/cover.jpg" />
+		<keywords>
+			hitech,russian,radiot,tech,news,радио
+		</keywords>
+		<category scheme="http://www.itunes.com/dtds/podcast-1.0.dtd">
+			Technology/Tech News
+		</category>
+		<category scheme="http://www.itunes.com/dtds/podcast-1.0.dtd">
+			Technology/Gadgets
+		</category>
+		<owner>
+			<email>
+				podcast@radio-t.com
+			</email>
+			<name>
+				Umputun, Bobuk, Gray, Ksenks
+			</name>
+		</owner>
+		<summary>
+			Еженедельные импровизации на хай–тек темы
+		</summary>
+		<category text="Technology">
+			<category text="Tech News" />
+		</category>
+		<category text="Technology">
+			<category text="Gadgets" />
+		</category>
+		<item>
+			<title>
+				Радио-Т 625
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt625.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.cnet.com/news/logitech-reportedly-in-talks-to-acquire-plantronics/">Logitech приобретает Plantronics</a>.</li>
+				<li><a href="https://bossasaservice.life">Boss as a Service</a>.</li>
+				<li><a href="https://gcemetery.co/">Кладбище проектов Google</a>.</li>
+				<li><a href="https://developer.github.com/actions/creating-workflows/creating-a-new-workflow/">Actions в реальной жизни</a>.</li>
+				<li><a href="https://www.forbes.com/sites/janakirammsv/2018/11/21/aws-adds-the-most-anticipated-feature-to-amazon-ec2/">AWS добавила ума EC2</a>.</li>
+				<li><a href="https://habr.com/post/427211/">Electron это Flash для десктопа</a>.</li>
+				<li><a href="http://blairreeves.me/2018/11/09/dont-work-remotely/?resubmit=hn">Как решить вопрос удаленной работы</a>.</li>
+				<li><a href="https://mashable.com/article/amazon-customer-data-leak/">Amazon потеряла данные пользователей</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast625.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-625.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast625.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/11/24/podcast-625/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/11/24//podcast-625/
+			</guid>
+			<pubDate>
+				Sat, 24 Nov 2018 17:59:34 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt625.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.cnet.com/news/logitech-reportedly-in-talks-to-acquire-plantronics/">Logitech приобретает Plantronics</a>.</li>
+				<li><a href="https://bossasaservice.life">Boss as a Service</a>.</li>
+				<li><a href="https://gcemetery.co/">Кладбище проектов Google</a>.</li>
+				<li><a href="https://developer.github.com/actions/creating-workflows/creating-a-new-workflow/">Actions в реальной жизни</a>.</li>
+				<li><a href="https://www.forbes.com/sites/janakirammsv/2018/11/21/aws-adds-the-most-anticipated-feature-to-amazon-ec2/">AWS добавила ума EC2</a>.</li>
+				<li><a href="https://habr.com/post/427211/">Electron это Flash для десктопа</a>.</li>
+				<li><a href="http://blairreeves.me/2018/11/09/dont-work-remotely/?resubmit=hn">Как решить вопрос удаленной работы</a>.</li>
+				<li><a href="https://mashable.com/article/amazon-customer-data-leak/">Amazon потеряла данные пользователей</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast625.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-625.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast625.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt625.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast625.mp3" type="audio/mp3" length="90301564" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast625.mp3" fileSize="90301564" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 624
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt624.jpg" alt=""></p>
+				<ul>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49615">Время поддержки Ubuntu 18.04 увеличено до 10 лет</a> - <em>00:04:02</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49594">HTTP поверх протокола QUIC будет HTTP/3</a> - <em>00:13:11</em>.</li>
+				<li><a href="https://outline.com/UzUdUE">Larry сказал лишнего</a> - <em>00:27:33</em>.</li>
+				<li><a href="https://www.itnews.com.au/news/amazon-and-oracle-in-database-drama-515447">Amazon, Oracle и драма вокруг БД</a> - <em>00:34:38</em>.</li>
+				<li><a href="https://aws.amazon.com/corretto/">Corretto для LTS OpenJDK</a> - <em>00:34:48</em>.</li>
+				<li><a href="https://news.ycombinator.com/item?id=18442941">Ужас кодовой базы</a> - <em>00:43:46</em>.</li>
+				<li><a href="https://habr.com/post/429612/">Фулстеки — это вечные мидлы</a> - <em>00:55:05</em>.</li>
+				<li><a href="https://www.theverge.com/2018/11/14/18095729/mark-zuckerberg-order-facebook-executive-android-phones">Всех посадить на андроид телефоны</a> - <em>01:09:55</em>.</li>
+				<li><a href="https://techcrunch.com/2018/11/15/facebook-borderline-content/">Facebook введет еще больше цензуры</a> - <em>01:15:03</em>.</li>
+				<li><a href="https://www.bleepingcomputer.com/news/security/cloudflare-brings-its-1111-dns-service-to-android-and-ios-mobile-devices/">Cloudflare с 1.1.1.1 DNS на телефонах</a> - <em>01:23:25</em>.</li>
+				<li><a href="https://pastebin.com/bwvqHhbA?fbclid=IwAR3Nk2wKnv4MjRycU0oQQas5eXQ3v5x9Or_KAsefmgmykH0PviLS74fIR1c">Мифический взлом Protonmail</a> - <em>01:32:22</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast624.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-624.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast624.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/11/17/podcast-624/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/11/17//podcast-624/
+			</guid>
+			<pubDate>
+				Sat, 17 Nov 2018 17:31:06 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt624.jpg" alt=""></p>
+				<ul>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49615">Время поддержки Ubuntu 18.04 увеличено до 10 лет</a> - <em>00:04:02</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49594">HTTP поверх протокола QUIC будет HTTP/3</a> - <em>00:13:11</em>.</li>
+				<li><a href="https://outline.com/UzUdUE">Larry сказал лишнего</a> - <em>00:27:33</em>.</li>
+				<li><a href="https://www.itnews.com.au/news/amazon-and-oracle-in-database-drama-515447">Amazon, Oracle и драма вокруг БД</a> - <em>00:34:38</em>.</li>
+				<li><a href="https://aws.amazon.com/corretto/">Corretto для LTS OpenJDK</a> - <em>00:34:48</em>.</li>
+				<li><a href="https://news.ycombinator.com/item?id=18442941">Ужас кодовой базы</a> - <em>00:43:46</em>.</li>
+				<li><a href="https://habr.com/post/429612/">Фулстеки — это вечные мидлы</a> - <em>00:55:05</em>.</li>
+				<li><a href="https://www.theverge.com/2018/11/14/18095729/mark-zuckerberg-order-facebook-executive-android-phones">Всех посадить на андроид телефоны</a> - <em>01:09:55</em>.</li>
+				<li><a href="https://techcrunch.com/2018/11/15/facebook-borderline-content/">Facebook введет еще больше цензуры</a> - <em>01:15:03</em>.</li>
+				<li><a href="https://www.bleepingcomputer.com/news/security/cloudflare-brings-its-1111-dns-service-to-android-and-ios-mobile-devices/">Cloudflare с 1.1.1.1 DNS на телефонах</a> - <em>01:23:25</em>.</li>
+				<li><a href="https://pastebin.com/bwvqHhbA?fbclid=IwAR3Nk2wKnv4MjRycU0oQQas5eXQ3v5x9Or_KAsefmgmykH0PviLS74fIR1c">Мифический взлом Protonmail</a> - <em>01:32:22</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast624.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-624.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast624.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt624.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast624.mp3" type="audio/mp3" length="75515997" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast624.mp3" fileSize="75515997" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 623
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt623.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://marco.org/2018/11/06/mac-mini-2018-review">2018 Mac Mini</a> - <em>00:06:00</em>.</li>
+				<li><a href="https://www.theverge.com/2018/11/5/18062612/apple-ipad-pro-review-2018-screen-usb-c-pencil-price-features">Apple iPad Pro 2018</a> - <em>00:27:32</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49580">GitHub преодолел рубеж в 100 млн репозиториев</a> - <em>00:47:22</em>.</li>
+				<li><a href="https://mashable.com/article/amazon-apple-sell-iphones-no-homepod/">Amazon начинает продавать продукты Apple</a> - <em>01:16:12</em>.</li>
+				<li><a href="https://techcrunch.com/2018/11/07/samsung-shares-a-glimpse-of-its-folding-infinity-flex-display-smartphone/">Samsung показал телефон с гибким дисплеем</a> - <em>01:29:03</em>.</li>
+				<li><a href="https://mashable.com/article/foldable-phones-fad/">Складные экраны надолго или это мода</a> - <em>01:33:21</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast623.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-623.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast623.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/11/10/podcast-623/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/11/10//podcast-623/
+			</guid>
+			<pubDate>
+				Sat, 10 Nov 2018 18:09:36 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt623.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://marco.org/2018/11/06/mac-mini-2018-review">2018 Mac Mini</a> - <em>00:06:00</em>.</li>
+				<li><a href="https://www.theverge.com/2018/11/5/18062612/apple-ipad-pro-review-2018-screen-usb-c-pencil-price-features">Apple iPad Pro 2018</a> - <em>00:27:32</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49580">GitHub преодолел рубеж в 100 млн репозиториев</a> - <em>00:47:22</em>.</li>
+				<li><a href="https://mashable.com/article/amazon-apple-sell-iphones-no-homepod/">Amazon начинает продавать продукты Apple</a> - <em>01:16:12</em>.</li>
+				<li><a href="https://techcrunch.com/2018/11/07/samsung-shares-a-glimpse-of-its-folding-infinity-flex-display-smartphone/">Samsung показал телефон с гибким дисплеем</a> - <em>01:29:03</em>.</li>
+				<li><a href="https://mashable.com/article/foldable-phones-fad/">Складные экраны надолго или это мода</a> - <em>01:33:21</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast623.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-623.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast623.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt623.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast623.mp3" type="audio/mp3" length="87763507" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast623.mp3" fileSize="87763507" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 622
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt622.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.redhat.com/en/blog/red-hat-ibm-creating-leading-hybrid-cloud-provider">Red Hat + IBM</a>.</li>
+				<li><a href="https://stratechery.com/2018/ibms-old-playbook/">IBM борется за облачный рынок</a>.</li>
+				<li><a href="https://jriddell.org/2018/11/02/red-hat-and-kde/">Red Hat убил KDE</a>.</li>
+				<li><a href="https://www.wired.com/story/everything-apple-announced-ipad-pro-macbook-air-mac-mini/?mbid=social_twitter">Apple с новыми MacBook Air и Mac Mini</a>.</li>
+				<li><a href="https://habr.com/post/428409/">Анализ инцидента 21 октября на GitHub</a>.</li>
+				<li><a href="https://github.com/sharkdp/bat">Как cat, но лучше</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast622.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-622.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast622.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/11/03/podcast-622/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/11/03//podcast-622/
+			</guid>
+			<pubDate>
+				Sat, 03 Nov 2018 18:12:55 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt622.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.redhat.com/en/blog/red-hat-ibm-creating-leading-hybrid-cloud-provider">Red Hat + IBM</a>.</li>
+				<li><a href="https://stratechery.com/2018/ibms-old-playbook/">IBM борется за облачный рынок</a>.</li>
+				<li><a href="https://jriddell.org/2018/11/02/red-hat-and-kde/">Red Hat убил KDE</a>.</li>
+				<li><a href="https://www.wired.com/story/everything-apple-announced-ipad-pro-macbook-air-mac-mini/?mbid=social_twitter">Apple с новыми MacBook Air и Mac Mini</a>.</li>
+				<li><a href="https://habr.com/post/428409/">Анализ инцидента 21 октября на GitHub</a>.</li>
+				<li><a href="https://github.com/sharkdp/bat">Как cat, но лучше</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast622.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-622.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast622.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt622.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast622.mp3" type="audio/mp3" length="88746234" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast622.mp3" fileSize="88746234" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 621
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt621.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://lwn.net/Articles/769110/">Linux 4.19 – главный вернулся</a> - <em>00:04:00</em>.</li>
+				<li><a href="https://blog.github.com/2018-10-21-october21-incident-report/">Болезненное падение Github</a> - <em>00:31:24</em>.</li>
+				<li><a href="https://www.macrumors.com/2018/10/26/what-to-expect-at-october-30-apple-event/">Что ожидать от внезапного события</a> - <em>00:44:02</em>.</li>
+				<li><a href="https://blog.github.com/2018-10-26-github-and-microsoft/">Покупка завершена</a> - <em>01:13:50</em>.</li>
+				<li><a href="https://github.com/akavel/up">Ultimate Plumber</a> - <em>01:21:44</em>.</li>
+				<li><a href="https://habr.com/post/427265/">Protobuffers — это неправильно</a> - <em>01:34:55</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast621.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-621.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast621.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/10/27/podcast-621/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/10/27//podcast-621/
+			</guid>
+			<pubDate>
+				Sat, 27 Oct 2018 19:06:08 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt621.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://lwn.net/Articles/769110/">Linux 4.19 – главный вернулся</a> - <em>00:04:00</em>.</li>
+				<li><a href="https://blog.github.com/2018-10-21-october21-incident-report/">Болезненное падение Github</a> - <em>00:31:24</em>.</li>
+				<li><a href="https://www.macrumors.com/2018/10/26/what-to-expect-at-october-30-apple-event/">Что ожидать от внезапного события</a> - <em>00:44:02</em>.</li>
+				<li><a href="https://blog.github.com/2018-10-26-github-and-microsoft/">Покупка завершена</a> - <em>01:13:50</em>.</li>
+				<li><a href="https://github.com/akavel/up">Ultimate Plumber</a> - <em>01:21:44</em>.</li>
+				<li><a href="https://habr.com/post/427265/">Protobuffers — это неправильно</a> - <em>01:34:55</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast621.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-621.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast621.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt621.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast621.mp3" type="audio/mp3" length="100691507" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast621.mp3" fileSize="100691507" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 620
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt620.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/10/16/github-launches-actions-its-workflow-automation-tool/">GitHub добавил Actions</a> - <em>00:04:00</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/16/mongodb-switches-up-its-open-source-license/">MongoDB поменяла лицензию</a> - <em>00:49:29</em>.</li>
+				<li><a href="https://www.winamp.com/">Winamp через 5 лет</a> - <em>00:57:45</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/15/palm-returns-as-an-ultra-mobile-smartphone/">Palm возвращается в странном виде</a> - <em>01:00:59</em>.</li>
+				<li><a href="https://thehelm.com/">Helm для почти локальной почты</a> - <em>01:24:31</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast620.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-620.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast620.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/10/20/podcast-620/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/10/20//podcast-620/
+			</guid>
+			<pubDate>
+				Sat, 20 Oct 2018 18:17:33 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt620.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/10/16/github-launches-actions-its-workflow-automation-tool/">GitHub добавил Actions</a> - <em>00:04:00</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/16/mongodb-switches-up-its-open-source-license/">MongoDB поменяла лицензию</a> - <em>00:49:29</em>.</li>
+				<li><a href="https://www.winamp.com/">Winamp через 5 лет</a> - <em>00:57:45</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/15/palm-returns-as-an-ultra-mobile-smartphone/">Palm возвращается в странном виде</a> - <em>01:00:59</em>.</li>
+				<li><a href="https://thehelm.com/">Helm для почти локальной почты</a> - <em>01:24:31</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast620.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-620.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast620.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt620.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast620.mp3" type="audio/mp3" length="80076454" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast620.mp3" fileSize="80076454" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 619
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt619.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.google/technology/safety-security/project-strobe/">Как гугл внезапно закрыл Google+</a> - <em>00:03:45</em>.</li>
+				<li><a href="https://www.engadget.com/2018/10/12/goodbye-google-plus-you-beautiful-squandered-opportunity/">Чо посшло не так с Google+</a> - <em>00:09:12</em>.</li>
+				<li><a href="https://www.gijsk.com/blog/2018/10/firefox-removes-core-product-support-for-rss-atom-feeds/">Firefox убрал поддержку RSS/Atom</a> - <em>00:25:47</em>.</li>
+				<li><a href="https://code.visualstudio.com/docs/supporting/faq">Отучить VSCode от плохих привычек</a> - <em>00:39:18</em>.</li>
+				<li><a href="https://www.theverge.com/2018/10/10/17958784/ai-recruiting-tool-bias-amazon-report">Amazon разработал сексисткий AI</a> - <em>01:02:55</em>.</li>
+				<li><a href="https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46">12 факторов для CLI</a> - <em>01:14:13</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast619.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-619.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast619.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/10/13/podcast-619/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/10/13//podcast-619/
+			</guid>
+			<pubDate>
+				Sat, 13 Oct 2018 18:20:54 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt619.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.google/technology/safety-security/project-strobe/">Как гугл внезапно закрыл Google+</a> - <em>00:03:45</em>.</li>
+				<li><a href="https://www.engadget.com/2018/10/12/goodbye-google-plus-you-beautiful-squandered-opportunity/">Чо посшло не так с Google+</a> - <em>00:09:12</em>.</li>
+				<li><a href="https://www.gijsk.com/blog/2018/10/firefox-removes-core-product-support-for-rss-atom-feeds/">Firefox убрал поддержку RSS/Atom</a> - <em>00:25:47</em>.</li>
+				<li><a href="https://code.visualstudio.com/docs/supporting/faq">Отучить VSCode от плохих привычек</a> - <em>00:39:18</em>.</li>
+				<li><a href="https://www.theverge.com/2018/10/10/17958784/ai-recruiting-tool-bias-amazon-report">Amazon разработал сексисткий AI</a> - <em>01:02:55</em>.</li>
+				<li><a href="https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46">12 факторов для CLI</a> - <em>01:14:13</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast619.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-619.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast619.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt619.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast619.mp3" type="audio/mp3" length="85528209" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast619.mp3" fileSize="85528209" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 618
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt618.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.digitalocean.com/custom-images/">Custom Image теперь и в DigitalOcean</a> - <em>00:13:36</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/05/scaleway-adds-object-storage/">Scaleway добавила object storage</a> - <em>00:25:05</em>.</li>
+				<li><a href="https://blog.cloudflare.com/introducing-workers-kv/">Workers KV - больше чем KV</a> - <em>00:33:26</em>.</li>
+				<li><a href="https://blog.cloudflare.com/building-with-workers-kv/">Технические детали Workers KV</a> - <em>00:37:58</em>.</li>
+				<li><a href="https://www.androidcentral.com/android-developers-love-kotlin">Kotlin проникает в Google Cloud Platform</a> - <em>00:46:08</em>.</li>
+				<li><a href="https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/">Что не так с конкурентностью в Go</a> - <em>01:06:24</em>.</li>
+				<li><a href="https://eli.thegreenplace.net/2018/go-hits-the-concurrency-nail-right-on-the-head/">Все так с конкурентностью в Go</a> - <em>01:12:38</em>.</li>
+				<li><a href="https://www.lightbluetouchpaper.org/2018/10/05/making-sense-of-the-supermicro-motherboard-attack/">Supermicro motherboard attack</a> - <em>01:32:44</em>.</li>
+				<li><a href="https://developer.okta.com/blog/2017/08/17/why-jwts-suck-as-session-tokens">Чем так плох JWT</a> - <em>01:44:56</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast618.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-618.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast618.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/10/06/podcast-618/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/10/06//podcast-618/
+			</guid>
+			<pubDate>
+				Sat, 06 Oct 2018 17:44:05 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt618.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.digitalocean.com/custom-images/">Custom Image теперь и в DigitalOcean</a> - <em>00:13:36</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/05/scaleway-adds-object-storage/">Scaleway добавила object storage</a> - <em>00:25:05</em>.</li>
+				<li><a href="https://blog.cloudflare.com/introducing-workers-kv/">Workers KV - больше чем KV</a> - <em>00:33:26</em>.</li>
+				<li><a href="https://blog.cloudflare.com/building-with-workers-kv/">Технические детали Workers KV</a> - <em>00:37:58</em>.</li>
+				<li><a href="https://www.androidcentral.com/android-developers-love-kotlin">Kotlin проникает в Google Cloud Platform</a> - <em>00:46:08</em>.</li>
+				<li><a href="https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/">Что не так с конкурентностью в Go</a> - <em>01:06:24</em>.</li>
+				<li><a href="https://eli.thegreenplace.net/2018/go-hits-the-concurrency-nail-right-on-the-head/">Все так с конкурентностью в Go</a> - <em>01:12:38</em>.</li>
+				<li><a href="https://www.lightbluetouchpaper.org/2018/10/05/making-sense-of-the-supermicro-motherboard-attack/">Supermicro motherboard attack</a> - <em>01:32:44</em>.</li>
+				<li><a href="https://developer.okta.com/blog/2017/08/17/why-jwts-suck-as-session-tokens">Чем так плох JWT</a> - <em>01:44:56</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast618.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-618.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast618.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt618.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast618.mp3" type="audio/mp3" length="84847981" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast618.mp3" fileSize="84847981" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 617
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt617.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.apple.com/macos/mojave/">macOS Mojave</a> - <em>00:04:33</em>.</li>
+				<li><a href="https://gizmodo.com/facebook-is-giving-advertisers-access-to-your-shadow-co-1828476051">Facebook сливает через 3х лиц</a> - <em>00:44:58</em>.</li>
+				<li><a href="https://gizmodo.com/50-million-facebook-accounts-affected-in-massive-securi-1829394250">50М пользователей Facebook утекли</a> - <em>00:47:43</em>.</li>
+				<li><a href="https://blog.cloudflare.com/cloudflare-registrar/">Cloudflare Registrar</a> - <em>01:09:10</em>.</li>
+				<li><a href="https://www.geekwire.com/2018/9-biggest-announcements-microsoft-ignite-tech-conference/">Microsoft Ignite</a> - <em>01:18:04</em>.</li>
+				<li><a href="https://www.apple.com/newsroom/2018/09/apple-acquires-shazam-offering-more-ways-to-discover-and-enjoy-music/">Apple приобрел Shazam</a> - <em>01:22:35</em>.</li>
+				<li><a href="https://9to5google.com/2018/09/25/google-chrome-70-fix-changes/">Скандалы вокруг Google и Chrome</a> - <em>01:25:28</em>.</li>
+				<li><a href="https://SkipLang.github.io/index.html">Язык программирования skip</a> - <em>01:34:18</em>.</li>
+				<li><a href="https://venturebeat.com/2018/09/07/tor-gets-its-first-official-mobile-browser/">Tor выпустил мобильный browser</a> - <em>01:35:35</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast617.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-617.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast617.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/29/podcast-617/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/29//podcast-617/
+			</guid>
+			<pubDate>
+				Sat, 29 Sep 2018 18:04:30 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt617.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.apple.com/macos/mojave/">macOS Mojave</a> - <em>00:04:33</em>.</li>
+				<li><a href="https://gizmodo.com/facebook-is-giving-advertisers-access-to-your-shadow-co-1828476051">Facebook сливает через 3х лиц</a> - <em>00:44:58</em>.</li>
+				<li><a href="https://gizmodo.com/50-million-facebook-accounts-affected-in-massive-securi-1829394250">50М пользователей Facebook утекли</a> - <em>00:47:43</em>.</li>
+				<li><a href="https://blog.cloudflare.com/cloudflare-registrar/">Cloudflare Registrar</a> - <em>01:09:10</em>.</li>
+				<li><a href="https://www.geekwire.com/2018/9-biggest-announcements-microsoft-ignite-tech-conference/">Microsoft Ignite</a> - <em>01:18:04</em>.</li>
+				<li><a href="https://www.apple.com/newsroom/2018/09/apple-acquires-shazam-offering-more-ways-to-discover-and-enjoy-music/">Apple приобрел Shazam</a> - <em>01:22:35</em>.</li>
+				<li><a href="https://9to5google.com/2018/09/25/google-chrome-70-fix-changes/">Скандалы вокруг Google и Chrome</a> - <em>01:25:28</em>.</li>
+				<li><a href="https://SkipLang.github.io/index.html">Язык программирования skip</a> - <em>01:34:18</em>.</li>
+				<li><a href="https://venturebeat.com/2018/09/07/tor-gets-its-first-official-mobile-browser/">Tor выпустил мобильный browser</a> - <em>01:35:35</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast617.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-617.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast617.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt617.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast617.mp3" type="audio/mp3" length="76018070" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast617.mp3" fileSize="76018070" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 616
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt616.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.bloomberg.com/tosv2.html?vid=&amp;uuid=15ef6670-bd59-11e8-bca1-0d901f158ed4&amp;url=L25ld3MvYXJ0aWNsZXMvMjAxOC0wOS0xOS9hbHBoYWJldC1iYWNrcy1naXRsYWItcy1xdWVzdC10by1zdXJwYXNzLW1pY3Jvc29mdC1zLWdpdGh1Yg==">Google вложился в GitLab</a> - <em>00:17:53</em>.</li>
+				<li><a href="https://www.sublimetext.com/blog/articles/sublime-merge">Sublime Merge</a> - <em>00:29:37</em>.</li>
+				<li><a href="https://lkml.org/lkml/2018/9/16/167">Линус заявил странное</a> - <em>00:45:15</em>.</li>
+				<li><a href="https://www.theverge.com/2018/9/20/17883242/amazon-alexa-event-2018-news-recap-echo-auto-dot-sub-link-auto-microwave">Amazon и его бесконечный ряд новинок</a> - <em>01:06:18</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast616.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-616.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast616.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/22/podcast-616/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/22//podcast-616/
+			</guid>
+			<pubDate>
+				Sat, 22 Sep 2018 18:53:35 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt616.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.bloomberg.com/tosv2.html?vid=&amp;uuid=15ef6670-bd59-11e8-bca1-0d901f158ed4&amp;url=L25ld3MvYXJ0aWNsZXMvMjAxOC0wOS0xOS9hbHBoYWJldC1iYWNrcy1naXRsYWItcy1xdWVzdC10by1zdXJwYXNzLW1pY3Jvc29mdC1zLWdpdGh1Yg==">Google вложился в GitLab</a> - <em>00:17:53</em>.</li>
+				<li><a href="https://www.sublimetext.com/blog/articles/sublime-merge">Sublime Merge</a> - <em>00:29:37</em>.</li>
+				<li><a href="https://lkml.org/lkml/2018/9/16/167">Линус заявил странное</a> - <em>00:45:15</em>.</li>
+				<li><a href="https://www.theverge.com/2018/9/20/17883242/amazon-alexa-event-2018-news-recap-echo-auto-dot-sub-link-auto-microwave">Amazon и его бесконечный ряд новинок</a> - <em>01:06:18</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast616.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-616.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast616.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt616.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast616.mp3" type="audio/mp3" length="99919066" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast616.mp3" fileSize="99919066" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 615
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt615.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/09/12/everything-apple-announced-at-its-iphone-xs-event/">Событие от Apple</a>.</li>
+				<li><a href="https://www.theverge.com/2018/9/12/17848500/google-inbox-shut-down-sunset-snooze-email-march-2019">Google закрывает Inbox</a>.</li>
+				<li><a href="https://www.buzzfeednews.com/article/ryanhatesthis/everything-you-need-to-know-about-the-law-european">Новый закон поломает интернет</a>.</li>
+				<li><a href="https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github">GitHub Pull Requests для Visual Studio Code</a>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49256">Python избавляется от терминов "master" и "slave"</a>.</li>
+				<li><a href="https://variety.com/2018/digital/news/plex-cloud-shutting-down-1202936840/">Plex уходит из облаков</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast615.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-615.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast615.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/15/podcast-615/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/15//podcast-615/
+			</guid>
+			<pubDate>
+				Sat, 15 Sep 2018 18:05:24 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt615.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/09/12/everything-apple-announced-at-its-iphone-xs-event/">Событие от Apple</a>.</li>
+				<li><a href="https://www.theverge.com/2018/9/12/17848500/google-inbox-shut-down-sunset-snooze-email-march-2019">Google закрывает Inbox</a>.</li>
+				<li><a href="https://www.buzzfeednews.com/article/ryanhatesthis/everything-you-need-to-know-about-the-law-european">Новый закон поломает интернет</a>.</li>
+				<li><a href="https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github">GitHub Pull Requests для Visual Studio Code</a>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49256">Python избавляется от терминов "master" и "slave"</a>.</li>
+				<li><a href="https://variety.com/2018/digital/news/plex-cloud-shutting-down-1202936840/">Plex уходит из облаков</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast615.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-615.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast615.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt615.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast615.mp3" type="audio/mp3" length="82621826" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast615.mp3" fileSize="82621826" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 614
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt614.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://cloud.yandex.ru/docs">Яндекс.Облако</a> - <em>00:02:18</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49227">Релиз Chrome 69 с переработанным интерфейсом</a> - <em>00:39:59</em>.</li>
+				<li><a href="https://bugs.chromium.org/p/chromium/issues/detail?id=881410">"www." пропал из URL</a> - <em>00:55:20</em>.</li>
+				<li><a href="https://threatpost.com/open-git-directories-leave-390k-websites-vulnerable/137299/">Открытые .Git на 390K сайтов</a> - <em>00:55:42</em>.</li>
+				<li><a href="http://www.pewresearch.org/fact-tank/2018/09/05/americans-are-changing-their-relationship-with-facebook/">Facebook уже не тот</a> - <em>01:05:12</em>.</li>
+				<li><a href="https://techcrunch.com/2018/09/04/evernote-lost-its-cto-cfo-cpo-and-hr-head-in-the-last-month-as-it-eyes-another-fundraise/">Evernote потерял всех руководителей</a> - <em>01:25:52</em>.</li>
+				<li><a href="https://habr.com/post/422421/">Microsoft собирается радикально улучшить Skype</a> - <em>01:48:48</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast614.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-614.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast614.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/08/podcast-614/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/08//podcast-614/
+			</guid>
+			<pubDate>
+				Sat, 08 Sep 2018 18:49:30 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt614.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://cloud.yandex.ru/docs">Яндекс.Облако</a> - <em>00:02:18</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49227">Релиз Chrome 69 с переработанным интерфейсом</a> - <em>00:39:59</em>.</li>
+				<li><a href="https://bugs.chromium.org/p/chromium/issues/detail?id=881410">"www." пропал из URL</a> - <em>00:55:20</em>.</li>
+				<li><a href="https://threatpost.com/open-git-directories-leave-390k-websites-vulnerable/137299/">Открытые .Git на 390K сайтов</a> - <em>00:55:42</em>.</li>
+				<li><a href="http://www.pewresearch.org/fact-tank/2018/09/05/americans-are-changing-their-relationship-with-facebook/">Facebook уже не тот</a> - <em>01:05:12</em>.</li>
+				<li><a href="https://techcrunch.com/2018/09/04/evernote-lost-its-cto-cfo-cpo-and-hr-head-in-the-last-month-as-it-eyes-another-fundraise/">Evernote потерял всех руководителей</a> - <em>01:25:52</em>.</li>
+				<li><a href="https://habr.com/post/422421/">Microsoft собирается радикально улучшить Skype</a> - <em>01:48:48</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast614.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-614.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast614.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt614.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast614.mp3" type="audio/mp3" length="99536895" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast614.mp3" fileSize="99536895" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 613
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt613.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://aws.amazon.com/about-aws/whats-new/2018/08/introducing-amazon-ec2-t3-instances/?fc=p_2">Amazon EC2 T3</a>.</li>
+				<li><a href="https://techcrunch.com/2018/08/23/aws-cuts-the-price-of-most-of-its-lightsail-virtual-private-servers-in-half/">AWS уронило цену на Lightsail</a>.</li>
+				<li><a href="https://blog.golang.org/go1.11">Go 1.11</a>.</li>
+				<li><a href="https://open.microsoft.com/2018/08/28/announcing-project-athens-gophersource-go-community/">Проект Athens</a>.</li>
+				<li><a href="https://blog.golang.org/go2draft">Go 2 Draft</a>.</li>
+				<li><a href="https://erikbern.com/2018/08/30/i-dont-want-to-learn-your-garbage-query-language.html">Зачем учить еще один DSL</a>.</li>
+				<li><a href="https://medium.com/gitpod/gitpod-gitpod-online-ide-for-github-6296b907a886">Gitpod — странное IDE для GitHub</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast613.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-613.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast613.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/01/podcast-613/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/01//podcast-613/
+			</guid>
+			<pubDate>
+				Sat, 01 Sep 2018 18:18:27 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt613.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://aws.amazon.com/about-aws/whats-new/2018/08/introducing-amazon-ec2-t3-instances/?fc=p_2">Amazon EC2 T3</a>.</li>
+				<li><a href="https://techcrunch.com/2018/08/23/aws-cuts-the-price-of-most-of-its-lightsail-virtual-private-servers-in-half/">AWS уронило цену на Lightsail</a>.</li>
+				<li><a href="https://blog.golang.org/go1.11">Go 1.11</a>.</li>
+				<li><a href="https://open.microsoft.com/2018/08/28/announcing-project-athens-gophersource-go-community/">Проект Athens</a>.</li>
+				<li><a href="https://blog.golang.org/go2draft">Go 2 Draft</a>.</li>
+				<li><a href="https://erikbern.com/2018/08/30/i-dont-want-to-learn-your-garbage-query-language.html">Зачем учить еще один DSL</a>.</li>
+				<li><a href="https://medium.com/gitpod/gitpod-gitpod-online-ide-for-github-6296b907a886">Gitpod — странное IDE для GitHub</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast613.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-613.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast613.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt613.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast613.mp3" type="audio/mp3" length="84207197" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast613.mp3" fileSize="84207197" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 612
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt612.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.theverge.com/2018/8/20/17725226/skype-private-conversation-end-to-end-encrypted-opt-in">Skype зашифровал беседы</a> - <em>00:02:39</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49171">Intel запретил публиковать результаты тестирования</a> - <em>00:19:58</em>.</li>
+				<li><a href="https://www.reddit.com/r/EnoughMuskSpam/comments/99sbwa/former_tesla_programmers_anecdotes_about_problems/">Бывший работникTesla разговорился</a> - <em>00:28:14</em>.</li>
+				<li><a href="https://venturebeat.com/2018/08/23/you-can-now-download-windows-95-as-an-app-for-mac-windows-and-linux/">Windows 95 для любой OS</a> - <em>00:43:58</em>.</li>
+				<li><a href="https://thenextweb.com/plugged/2018/08/23/logitechs-mx-vertical-is-the-ergonomic-mouse-i-didnt-know-i-wanted/">Logitech’s MX Vertical</a> - <em>00:50:34</em>.</li>
+				<li><a href="https://tech.co/google-pixel-3-xl-leaks-rumors-2018-08">Google Pixel 3 XL</a> - <em>01:00:00</em>.</li>
+				<li><a href="https://www.zdnet.com/pictures/microsoft-surface-go-first-impressions/">Microsoft Surface Go</a> - <em>01:10:37</em>.</li>
+				<li><a href="https://www.independent.co.uk/life-style/gadgets-and-tech/news/google-chrome-incognito-mode-personal-data-private-browser-a8502386.html">Google Chrome и не очень приватный режим</a> - <em>01:18:28</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast612.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-612.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast612.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/08/25/podcast-612/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/08/25//podcast-612/
+			</guid>
+			<pubDate>
+				Sat, 25 Aug 2018 19:30:37 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt612.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.theverge.com/2018/8/20/17725226/skype-private-conversation-end-to-end-encrypted-opt-in">Skype зашифровал беседы</a> - <em>00:02:39</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49171">Intel запретил публиковать результаты тестирования</a> - <em>00:19:58</em>.</li>
+				<li><a href="https://www.reddit.com/r/EnoughMuskSpam/comments/99sbwa/former_tesla_programmers_anecdotes_about_problems/">Бывший работникTesla разговорился</a> - <em>00:28:14</em>.</li>
+				<li><a href="https://venturebeat.com/2018/08/23/you-can-now-download-windows-95-as-an-app-for-mac-windows-and-linux/">Windows 95 для любой OS</a> - <em>00:43:58</em>.</li>
+				<li><a href="https://thenextweb.com/plugged/2018/08/23/logitechs-mx-vertical-is-the-ergonomic-mouse-i-didnt-know-i-wanted/">Logitech’s MX Vertical</a> - <em>00:50:34</em>.</li>
+				<li><a href="https://tech.co/google-pixel-3-xl-leaks-rumors-2018-08">Google Pixel 3 XL</a> - <em>01:00:00</em>.</li>
+				<li><a href="https://www.zdnet.com/pictures/microsoft-surface-go-first-impressions/">Microsoft Surface Go</a> - <em>01:10:37</em>.</li>
+				<li><a href="https://www.independent.co.uk/life-style/gadgets-and-tech/news/google-chrome-incognito-mode-personal-data-private-browser-a8502386.html">Google Chrome и не очень приватный режим</a> - <em>01:18:28</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast612.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-612.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast612.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt612.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast612.mp3" type="audio/mp3" length="73837368" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast612.mp3" fileSize="73837368" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 611
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt611.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.theverge.com/2018/8/17/17715166/google-location-tracking-history-weather-maps">Google признается в страшном</a> - <em>00:01:43</em>.</li>
+				<li><a href="https://www.engadget.com/2018/08/17/how-google-location-tracking-issue-affects-you/">Чем эта слежка нам грозит</a> - <em>00:10:53</em>.</li>
+				<li><a href="https://www.cultofmac.com/570095/google-makes-big-improvements-to-gmail-google-voice-on-ios/">Улучшения в Gmail и Google Voice для iOS</a> - <em>00:28:13</em>.</li>
+				<li><a href="https://techcrunch.com/2018/08/17/the-automatica-automates-pour-over-coffee-in-a-charming-and-totally-unnecessary-way/">Стимппанк от Automatica</a> - <em>00:35:17</em>.</li>
+				<li><a href="https://github.com/Microsoft/FASTER">FASTER - встраиваемый KV от MS</a> - <em>00:50:22</em>.</li>
+				<li><a href="https://zeit.co/blog/serverless-docker">Serverless Docker</a> - <em>00:59:15</em>.</li>
+				<li><a href="https://www.engadget.com/2018/08/16/asus-zenbook-pro-15-review/">ZenBook Pro 15</a> - <em>01:20:13</em>.</li>
+				<li><a href="https://www.theverge.com/circuitbreaker/2018/8/17/17723464/amazon-live-tv-recording-fire-tv-device">Amazon выходит на рынок DVR</a> - <em>01:30:05</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast611.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-611.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast611.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/08/18/podcast-611/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/08/18//podcast-611/
+			</guid>
+			<pubDate>
+				Sat, 18 Aug 2018 18:09:02 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt611.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.theverge.com/2018/8/17/17715166/google-location-tracking-history-weather-maps">Google признается в страшном</a> - <em>00:01:43</em>.</li>
+				<li><a href="https://www.engadget.com/2018/08/17/how-google-location-tracking-issue-affects-you/">Чем эта слежка нам грозит</a> - <em>00:10:53</em>.</li>
+				<li><a href="https://www.cultofmac.com/570095/google-makes-big-improvements-to-gmail-google-voice-on-ios/">Улучшения в Gmail и Google Voice для iOS</a> - <em>00:28:13</em>.</li>
+				<li><a href="https://techcrunch.com/2018/08/17/the-automatica-automates-pour-over-coffee-in-a-charming-and-totally-unnecessary-way/">Стимппанк от Automatica</a> - <em>00:35:17</em>.</li>
+				<li><a href="https://github.com/Microsoft/FASTER">FASTER - встраиваемый KV от MS</a> - <em>00:50:22</em>.</li>
+				<li><a href="https://zeit.co/blog/serverless-docker">Serverless Docker</a> - <em>00:59:15</em>.</li>
+				<li><a href="https://www.engadget.com/2018/08/16/asus-zenbook-pro-15-review/">ZenBook Pro 15</a> - <em>01:20:13</em>.</li>
+				<li><a href="https://www.theverge.com/circuitbreaker/2018/8/17/17723464/amazon-live-tv-recording-fire-tv-device">Amazon выходит на рынок DVR</a> - <em>01:30:05</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast611.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-611.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast611.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt611.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast611.mp3" type="audio/mp3" length="89446054" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast611.mp3" fileSize="89446054" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 610
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt610.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://thenextweb.com/security/2018/08/07/hacker-swipes-snapchats-source-code-publishes-it-on-github/">Утечка Snapchat</a> - <em>00:02:24</em>.</li>
+				<li><a href="https://sdtimes.com/cloud/amazon-aurora-serverless-now-generally-available/">Amazon Aurora Serverless</a> - <em>00:19:28</em>.</li>
+				<li><a href="https://techcrunch.com/2018/08/07/microsoft-decides-to-support-skype-classic-for-some-time-after-users-revolt/">Microsoft отступила в Skype Classic</a> - <em>00:33:18</em>.</li>
+				<li><a href="https://letsencrypt.org/2018/08/06/trusted-by-all-major-root-programs.html">Let's Encrypt теперь доверяют все</a> - <em>00:43:40</em>.</li>
+				<li><a href="https://githubengineering.com/glb-director-open-source-load-balancer/">GLB от GitHub</a> - <em>01:05:37</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49096">Выпуск Dart 2.0</a> - <em>01:12:31</em>.</li>
+				<li><a href="https://mashable.com/2018/08/06/facebook-financial-info-banks/">Facebook охотится на банки</a> - <em>01:23:58</em>.</li>
+				<li><a href="https://torrentfreak.com/the-pirate-bay-turns-15-years-old-180810/">The Pirate Bay Turns 15 Years Old - TorrentFreak</a> - <em>01:32:20</em>.</li>
+				<li><a href="https://julialang.org/blog/2018/08/one-point-zero">Julia 1.0</a> - <em>01:40:59</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast610.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-610.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast610.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/08/11/podcast-610/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/08/11//podcast-610/
+			</guid>
+			<pubDate>
+				Sat, 11 Aug 2018 18:37:37 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt610.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://thenextweb.com/security/2018/08/07/hacker-swipes-snapchats-source-code-publishes-it-on-github/">Утечка Snapchat</a> - <em>00:02:24</em>.</li>
+				<li><a href="https://sdtimes.com/cloud/amazon-aurora-serverless-now-generally-available/">Amazon Aurora Serverless</a> - <em>00:19:28</em>.</li>
+				<li><a href="https://techcrunch.com/2018/08/07/microsoft-decides-to-support-skype-classic-for-some-time-after-users-revolt/">Microsoft отступила в Skype Classic</a> - <em>00:33:18</em>.</li>
+				<li><a href="https://letsencrypt.org/2018/08/06/trusted-by-all-major-root-programs.html">Let's Encrypt теперь доверяют все</a> - <em>00:43:40</em>.</li>
+				<li><a href="https://githubengineering.com/glb-director-open-source-load-balancer/">GLB от GitHub</a> - <em>01:05:37</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49096">Выпуск Dart 2.0</a> - <em>01:12:31</em>.</li>
+				<li><a href="https://mashable.com/2018/08/06/facebook-financial-info-banks/">Facebook охотится на банки</a> - <em>01:23:58</em>.</li>
+				<li><a href="https://torrentfreak.com/the-pirate-bay-turns-15-years-old-180810/">The Pirate Bay Turns 15 Years Old - TorrentFreak</a> - <em>01:32:20</em>.</li>
+				<li><a href="https://julialang.org/blog/2018/08/one-point-zero">Julia 1.0</a> - <em>01:40:59</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast610.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-610.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast610.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt610.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast610.mp3" type="audio/mp3" length="90769679" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast610.mp3" fileSize="90769679" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 609
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt609.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://dev.to/powerwebdev/an-introduction-to-the-concept-of-design-patterns-o29">Разговоры про шаблоны проектирования</a> - <em>00:10:33</em>.</li>
+				<li><a href="https://brandur.org/interfaces">Чему нас учит терминал</a> - <em>00:33:59</em>.</li>
+				<li><a href="https://sdtimes.com/java/apache-netbeans-incubating-9-0-is-now-available/">Apache NetBeans 9.0</a> - <em>00:53:50</em>.</li>
+				<li><a href="https://blog.plaid.com/building-an-inclusive-code-review-culture/">Проводим Code Review культурно</a> - <em>01:04:25</em>.</li>
+				<li><a href="https://github.com/grumpyhome/grumpy/blob/master/README.md">Grumpy - из питона в Go </a> - <em>01:41:04</em>.</li>
+				<li><a href="https://blog.golang.org/go-cloud">Go Cloud</a> - <em>01:45:06</em>.</li>
+				<li><a href="https://blog.bitbucket.org/2018/07/19/13-new-bitbucket-cloud-features/">Обновления Bitbucket</a> - <em>01:49:40</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast609.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-609.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast609.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/08/04/podcast-609/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/08/04//podcast-609/
+			</guid>
+			<pubDate>
+				Sat, 04 Aug 2018 19:14:14 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt609.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://dev.to/powerwebdev/an-introduction-to-the-concept-of-design-patterns-o29">Разговоры про шаблоны проектирования</a> - <em>00:10:33</em>.</li>
+				<li><a href="https://brandur.org/interfaces">Чему нас учит терминал</a> - <em>00:33:59</em>.</li>
+				<li><a href="https://sdtimes.com/java/apache-netbeans-incubating-9-0-is-now-available/">Apache NetBeans 9.0</a> - <em>00:53:50</em>.</li>
+				<li><a href="https://blog.plaid.com/building-an-inclusive-code-review-culture/">Проводим Code Review культурно</a> - <em>01:04:25</em>.</li>
+				<li><a href="https://github.com/grumpyhome/grumpy/blob/master/README.md">Grumpy - из питона в Go </a> - <em>01:41:04</em>.</li>
+				<li><a href="https://blog.golang.org/go-cloud">Go Cloud</a> - <em>01:45:06</em>.</li>
+				<li><a href="https://blog.bitbucket.org/2018/07/19/13-new-bitbucket-cloud-features/">Обновления Bitbucket</a> - <em>01:49:40</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast609.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-609.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast609.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt609.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast609.mp3" type="audio/mp3" length="103882887" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast609.mp3" fileSize="103882887" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 608
+			</title>
+			<description>
+				<![CDATA[
+				<ul>
+				<li><a href="https://www.atlassian.com/blog/announcements/new-atlassian-slack-partnership">Конец hipcat</a></li>
+				<li><a href="https://zapier.com/blog/slack-versus-hipchat/">Почему Slack захватил рынок</a>.</li>
+				<li><a href="https://telegram.org/blog/passport">Telegram Passport</a>.</li>
+				<li><a href="https://www.digitaltrends.com/computing/google-titan-security-key-eliminates-passwords/">USB токен от Google</a>.</li>
+				<li><a href="https://www.scientificamerican.com/article/23andme-is-sharing-genetic-data-with-drug-giant/">23andMe нашел себе подозрительных друзей</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast608.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-608.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast608.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/07/28/podcast-608/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/07/28//podcast-608/
+			</guid>
+			<pubDate>
+				Sat, 28 Jul 2018 22:30:06 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<ul>
+				<li><a href="https://www.atlassian.com/blog/announcements/new-atlassian-slack-partnership">Конец hipcat</a></li>
+				<li><a href="https://zapier.com/blog/slack-versus-hipchat/">Почему Slack захватил рынок</a>.</li>
+				<li><a href="https://telegram.org/blog/passport">Telegram Passport</a>.</li>
+				<li><a href="https://www.digitaltrends.com/computing/google-titan-security-key-eliminates-passwords/">USB токен от Google</a>.</li>
+				<li><a href="https://www.scientificamerican.com/article/23andme-is-sharing-genetic-data-with-drug-giant/">23andMe нашел себе подозрительных друзей</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast608.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-608.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast608.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt608.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast608.mp3" type="audio/mp3" length="114907584" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast608.mp3" fileSize="114907584" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 607
+			</title>
+			<description>
+				<![CDATA[
+				<ul>
+				<li><a href="https://www.inverse.com/article/47169-macbook-pro-2018-throttling-complaints-reddit">MacBook Pro 2018 неприятно удивил</a></li>
+				<li><a href="http://europa.eu/rapid/press-release_IP-18-4581_en.htm">Google наказывают за все хорошее</a></li>
+				<li><a href="https://mashable.com/2018/07/18/corning-gorilla-glass-6-textured-glass/">Телефон из стекла</a></li>
+				<li><a href="https://quariety.com/2018/07/20/peertube-the-decentralized-youtube-succeeds-in-crowdfunding/">PeerTube собрал немного денег на убийство YouTube</a></li>
+				<li><a href="https://thenextweb.com/insider/2018/07/18/walmart-is-reportedly-building-a-video-streaming-service-to-take-on-netflix/">Walmart тоже хочет быть видео сервисом</a></li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast607.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-607.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast607.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/07/22/podcast-607/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/07/22//podcast-607/
+			</guid>
+			<pubDate>
+				Sun, 22 Jul 2018 00:09:00 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<ul>
+				<li><a href="https://www.inverse.com/article/47169-macbook-pro-2018-throttling-complaints-reddit">MacBook Pro 2018 неприятно удивил</a></li>
+				<li><a href="http://europa.eu/rapid/press-release_IP-18-4581_en.htm">Google наказывают за все хорошее</a></li>
+				<li><a href="https://mashable.com/2018/07/18/corning-gorilla-glass-6-textured-glass/">Телефон из стекла</a></li>
+				<li><a href="https://quariety.com/2018/07/20/peertube-the-decentralized-youtube-succeeds-in-crowdfunding/">PeerTube собрал немного денег на убийство YouTube</a></li>
+				<li><a href="https://thenextweb.com/insider/2018/07/18/walmart-is-reportedly-building-a-video-streaming-service-to-take-on-netflix/">Walmart тоже хочет быть видео сервисом</a></li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast607.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-607.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast607.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt607.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast607.mp3" type="audio/mp3" length="66279099" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast607.mp3" fileSize="66279099" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 606
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt606.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/07/12/apples-macbook-refresh-puts-the-focus-back-on-creative-pros/">Новые MacBook Pro не совсем то, что ожидали</a> - <em>00:02:16</em>.</li>
+				<li><a href="https://www.theverge.com/2018/7/12/17563640/apple-macbook-pro-touchbar-2018-intel-processor-siri-true-tone">Клавиатура тише, процессоры шустрее</a> - <em>00:18:33</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=48959">Гвидо ван Россум решил отстраниться</a> - <em>00:30:22</em>.</li>
+				<li><a href="https://techcrunch.com/2018/07/12/microsoft-teams-gets-a-free-version/">Microsoft Teams доступна в бесплатном варианте</a> - <em>01:00:06</em>.</li>
+				<li><a href="https://gizmodo.com/twitter-is-suspending-more-than-one-million-accounts-pe-1827409235">Twitter удалят спамеров миллионами</a> - <em>01:07:58</em>.</li>
+				<li><a href="https://blog.ubuntu.com/2018/07/09/minimal-ubuntu-released">Minimal Ubuntu не так уж и мала</a> - <em>01:19:27</em>.</li>
+				<li><a href="https://bgr.com/2018/07/10/apple-1password-acquisition-deal/amp/">Apple купил много 1Password, но может купить еще больше</a> - <em>01:30:16</em>.</li>
+				<li><a href="http://blog.memsql.com/nosql/">Прощай, NoSQL. Твое время ушло</a> - <em>01:36:56</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast606.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-606.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast606.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/07/14/podcast-606/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/07/14//podcast-606/
+			</guid>
+			<pubDate>
+				Sat, 14 Jul 2018 18:17:43 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt606.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/07/12/apples-macbook-refresh-puts-the-focus-back-on-creative-pros/">Новые MacBook Pro не совсем то, что ожидали</a> - <em>00:02:16</em>.</li>
+				<li><a href="https://www.theverge.com/2018/7/12/17563640/apple-macbook-pro-touchbar-2018-intel-processor-siri-true-tone">Клавиатура тише, процессоры шустрее</a> - <em>00:18:33</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=48959">Гвидо ван Россум решил отстраниться</a> - <em>00:30:22</em>.</li>
+				<li><a href="https://techcrunch.com/2018/07/12/microsoft-teams-gets-a-free-version/">Microsoft Teams доступна в бесплатном варианте</a> - <em>01:00:06</em>.</li>
+				<li><a href="https://gizmodo.com/twitter-is-suspending-more-than-one-million-accounts-pe-1827409235">Twitter удалят спамеров миллионами</a> - <em>01:07:58</em>.</li>
+				<li><a href="https://blog.ubuntu.com/2018/07/09/minimal-ubuntu-released">Minimal Ubuntu не так уж и мала</a> - <em>01:19:27</em>.</li>
+				<li><a href="https://bgr.com/2018/07/10/apple-1password-acquisition-deal/amp/">Apple купил много 1Password, но может купить еще больше</a> - <em>01:30:16</em>.</li>
+				<li><a href="http://blog.memsql.com/nosql/">Прощай, NoSQL. Твое время ушло</a> - <em>01:36:56</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast606.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-606.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast606.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt606.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast606.mp3" type="audio/mp3" length="88343426" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast606.mp3" fileSize="88343426" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<credit role="author">
+			Umputun, Bobuk, Gray, Ksenks
+		</credit>
+		<rating>
+			nonadult
+		</rating>
+		<description type="plain">
+			Еженедельные импровизации на хай–тек темы
+		</description>
+	</channel>
+</rss>

+ 1501 - 0
app/rss/testdata/f2.xml

@@ -0,0 +1,1501 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
+	<channel>
+		<title>
+			Радио-Т
+		</title>
+		<link>
+			https://radio-t.com
+		</link>
+		<language>
+			ru
+		</language>
+		<copyright>
+			Creative Commons - Attribution, Noncommercial, No Derivative Works 3.0 License.
+		</copyright>
+		<author>
+			Umputun, Bobuk, Gray, Ksenks
+		</author>
+		<subtitle>
+			Еженедельные импровизации на хай–тек темы
+		</subtitle>
+		<description>
+			Разговоры на темы хайтек, высоких компьютерных технологий, гаджетов, облаков, программирования и прочего интересного из мира ИТ.
+		</description>
+		<explicit>
+			no
+		</explicit>
+		<image href="https://radio-t.com/images/cover.jpg" />
+		<keywords>
+			hitech,russian,radiot,tech,news,радио
+		</keywords>
+		<link xmlns:atom10="http://www.w3.org/2005/Atom" rel="self" type="application/rss+xml" href="http://feeds.feedburner.com/radio-t" />
+		<info xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" uri="radio-t" />
+		<link xmlns:atom10="http://www.w3.org/2005/Atom" rel="hub" href="http://pubsubhubbub.appspot.com/" />
+		<copyright>
+			Creative Commons - Attribution, Noncommercial, No Derivative Works 3.0 License.
+		</copyright>
+		<thumbnail url="https://radio-t.com/images/cover.jpg" />
+		<keywords>
+			hitech,russian,radiot,tech,news,радио
+		</keywords>
+		<category scheme="http://www.itunes.com/dtds/podcast-1.0.dtd">
+			Technology/Tech News
+		</category>
+		<category scheme="http://www.itunes.com/dtds/podcast-1.0.dtd">
+			Technology/Gadgets
+		</category>
+		<owner>
+			<email>
+				podcast@radio-t.com
+			</email>
+			<name>
+				Umputun, Bobuk, Gray, Ksenks
+			</name>
+		</owner>
+		<summary>
+			Еженедельные импровизации на хай–тек темы
+		</summary>
+		<category text="Technology">
+			<category text="Tech News" />
+		</category>
+		<category text="Technology">
+			<category text="Gadgets" />
+		</category>
+		<item>
+			<title>
+				Радио-Т 626
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt626.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.golang.org/go2-here-we-come">Go 2 начинается</a> - <em>00:01:31</em>.</li>
+				<li><a href="https://aws.amazon.com/blogs/aws/new-aws-transfer-for-sftp-fully-managed-sftp-service-for-amazon-s3/">AWS Transfer for SFTP</a> - <em>00:19:39</em>.</li>
+				<li><a href="https://aws.amazon.com/blogs/compute/introducing-aws-app-mesh-service-mesh-for-microservices-on-aws/">AWS App Mesh</a> - <em>00:33:39</em>.</li>
+				<li><a href="https://aws.amazon.com/blogs/aws/amazon-dynamodb-on-demand-no-capacity-planning-and-pay-per-request-pricing/">Amazon DynamoDB On-Demand</a> - <em>00:46:50</em>.</li>
+				<li><a href="https://aws.amazon.com/about-aws/whats-new/2018/11/alb-can-now-invoke-lambda-functions-to-serve-https-requests/">ALB сможет вызвать Lambda</a> - <em>00:54:45</em>.</li>
+				<li><a href="https://aws.amazon.com/blogs/aws/new-for-aws-lambda-use-any-programming-language-and-share-common-components/">Слои общего кода в AWS Lambda</a> - <em>01:15:46</em>.</li>
+				<li><a href="https://blog.drone.io/drone-cloud/">Drone Cloud и бесплатно</a> - <em>01:21:39</em>.</li>
+				<li><a href="https://www.foundationdb.org/blog/announcing-document-layer/">FoundationDB Document Layer совместим с mongo</a> - <em>01:41:31</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast626.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-626.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast626.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/12/01/podcast-626/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/12/01//podcast-626/
+			</guid>
+			<pubDate>
+				Sat, 01 Dec 2018 18:11:19 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt626.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.golang.org/go2-here-we-come">Go 2 начинается</a> - <em>00:01:31</em>.</li>
+				<li><a href="https://aws.amazon.com/blogs/aws/new-aws-transfer-for-sftp-fully-managed-sftp-service-for-amazon-s3/">AWS Transfer for SFTP</a> - <em>00:19:39</em>.</li>
+				<li><a href="https://aws.amazon.com/blogs/compute/introducing-aws-app-mesh-service-mesh-for-microservices-on-aws/">AWS App Mesh</a> - <em>00:33:39</em>.</li>
+				<li><a href="https://aws.amazon.com/blogs/aws/amazon-dynamodb-on-demand-no-capacity-planning-and-pay-per-request-pricing/">Amazon DynamoDB On-Demand</a> - <em>00:46:50</em>.</li>
+				<li><a href="https://aws.amazon.com/about-aws/whats-new/2018/11/alb-can-now-invoke-lambda-functions-to-serve-https-requests/">ALB сможет вызвать Lambda</a> - <em>00:54:45</em>.</li>
+				<li><a href="https://aws.amazon.com/blogs/aws/new-for-aws-lambda-use-any-programming-language-and-share-common-components/">Слои общего кода в AWS Lambda</a> - <em>01:15:46</em>.</li>
+				<li><a href="https://blog.drone.io/drone-cloud/">Drone Cloud и бесплатно</a> - <em>01:21:39</em>.</li>
+				<li><a href="https://www.foundationdb.org/blog/announcing-document-layer/">FoundationDB Document Layer совместим с mongo</a> - <em>01:41:31</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast626.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-626.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast626.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt626.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast626.mp3" type="audio/mp3" length="92424797" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast626.mp3" fileSize="92424797" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 625
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt625.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.cnet.com/news/logitech-reportedly-in-talks-to-acquire-plantronics/">Logitech приобретает Plantronics</a>.</li>
+				<li><a href="https://bossasaservice.life">Boss as a Service</a>.</li>
+				<li><a href="https://gcemetery.co/">Кладбище проектов Google</a>.</li>
+				<li><a href="https://developer.github.com/actions/creating-workflows/creating-a-new-workflow/">Actions в реальной жизни</a>.</li>
+				<li><a href="https://www.forbes.com/sites/janakirammsv/2018/11/21/aws-adds-the-most-anticipated-feature-to-amazon-ec2/">AWS добавила ума EC2</a>.</li>
+				<li><a href="https://habr.com/post/427211/">Electron это Flash для десктопа</a>.</li>
+				<li><a href="http://blairreeves.me/2018/11/09/dont-work-remotely/?resubmit=hn">Как решить вопрос удаленной работы</a>.</li>
+				<li><a href="https://mashable.com/article/amazon-customer-data-leak/">Amazon потеряла данные пользователей</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast625.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-625.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast625.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/11/24/podcast-625/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/11/24//podcast-625/
+			</guid>
+			<pubDate>
+				Sat, 24 Nov 2018 17:59:34 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt625.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.cnet.com/news/logitech-reportedly-in-talks-to-acquire-plantronics/">Logitech приобретает Plantronics</a>.</li>
+				<li><a href="https://bossasaservice.life">Boss as a Service</a>.</li>
+				<li><a href="https://gcemetery.co/">Кладбище проектов Google</a>.</li>
+				<li><a href="https://developer.github.com/actions/creating-workflows/creating-a-new-workflow/">Actions в реальной жизни</a>.</li>
+				<li><a href="https://www.forbes.com/sites/janakirammsv/2018/11/21/aws-adds-the-most-anticipated-feature-to-amazon-ec2/">AWS добавила ума EC2</a>.</li>
+				<li><a href="https://habr.com/post/427211/">Electron это Flash для десктопа</a>.</li>
+				<li><a href="http://blairreeves.me/2018/11/09/dont-work-remotely/?resubmit=hn">Как решить вопрос удаленной работы</a>.</li>
+				<li><a href="https://mashable.com/article/amazon-customer-data-leak/">Amazon потеряла данные пользователей</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast625.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-625.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast625.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt625.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast625.mp3" type="audio/mp3" length="90301564" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast625.mp3" fileSize="90301564" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 624
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt624.jpg" alt=""></p>
+				<ul>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49615">Время поддержки Ubuntu 18.04 увеличено до 10 лет</a> - <em>00:04:02</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49594">HTTP поверх протокола QUIC будет HTTP/3</a> - <em>00:13:11</em>.</li>
+				<li><a href="https://outline.com/UzUdUE">Larry сказал лишнего</a> - <em>00:27:33</em>.</li>
+				<li><a href="https://www.itnews.com.au/news/amazon-and-oracle-in-database-drama-515447">Amazon, Oracle и драма вокруг БД</a> - <em>00:34:38</em>.</li>
+				<li><a href="https://aws.amazon.com/corretto/">Corretto для LTS OpenJDK</a> - <em>00:34:48</em>.</li>
+				<li><a href="https://news.ycombinator.com/item?id=18442941">Ужас кодовой базы</a> - <em>00:43:46</em>.</li>
+				<li><a href="https://habr.com/post/429612/">Фулстеки — это вечные мидлы</a> - <em>00:55:05</em>.</li>
+				<li><a href="https://www.theverge.com/2018/11/14/18095729/mark-zuckerberg-order-facebook-executive-android-phones">Всех посадить на андроид телефоны</a> - <em>01:09:55</em>.</li>
+				<li><a href="https://techcrunch.com/2018/11/15/facebook-borderline-content/">Facebook введет еще больше цензуры</a> - <em>01:15:03</em>.</li>
+				<li><a href="https://www.bleepingcomputer.com/news/security/cloudflare-brings-its-1111-dns-service-to-android-and-ios-mobile-devices/">Cloudflare с 1.1.1.1 DNS на телефонах</a> - <em>01:23:25</em>.</li>
+				<li><a href="https://pastebin.com/bwvqHhbA?fbclid=IwAR3Nk2wKnv4MjRycU0oQQas5eXQ3v5x9Or_KAsefmgmykH0PviLS74fIR1c">Мифический взлом Protonmail</a> - <em>01:32:22</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast624.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-624.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast624.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/11/17/podcast-624/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/11/17//podcast-624/
+			</guid>
+			<pubDate>
+				Sat, 17 Nov 2018 17:31:06 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt624.jpg" alt=""></p>
+				<ul>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49615">Время поддержки Ubuntu 18.04 увеличено до 10 лет</a> - <em>00:04:02</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49594">HTTP поверх протокола QUIC будет HTTP/3</a> - <em>00:13:11</em>.</li>
+				<li><a href="https://outline.com/UzUdUE">Larry сказал лишнего</a> - <em>00:27:33</em>.</li>
+				<li><a href="https://www.itnews.com.au/news/amazon-and-oracle-in-database-drama-515447">Amazon, Oracle и драма вокруг БД</a> - <em>00:34:38</em>.</li>
+				<li><a href="https://aws.amazon.com/corretto/">Corretto для LTS OpenJDK</a> - <em>00:34:48</em>.</li>
+				<li><a href="https://news.ycombinator.com/item?id=18442941">Ужас кодовой базы</a> - <em>00:43:46</em>.</li>
+				<li><a href="https://habr.com/post/429612/">Фулстеки — это вечные мидлы</a> - <em>00:55:05</em>.</li>
+				<li><a href="https://www.theverge.com/2018/11/14/18095729/mark-zuckerberg-order-facebook-executive-android-phones">Всех посадить на андроид телефоны</a> - <em>01:09:55</em>.</li>
+				<li><a href="https://techcrunch.com/2018/11/15/facebook-borderline-content/">Facebook введет еще больше цензуры</a> - <em>01:15:03</em>.</li>
+				<li><a href="https://www.bleepingcomputer.com/news/security/cloudflare-brings-its-1111-dns-service-to-android-and-ios-mobile-devices/">Cloudflare с 1.1.1.1 DNS на телефонах</a> - <em>01:23:25</em>.</li>
+				<li><a href="https://pastebin.com/bwvqHhbA?fbclid=IwAR3Nk2wKnv4MjRycU0oQQas5eXQ3v5x9Or_KAsefmgmykH0PviLS74fIR1c">Мифический взлом Protonmail</a> - <em>01:32:22</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast624.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-624.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast624.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt624.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast624.mp3" type="audio/mp3" length="75515997" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast624.mp3" fileSize="75515997" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 623
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt623.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://marco.org/2018/11/06/mac-mini-2018-review">2018 Mac Mini</a> - <em>00:06:00</em>.</li>
+				<li><a href="https://www.theverge.com/2018/11/5/18062612/apple-ipad-pro-review-2018-screen-usb-c-pencil-price-features">Apple iPad Pro 2018</a> - <em>00:27:32</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49580">GitHub преодолел рубеж в 100 млн репозиториев</a> - <em>00:47:22</em>.</li>
+				<li><a href="https://mashable.com/article/amazon-apple-sell-iphones-no-homepod/">Amazon начинает продавать продукты Apple</a> - <em>01:16:12</em>.</li>
+				<li><a href="https://techcrunch.com/2018/11/07/samsung-shares-a-glimpse-of-its-folding-infinity-flex-display-smartphone/">Samsung показал телефон с гибким дисплеем</a> - <em>01:29:03</em>.</li>
+				<li><a href="https://mashable.com/article/foldable-phones-fad/">Складные экраны надолго или это мода</a> - <em>01:33:21</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast623.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-623.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast623.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/11/10/podcast-623/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/11/10//podcast-623/
+			</guid>
+			<pubDate>
+				Sat, 10 Nov 2018 18:09:36 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt623.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://marco.org/2018/11/06/mac-mini-2018-review">2018 Mac Mini</a> - <em>00:06:00</em>.</li>
+				<li><a href="https://www.theverge.com/2018/11/5/18062612/apple-ipad-pro-review-2018-screen-usb-c-pencil-price-features">Apple iPad Pro 2018</a> - <em>00:27:32</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49580">GitHub преодолел рубеж в 100 млн репозиториев</a> - <em>00:47:22</em>.</li>
+				<li><a href="https://mashable.com/article/amazon-apple-sell-iphones-no-homepod/">Amazon начинает продавать продукты Apple</a> - <em>01:16:12</em>.</li>
+				<li><a href="https://techcrunch.com/2018/11/07/samsung-shares-a-glimpse-of-its-folding-infinity-flex-display-smartphone/">Samsung показал телефон с гибким дисплеем</a> - <em>01:29:03</em>.</li>
+				<li><a href="https://mashable.com/article/foldable-phones-fad/">Складные экраны надолго или это мода</a> - <em>01:33:21</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast623.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-623.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast623.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt623.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast623.mp3" type="audio/mp3" length="87763507" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast623.mp3" fileSize="87763507" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 622
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt622.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.redhat.com/en/blog/red-hat-ibm-creating-leading-hybrid-cloud-provider">Red Hat + IBM</a>.</li>
+				<li><a href="https://stratechery.com/2018/ibms-old-playbook/">IBM борется за облачный рынок</a>.</li>
+				<li><a href="https://jriddell.org/2018/11/02/red-hat-and-kde/">Red Hat убил KDE</a>.</li>
+				<li><a href="https://www.wired.com/story/everything-apple-announced-ipad-pro-macbook-air-mac-mini/?mbid=social_twitter">Apple с новыми MacBook Air и Mac Mini</a>.</li>
+				<li><a href="https://habr.com/post/428409/">Анализ инцидента 21 октября на GitHub</a>.</li>
+				<li><a href="https://github.com/sharkdp/bat">Как cat, но лучше</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast622.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-622.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast622.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/11/03/podcast-622/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/11/03//podcast-622/
+			</guid>
+			<pubDate>
+				Sat, 03 Nov 2018 18:12:55 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt622.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.redhat.com/en/blog/red-hat-ibm-creating-leading-hybrid-cloud-provider">Red Hat + IBM</a>.</li>
+				<li><a href="https://stratechery.com/2018/ibms-old-playbook/">IBM борется за облачный рынок</a>.</li>
+				<li><a href="https://jriddell.org/2018/11/02/red-hat-and-kde/">Red Hat убил KDE</a>.</li>
+				<li><a href="https://www.wired.com/story/everything-apple-announced-ipad-pro-macbook-air-mac-mini/?mbid=social_twitter">Apple с новыми MacBook Air и Mac Mini</a>.</li>
+				<li><a href="https://habr.com/post/428409/">Анализ инцидента 21 октября на GitHub</a>.</li>
+				<li><a href="https://github.com/sharkdp/bat">Как cat, но лучше</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast622.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-622.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast622.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt622.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast622.mp3" type="audio/mp3" length="88746234" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast622.mp3" fileSize="88746234" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 621
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt621.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://lwn.net/Articles/769110/">Linux 4.19 – главный вернулся</a> - <em>00:04:00</em>.</li>
+				<li><a href="https://blog.github.com/2018-10-21-october21-incident-report/">Болезненное падение Github</a> - <em>00:31:24</em>.</li>
+				<li><a href="https://www.macrumors.com/2018/10/26/what-to-expect-at-october-30-apple-event/">Что ожидать от внезапного события</a> - <em>00:44:02</em>.</li>
+				<li><a href="https://blog.github.com/2018-10-26-github-and-microsoft/">Покупка завершена</a> - <em>01:13:50</em>.</li>
+				<li><a href="https://github.com/akavel/up">Ultimate Plumber</a> - <em>01:21:44</em>.</li>
+				<li><a href="https://habr.com/post/427265/">Protobuffers — это неправильно</a> - <em>01:34:55</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast621.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-621.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast621.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/10/27/podcast-621/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/10/27//podcast-621/
+			</guid>
+			<pubDate>
+				Sat, 27 Oct 2018 19:06:08 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt621.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://lwn.net/Articles/769110/">Linux 4.19 – главный вернулся</a> - <em>00:04:00</em>.</li>
+				<li><a href="https://blog.github.com/2018-10-21-october21-incident-report/">Болезненное падение Github</a> - <em>00:31:24</em>.</li>
+				<li><a href="https://www.macrumors.com/2018/10/26/what-to-expect-at-october-30-apple-event/">Что ожидать от внезапного события</a> - <em>00:44:02</em>.</li>
+				<li><a href="https://blog.github.com/2018-10-26-github-and-microsoft/">Покупка завершена</a> - <em>01:13:50</em>.</li>
+				<li><a href="https://github.com/akavel/up">Ultimate Plumber</a> - <em>01:21:44</em>.</li>
+				<li><a href="https://habr.com/post/427265/">Protobuffers — это неправильно</a> - <em>01:34:55</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast621.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-621.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast621.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt621.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast621.mp3" type="audio/mp3" length="100691507" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast621.mp3" fileSize="100691507" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 620
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt620.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/10/16/github-launches-actions-its-workflow-automation-tool/">GitHub добавил Actions</a> - <em>00:04:00</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/16/mongodb-switches-up-its-open-source-license/">MongoDB поменяла лицензию</a> - <em>00:49:29</em>.</li>
+				<li><a href="https://www.winamp.com/">Winamp через 5 лет</a> - <em>00:57:45</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/15/palm-returns-as-an-ultra-mobile-smartphone/">Palm возвращается в странном виде</a> - <em>01:00:59</em>.</li>
+				<li><a href="https://thehelm.com/">Helm для почти локальной почты</a> - <em>01:24:31</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast620.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-620.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast620.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/10/20/podcast-620/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/10/20//podcast-620/
+			</guid>
+			<pubDate>
+				Sat, 20 Oct 2018 18:17:33 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt620.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/10/16/github-launches-actions-its-workflow-automation-tool/">GitHub добавил Actions</a> - <em>00:04:00</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/16/mongodb-switches-up-its-open-source-license/">MongoDB поменяла лицензию</a> - <em>00:49:29</em>.</li>
+				<li><a href="https://www.winamp.com/">Winamp через 5 лет</a> - <em>00:57:45</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/15/palm-returns-as-an-ultra-mobile-smartphone/">Palm возвращается в странном виде</a> - <em>01:00:59</em>.</li>
+				<li><a href="https://thehelm.com/">Helm для почти локальной почты</a> - <em>01:24:31</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast620.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-620.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast620.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt620.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast620.mp3" type="audio/mp3" length="80076454" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast620.mp3" fileSize="80076454" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 619
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt619.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.google/technology/safety-security/project-strobe/">Как гугл внезапно закрыл Google+</a> - <em>00:03:45</em>.</li>
+				<li><a href="https://www.engadget.com/2018/10/12/goodbye-google-plus-you-beautiful-squandered-opportunity/">Чо посшло не так с Google+</a> - <em>00:09:12</em>.</li>
+				<li><a href="https://www.gijsk.com/blog/2018/10/firefox-removes-core-product-support-for-rss-atom-feeds/">Firefox убрал поддержку RSS/Atom</a> - <em>00:25:47</em>.</li>
+				<li><a href="https://code.visualstudio.com/docs/supporting/faq">Отучить VSCode от плохих привычек</a> - <em>00:39:18</em>.</li>
+				<li><a href="https://www.theverge.com/2018/10/10/17958784/ai-recruiting-tool-bias-amazon-report">Amazon разработал сексисткий AI</a> - <em>01:02:55</em>.</li>
+				<li><a href="https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46">12 факторов для CLI</a> - <em>01:14:13</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast619.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-619.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast619.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/10/13/podcast-619/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/10/13//podcast-619/
+			</guid>
+			<pubDate>
+				Sat, 13 Oct 2018 18:20:54 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt619.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.google/technology/safety-security/project-strobe/">Как гугл внезапно закрыл Google+</a> - <em>00:03:45</em>.</li>
+				<li><a href="https://www.engadget.com/2018/10/12/goodbye-google-plus-you-beautiful-squandered-opportunity/">Чо посшло не так с Google+</a> - <em>00:09:12</em>.</li>
+				<li><a href="https://www.gijsk.com/blog/2018/10/firefox-removes-core-product-support-for-rss-atom-feeds/">Firefox убрал поддержку RSS/Atom</a> - <em>00:25:47</em>.</li>
+				<li><a href="https://code.visualstudio.com/docs/supporting/faq">Отучить VSCode от плохих привычек</a> - <em>00:39:18</em>.</li>
+				<li><a href="https://www.theverge.com/2018/10/10/17958784/ai-recruiting-tool-bias-amazon-report">Amazon разработал сексисткий AI</a> - <em>01:02:55</em>.</li>
+				<li><a href="https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46">12 факторов для CLI</a> - <em>01:14:13</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast619.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-619.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast619.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt619.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast619.mp3" type="audio/mp3" length="85528209" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast619.mp3" fileSize="85528209" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 618
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt618.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.digitalocean.com/custom-images/">Custom Image теперь и в DigitalOcean</a> - <em>00:13:36</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/05/scaleway-adds-object-storage/">Scaleway добавила object storage</a> - <em>00:25:05</em>.</li>
+				<li><a href="https://blog.cloudflare.com/introducing-workers-kv/">Workers KV - больше чем KV</a> - <em>00:33:26</em>.</li>
+				<li><a href="https://blog.cloudflare.com/building-with-workers-kv/">Технические детали Workers KV</a> - <em>00:37:58</em>.</li>
+				<li><a href="https://www.androidcentral.com/android-developers-love-kotlin">Kotlin проникает в Google Cloud Platform</a> - <em>00:46:08</em>.</li>
+				<li><a href="https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/">Что не так с конкурентностью в Go</a> - <em>01:06:24</em>.</li>
+				<li><a href="https://eli.thegreenplace.net/2018/go-hits-the-concurrency-nail-right-on-the-head/">Все так с конкурентностью в Go</a> - <em>01:12:38</em>.</li>
+				<li><a href="https://www.lightbluetouchpaper.org/2018/10/05/making-sense-of-the-supermicro-motherboard-attack/">Supermicro motherboard attack</a> - <em>01:32:44</em>.</li>
+				<li><a href="https://developer.okta.com/blog/2017/08/17/why-jwts-suck-as-session-tokens">Чем так плох JWT</a> - <em>01:44:56</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast618.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-618.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast618.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/10/06/podcast-618/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/10/06//podcast-618/
+			</guid>
+			<pubDate>
+				Sat, 06 Oct 2018 17:44:05 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt618.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://blog.digitalocean.com/custom-images/">Custom Image теперь и в DigitalOcean</a> - <em>00:13:36</em>.</li>
+				<li><a href="https://techcrunch.com/2018/10/05/scaleway-adds-object-storage/">Scaleway добавила object storage</a> - <em>00:25:05</em>.</li>
+				<li><a href="https://blog.cloudflare.com/introducing-workers-kv/">Workers KV - больше чем KV</a> - <em>00:33:26</em>.</li>
+				<li><a href="https://blog.cloudflare.com/building-with-workers-kv/">Технические детали Workers KV</a> - <em>00:37:58</em>.</li>
+				<li><a href="https://www.androidcentral.com/android-developers-love-kotlin">Kotlin проникает в Google Cloud Platform</a> - <em>00:46:08</em>.</li>
+				<li><a href="https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/">Что не так с конкурентностью в Go</a> - <em>01:06:24</em>.</li>
+				<li><a href="https://eli.thegreenplace.net/2018/go-hits-the-concurrency-nail-right-on-the-head/">Все так с конкурентностью в Go</a> - <em>01:12:38</em>.</li>
+				<li><a href="https://www.lightbluetouchpaper.org/2018/10/05/making-sense-of-the-supermicro-motherboard-attack/">Supermicro motherboard attack</a> - <em>01:32:44</em>.</li>
+				<li><a href="https://developer.okta.com/blog/2017/08/17/why-jwts-suck-as-session-tokens">Чем так плох JWT</a> - <em>01:44:56</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast618.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-618.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast618.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt618.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast618.mp3" type="audio/mp3" length="84847981" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast618.mp3" fileSize="84847981" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 617
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt617.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.apple.com/macos/mojave/">macOS Mojave</a> - <em>00:04:33</em>.</li>
+				<li><a href="https://gizmodo.com/facebook-is-giving-advertisers-access-to-your-shadow-co-1828476051">Facebook сливает через 3х лиц</a> - <em>00:44:58</em>.</li>
+				<li><a href="https://gizmodo.com/50-million-facebook-accounts-affected-in-massive-securi-1829394250">50М пользователей Facebook утекли</a> - <em>00:47:43</em>.</li>
+				<li><a href="https://blog.cloudflare.com/cloudflare-registrar/">Cloudflare Registrar</a> - <em>01:09:10</em>.</li>
+				<li><a href="https://www.geekwire.com/2018/9-biggest-announcements-microsoft-ignite-tech-conference/">Microsoft Ignite</a> - <em>01:18:04</em>.</li>
+				<li><a href="https://www.apple.com/newsroom/2018/09/apple-acquires-shazam-offering-more-ways-to-discover-and-enjoy-music/">Apple приобрел Shazam</a> - <em>01:22:35</em>.</li>
+				<li><a href="https://9to5google.com/2018/09/25/google-chrome-70-fix-changes/">Скандалы вокруг Google и Chrome</a> - <em>01:25:28</em>.</li>
+				<li><a href="https://SkipLang.github.io/index.html">Язык программирования skip</a> - <em>01:34:18</em>.</li>
+				<li><a href="https://venturebeat.com/2018/09/07/tor-gets-its-first-official-mobile-browser/">Tor выпустил мобильный browser</a> - <em>01:35:35</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast617.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-617.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast617.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/29/podcast-617/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/29//podcast-617/
+			</guid>
+			<pubDate>
+				Sat, 29 Sep 2018 18:04:30 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt617.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.apple.com/macos/mojave/">macOS Mojave</a> - <em>00:04:33</em>.</li>
+				<li><a href="https://gizmodo.com/facebook-is-giving-advertisers-access-to-your-shadow-co-1828476051">Facebook сливает через 3х лиц</a> - <em>00:44:58</em>.</li>
+				<li><a href="https://gizmodo.com/50-million-facebook-accounts-affected-in-massive-securi-1829394250">50М пользователей Facebook утекли</a> - <em>00:47:43</em>.</li>
+				<li><a href="https://blog.cloudflare.com/cloudflare-registrar/">Cloudflare Registrar</a> - <em>01:09:10</em>.</li>
+				<li><a href="https://www.geekwire.com/2018/9-biggest-announcements-microsoft-ignite-tech-conference/">Microsoft Ignite</a> - <em>01:18:04</em>.</li>
+				<li><a href="https://www.apple.com/newsroom/2018/09/apple-acquires-shazam-offering-more-ways-to-discover-and-enjoy-music/">Apple приобрел Shazam</a> - <em>01:22:35</em>.</li>
+				<li><a href="https://9to5google.com/2018/09/25/google-chrome-70-fix-changes/">Скандалы вокруг Google и Chrome</a> - <em>01:25:28</em>.</li>
+				<li><a href="https://SkipLang.github.io/index.html">Язык программирования skip</a> - <em>01:34:18</em>.</li>
+				<li><a href="https://venturebeat.com/2018/09/07/tor-gets-its-first-official-mobile-browser/">Tor выпустил мобильный browser</a> - <em>01:35:35</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast617.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-617.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast617.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt617.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast617.mp3" type="audio/mp3" length="76018070" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast617.mp3" fileSize="76018070" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 616
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt616.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.bloomberg.com/tosv2.html?vid=&amp;uuid=15ef6670-bd59-11e8-bca1-0d901f158ed4&amp;url=L25ld3MvYXJ0aWNsZXMvMjAxOC0wOS0xOS9hbHBoYWJldC1iYWNrcy1naXRsYWItcy1xdWVzdC10by1zdXJwYXNzLW1pY3Jvc29mdC1zLWdpdGh1Yg==">Google вложился в GitLab</a> - <em>00:17:53</em>.</li>
+				<li><a href="https://www.sublimetext.com/blog/articles/sublime-merge">Sublime Merge</a> - <em>00:29:37</em>.</li>
+				<li><a href="https://lkml.org/lkml/2018/9/16/167">Линус заявил странное</a> - <em>00:45:15</em>.</li>
+				<li><a href="https://www.theverge.com/2018/9/20/17883242/amazon-alexa-event-2018-news-recap-echo-auto-dot-sub-link-auto-microwave">Amazon и его бесконечный ряд новинок</a> - <em>01:06:18</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast616.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-616.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast616.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/22/podcast-616/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/22//podcast-616/
+			</guid>
+			<pubDate>
+				Sat, 22 Sep 2018 18:53:35 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt616.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.bloomberg.com/tosv2.html?vid=&amp;uuid=15ef6670-bd59-11e8-bca1-0d901f158ed4&amp;url=L25ld3MvYXJ0aWNsZXMvMjAxOC0wOS0xOS9hbHBoYWJldC1iYWNrcy1naXRsYWItcy1xdWVzdC10by1zdXJwYXNzLW1pY3Jvc29mdC1zLWdpdGh1Yg==">Google вложился в GitLab</a> - <em>00:17:53</em>.</li>
+				<li><a href="https://www.sublimetext.com/blog/articles/sublime-merge">Sublime Merge</a> - <em>00:29:37</em>.</li>
+				<li><a href="https://lkml.org/lkml/2018/9/16/167">Линус заявил странное</a> - <em>00:45:15</em>.</li>
+				<li><a href="https://www.theverge.com/2018/9/20/17883242/amazon-alexa-event-2018-news-recap-echo-auto-dot-sub-link-auto-microwave">Amazon и его бесконечный ряд новинок</a> - <em>01:06:18</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast616.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-616.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast616.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt616.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast616.mp3" type="audio/mp3" length="99919066" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast616.mp3" fileSize="99919066" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 615
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt615.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/09/12/everything-apple-announced-at-its-iphone-xs-event/">Событие от Apple</a>.</li>
+				<li><a href="https://www.theverge.com/2018/9/12/17848500/google-inbox-shut-down-sunset-snooze-email-march-2019">Google закрывает Inbox</a>.</li>
+				<li><a href="https://www.buzzfeednews.com/article/ryanhatesthis/everything-you-need-to-know-about-the-law-european">Новый закон поломает интернет</a>.</li>
+				<li><a href="https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github">GitHub Pull Requests для Visual Studio Code</a>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49256">Python избавляется от терминов "master" и "slave"</a>.</li>
+				<li><a href="https://variety.com/2018/digital/news/plex-cloud-shutting-down-1202936840/">Plex уходит из облаков</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast615.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-615.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast615.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/15/podcast-615/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/15//podcast-615/
+			</guid>
+			<pubDate>
+				Sat, 15 Sep 2018 18:05:24 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt615.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/09/12/everything-apple-announced-at-its-iphone-xs-event/">Событие от Apple</a>.</li>
+				<li><a href="https://www.theverge.com/2018/9/12/17848500/google-inbox-shut-down-sunset-snooze-email-march-2019">Google закрывает Inbox</a>.</li>
+				<li><a href="https://www.buzzfeednews.com/article/ryanhatesthis/everything-you-need-to-know-about-the-law-european">Новый закон поломает интернет</a>.</li>
+				<li><a href="https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github">GitHub Pull Requests для Visual Studio Code</a>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49256">Python избавляется от терминов "master" и "slave"</a>.</li>
+				<li><a href="https://variety.com/2018/digital/news/plex-cloud-shutting-down-1202936840/">Plex уходит из облаков</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast615.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-615.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast615.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt615.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast615.mp3" type="audio/mp3" length="82621826" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast615.mp3" fileSize="82621826" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 614
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt614.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://cloud.yandex.ru/docs">Яндекс.Облако</a> - <em>00:02:18</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49227">Релиз Chrome 69 с переработанным интерфейсом</a> - <em>00:39:59</em>.</li>
+				<li><a href="https://bugs.chromium.org/p/chromium/issues/detail?id=881410">"www." пропал из URL</a> - <em>00:55:20</em>.</li>
+				<li><a href="https://threatpost.com/open-git-directories-leave-390k-websites-vulnerable/137299/">Открытые .Git на 390K сайтов</a> - <em>00:55:42</em>.</li>
+				<li><a href="http://www.pewresearch.org/fact-tank/2018/09/05/americans-are-changing-their-relationship-with-facebook/">Facebook уже не тот</a> - <em>01:05:12</em>.</li>
+				<li><a href="https://techcrunch.com/2018/09/04/evernote-lost-its-cto-cfo-cpo-and-hr-head-in-the-last-month-as-it-eyes-another-fundraise/">Evernote потерял всех руководителей</a> - <em>01:25:52</em>.</li>
+				<li><a href="https://habr.com/post/422421/">Microsoft собирается радикально улучшить Skype</a> - <em>01:48:48</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast614.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-614.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast614.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/08/podcast-614/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/08//podcast-614/
+			</guid>
+			<pubDate>
+				Sat, 08 Sep 2018 18:49:30 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt614.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://cloud.yandex.ru/docs">Яндекс.Облако</a> - <em>00:02:18</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49227">Релиз Chrome 69 с переработанным интерфейсом</a> - <em>00:39:59</em>.</li>
+				<li><a href="https://bugs.chromium.org/p/chromium/issues/detail?id=881410">"www." пропал из URL</a> - <em>00:55:20</em>.</li>
+				<li><a href="https://threatpost.com/open-git-directories-leave-390k-websites-vulnerable/137299/">Открытые .Git на 390K сайтов</a> - <em>00:55:42</em>.</li>
+				<li><a href="http://www.pewresearch.org/fact-tank/2018/09/05/americans-are-changing-their-relationship-with-facebook/">Facebook уже не тот</a> - <em>01:05:12</em>.</li>
+				<li><a href="https://techcrunch.com/2018/09/04/evernote-lost-its-cto-cfo-cpo-and-hr-head-in-the-last-month-as-it-eyes-another-fundraise/">Evernote потерял всех руководителей</a> - <em>01:25:52</em>.</li>
+				<li><a href="https://habr.com/post/422421/">Microsoft собирается радикально улучшить Skype</a> - <em>01:48:48</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast614.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-614.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast614.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt614.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast614.mp3" type="audio/mp3" length="99536895" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast614.mp3" fileSize="99536895" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 613
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt613.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://aws.amazon.com/about-aws/whats-new/2018/08/introducing-amazon-ec2-t3-instances/?fc=p_2">Amazon EC2 T3</a>.</li>
+				<li><a href="https://techcrunch.com/2018/08/23/aws-cuts-the-price-of-most-of-its-lightsail-virtual-private-servers-in-half/">AWS уронило цену на Lightsail</a>.</li>
+				<li><a href="https://blog.golang.org/go1.11">Go 1.11</a>.</li>
+				<li><a href="https://open.microsoft.com/2018/08/28/announcing-project-athens-gophersource-go-community/">Проект Athens</a>.</li>
+				<li><a href="https://blog.golang.org/go2draft">Go 2 Draft</a>.</li>
+				<li><a href="https://erikbern.com/2018/08/30/i-dont-want-to-learn-your-garbage-query-language.html">Зачем учить еще один DSL</a>.</li>
+				<li><a href="https://medium.com/gitpod/gitpod-gitpod-online-ide-for-github-6296b907a886">Gitpod — странное IDE для GitHub</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast613.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-613.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast613.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/09/01/podcast-613/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/09/01//podcast-613/
+			</guid>
+			<pubDate>
+				Sat, 01 Sep 2018 18:18:27 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt613.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://aws.amazon.com/about-aws/whats-new/2018/08/introducing-amazon-ec2-t3-instances/?fc=p_2">Amazon EC2 T3</a>.</li>
+				<li><a href="https://techcrunch.com/2018/08/23/aws-cuts-the-price-of-most-of-its-lightsail-virtual-private-servers-in-half/">AWS уронило цену на Lightsail</a>.</li>
+				<li><a href="https://blog.golang.org/go1.11">Go 1.11</a>.</li>
+				<li><a href="https://open.microsoft.com/2018/08/28/announcing-project-athens-gophersource-go-community/">Проект Athens</a>.</li>
+				<li><a href="https://blog.golang.org/go2draft">Go 2 Draft</a>.</li>
+				<li><a href="https://erikbern.com/2018/08/30/i-dont-want-to-learn-your-garbage-query-language.html">Зачем учить еще один DSL</a>.</li>
+				<li><a href="https://medium.com/gitpod/gitpod-gitpod-online-ide-for-github-6296b907a886">Gitpod — странное IDE для GitHub</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast613.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-613.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast613.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt613.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast613.mp3" type="audio/mp3" length="84207197" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast613.mp3" fileSize="84207197" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 612
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt612.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.theverge.com/2018/8/20/17725226/skype-private-conversation-end-to-end-encrypted-opt-in">Skype зашифровал беседы</a> - <em>00:02:39</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49171">Intel запретил публиковать результаты тестирования</a> - <em>00:19:58</em>.</li>
+				<li><a href="https://www.reddit.com/r/EnoughMuskSpam/comments/99sbwa/former_tesla_programmers_anecdotes_about_problems/">Бывший работникTesla разговорился</a> - <em>00:28:14</em>.</li>
+				<li><a href="https://venturebeat.com/2018/08/23/you-can-now-download-windows-95-as-an-app-for-mac-windows-and-linux/">Windows 95 для любой OS</a> - <em>00:43:58</em>.</li>
+				<li><a href="https://thenextweb.com/plugged/2018/08/23/logitechs-mx-vertical-is-the-ergonomic-mouse-i-didnt-know-i-wanted/">Logitech’s MX Vertical</a> - <em>00:50:34</em>.</li>
+				<li><a href="https://tech.co/google-pixel-3-xl-leaks-rumors-2018-08">Google Pixel 3 XL</a> - <em>01:00:00</em>.</li>
+				<li><a href="https://www.zdnet.com/pictures/microsoft-surface-go-first-impressions/">Microsoft Surface Go</a> - <em>01:10:37</em>.</li>
+				<li><a href="https://www.independent.co.uk/life-style/gadgets-and-tech/news/google-chrome-incognito-mode-personal-data-private-browser-a8502386.html">Google Chrome и не очень приватный режим</a> - <em>01:18:28</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast612.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-612.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast612.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/08/25/podcast-612/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/08/25//podcast-612/
+			</guid>
+			<pubDate>
+				Sat, 25 Aug 2018 19:30:37 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt612.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.theverge.com/2018/8/20/17725226/skype-private-conversation-end-to-end-encrypted-opt-in">Skype зашифровал беседы</a> - <em>00:02:39</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49171">Intel запретил публиковать результаты тестирования</a> - <em>00:19:58</em>.</li>
+				<li><a href="https://www.reddit.com/r/EnoughMuskSpam/comments/99sbwa/former_tesla_programmers_anecdotes_about_problems/">Бывший работникTesla разговорился</a> - <em>00:28:14</em>.</li>
+				<li><a href="https://venturebeat.com/2018/08/23/you-can-now-download-windows-95-as-an-app-for-mac-windows-and-linux/">Windows 95 для любой OS</a> - <em>00:43:58</em>.</li>
+				<li><a href="https://thenextweb.com/plugged/2018/08/23/logitechs-mx-vertical-is-the-ergonomic-mouse-i-didnt-know-i-wanted/">Logitech’s MX Vertical</a> - <em>00:50:34</em>.</li>
+				<li><a href="https://tech.co/google-pixel-3-xl-leaks-rumors-2018-08">Google Pixel 3 XL</a> - <em>01:00:00</em>.</li>
+				<li><a href="https://www.zdnet.com/pictures/microsoft-surface-go-first-impressions/">Microsoft Surface Go</a> - <em>01:10:37</em>.</li>
+				<li><a href="https://www.independent.co.uk/life-style/gadgets-and-tech/news/google-chrome-incognito-mode-personal-data-private-browser-a8502386.html">Google Chrome и не очень приватный режим</a> - <em>01:18:28</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast612.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-612.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast612.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt612.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast612.mp3" type="audio/mp3" length="73837368" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast612.mp3" fileSize="73837368" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 611
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt611.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.theverge.com/2018/8/17/17715166/google-location-tracking-history-weather-maps">Google признается в страшном</a> - <em>00:01:43</em>.</li>
+				<li><a href="https://www.engadget.com/2018/08/17/how-google-location-tracking-issue-affects-you/">Чем эта слежка нам грозит</a> - <em>00:10:53</em>.</li>
+				<li><a href="https://www.cultofmac.com/570095/google-makes-big-improvements-to-gmail-google-voice-on-ios/">Улучшения в Gmail и Google Voice для iOS</a> - <em>00:28:13</em>.</li>
+				<li><a href="https://techcrunch.com/2018/08/17/the-automatica-automates-pour-over-coffee-in-a-charming-and-totally-unnecessary-way/">Стимппанк от Automatica</a> - <em>00:35:17</em>.</li>
+				<li><a href="https://github.com/Microsoft/FASTER">FASTER - встраиваемый KV от MS</a> - <em>00:50:22</em>.</li>
+				<li><a href="https://zeit.co/blog/serverless-docker">Serverless Docker</a> - <em>00:59:15</em>.</li>
+				<li><a href="https://www.engadget.com/2018/08/16/asus-zenbook-pro-15-review/">ZenBook Pro 15</a> - <em>01:20:13</em>.</li>
+				<li><a href="https://www.theverge.com/circuitbreaker/2018/8/17/17723464/amazon-live-tv-recording-fire-tv-device">Amazon выходит на рынок DVR</a> - <em>01:30:05</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast611.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-611.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast611.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/08/18/podcast-611/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/08/18//podcast-611/
+			</guid>
+			<pubDate>
+				Sat, 18 Aug 2018 18:09:02 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt611.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://www.theverge.com/2018/8/17/17715166/google-location-tracking-history-weather-maps">Google признается в страшном</a> - <em>00:01:43</em>.</li>
+				<li><a href="https://www.engadget.com/2018/08/17/how-google-location-tracking-issue-affects-you/">Чем эта слежка нам грозит</a> - <em>00:10:53</em>.</li>
+				<li><a href="https://www.cultofmac.com/570095/google-makes-big-improvements-to-gmail-google-voice-on-ios/">Улучшения в Gmail и Google Voice для iOS</a> - <em>00:28:13</em>.</li>
+				<li><a href="https://techcrunch.com/2018/08/17/the-automatica-automates-pour-over-coffee-in-a-charming-and-totally-unnecessary-way/">Стимппанк от Automatica</a> - <em>00:35:17</em>.</li>
+				<li><a href="https://github.com/Microsoft/FASTER">FASTER - встраиваемый KV от MS</a> - <em>00:50:22</em>.</li>
+				<li><a href="https://zeit.co/blog/serverless-docker">Serverless Docker</a> - <em>00:59:15</em>.</li>
+				<li><a href="https://www.engadget.com/2018/08/16/asus-zenbook-pro-15-review/">ZenBook Pro 15</a> - <em>01:20:13</em>.</li>
+				<li><a href="https://www.theverge.com/circuitbreaker/2018/8/17/17723464/amazon-live-tv-recording-fire-tv-device">Amazon выходит на рынок DVR</a> - <em>01:30:05</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast611.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-611.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast611.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt611.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast611.mp3" type="audio/mp3" length="89446054" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast611.mp3" fileSize="89446054" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 610
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt610.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://thenextweb.com/security/2018/08/07/hacker-swipes-snapchats-source-code-publishes-it-on-github/">Утечка Snapchat</a> - <em>00:02:24</em>.</li>
+				<li><a href="https://sdtimes.com/cloud/amazon-aurora-serverless-now-generally-available/">Amazon Aurora Serverless</a> - <em>00:19:28</em>.</li>
+				<li><a href="https://techcrunch.com/2018/08/07/microsoft-decides-to-support-skype-classic-for-some-time-after-users-revolt/">Microsoft отступила в Skype Classic</a> - <em>00:33:18</em>.</li>
+				<li><a href="https://letsencrypt.org/2018/08/06/trusted-by-all-major-root-programs.html">Let's Encrypt теперь доверяют все</a> - <em>00:43:40</em>.</li>
+				<li><a href="https://githubengineering.com/glb-director-open-source-load-balancer/">GLB от GitHub</a> - <em>01:05:37</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49096">Выпуск Dart 2.0</a> - <em>01:12:31</em>.</li>
+				<li><a href="https://mashable.com/2018/08/06/facebook-financial-info-banks/">Facebook охотится на банки</a> - <em>01:23:58</em>.</li>
+				<li><a href="https://torrentfreak.com/the-pirate-bay-turns-15-years-old-180810/">The Pirate Bay Turns 15 Years Old - TorrentFreak</a> - <em>01:32:20</em>.</li>
+				<li><a href="https://julialang.org/blog/2018/08/one-point-zero">Julia 1.0</a> - <em>01:40:59</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast610.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-610.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast610.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/08/11/podcast-610/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/08/11//podcast-610/
+			</guid>
+			<pubDate>
+				Sat, 11 Aug 2018 18:37:37 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt610.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://thenextweb.com/security/2018/08/07/hacker-swipes-snapchats-source-code-publishes-it-on-github/">Утечка Snapchat</a> - <em>00:02:24</em>.</li>
+				<li><a href="https://sdtimes.com/cloud/amazon-aurora-serverless-now-generally-available/">Amazon Aurora Serverless</a> - <em>00:19:28</em>.</li>
+				<li><a href="https://techcrunch.com/2018/08/07/microsoft-decides-to-support-skype-classic-for-some-time-after-users-revolt/">Microsoft отступила в Skype Classic</a> - <em>00:33:18</em>.</li>
+				<li><a href="https://letsencrypt.org/2018/08/06/trusted-by-all-major-root-programs.html">Let's Encrypt теперь доверяют все</a> - <em>00:43:40</em>.</li>
+				<li><a href="https://githubengineering.com/glb-director-open-source-load-balancer/">GLB от GitHub</a> - <em>01:05:37</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=49096">Выпуск Dart 2.0</a> - <em>01:12:31</em>.</li>
+				<li><a href="https://mashable.com/2018/08/06/facebook-financial-info-banks/">Facebook охотится на банки</a> - <em>01:23:58</em>.</li>
+				<li><a href="https://torrentfreak.com/the-pirate-bay-turns-15-years-old-180810/">The Pirate Bay Turns 15 Years Old - TorrentFreak</a> - <em>01:32:20</em>.</li>
+				<li><a href="https://julialang.org/blog/2018/08/one-point-zero">Julia 1.0</a> - <em>01:40:59</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast610.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-610.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast610.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt610.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast610.mp3" type="audio/mp3" length="90769679" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast610.mp3" fileSize="90769679" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 609
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt609.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://dev.to/powerwebdev/an-introduction-to-the-concept-of-design-patterns-o29">Разговоры про шаблоны проектирования</a> - <em>00:10:33</em>.</li>
+				<li><a href="https://brandur.org/interfaces">Чему нас учит терминал</a> - <em>00:33:59</em>.</li>
+				<li><a href="https://sdtimes.com/java/apache-netbeans-incubating-9-0-is-now-available/">Apache NetBeans 9.0</a> - <em>00:53:50</em>.</li>
+				<li><a href="https://blog.plaid.com/building-an-inclusive-code-review-culture/">Проводим Code Review культурно</a> - <em>01:04:25</em>.</li>
+				<li><a href="https://github.com/grumpyhome/grumpy/blob/master/README.md">Grumpy - из питона в Go </a> - <em>01:41:04</em>.</li>
+				<li><a href="https://blog.golang.org/go-cloud">Go Cloud</a> - <em>01:45:06</em>.</li>
+				<li><a href="https://blog.bitbucket.org/2018/07/19/13-new-bitbucket-cloud-features/">Обновления Bitbucket</a> - <em>01:49:40</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast609.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-609.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast609.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/08/04/podcast-609/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/08/04//podcast-609/
+			</guid>
+			<pubDate>
+				Sat, 04 Aug 2018 19:14:14 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt609.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://dev.to/powerwebdev/an-introduction-to-the-concept-of-design-patterns-o29">Разговоры про шаблоны проектирования</a> - <em>00:10:33</em>.</li>
+				<li><a href="https://brandur.org/interfaces">Чему нас учит терминал</a> - <em>00:33:59</em>.</li>
+				<li><a href="https://sdtimes.com/java/apache-netbeans-incubating-9-0-is-now-available/">Apache NetBeans 9.0</a> - <em>00:53:50</em>.</li>
+				<li><a href="https://blog.plaid.com/building-an-inclusive-code-review-culture/">Проводим Code Review культурно</a> - <em>01:04:25</em>.</li>
+				<li><a href="https://github.com/grumpyhome/grumpy/blob/master/README.md">Grumpy - из питона в Go </a> - <em>01:41:04</em>.</li>
+				<li><a href="https://blog.golang.org/go-cloud">Go Cloud</a> - <em>01:45:06</em>.</li>
+				<li><a href="https://blog.bitbucket.org/2018/07/19/13-new-bitbucket-cloud-features/">Обновления Bitbucket</a> - <em>01:49:40</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast609.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-609.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast609.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt609.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast609.mp3" type="audio/mp3" length="103882887" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast609.mp3" fileSize="103882887" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 608
+			</title>
+			<description>
+				<![CDATA[
+				<ul>
+				<li><a href="https://www.atlassian.com/blog/announcements/new-atlassian-slack-partnership">Конец hipcat</a></li>
+				<li><a href="https://zapier.com/blog/slack-versus-hipchat/">Почему Slack захватил рынок</a>.</li>
+				<li><a href="https://telegram.org/blog/passport">Telegram Passport</a>.</li>
+				<li><a href="https://www.digitaltrends.com/computing/google-titan-security-key-eliminates-passwords/">USB токен от Google</a>.</li>
+				<li><a href="https://www.scientificamerican.com/article/23andme-is-sharing-genetic-data-with-drug-giant/">23andMe нашел себе подозрительных друзей</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast608.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-608.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast608.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/07/28/podcast-608/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/07/28//podcast-608/
+			</guid>
+			<pubDate>
+				Sat, 28 Jul 2018 22:30:06 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<ul>
+				<li><a href="https://www.atlassian.com/blog/announcements/new-atlassian-slack-partnership">Конец hipcat</a></li>
+				<li><a href="https://zapier.com/blog/slack-versus-hipchat/">Почему Slack захватил рынок</a>.</li>
+				<li><a href="https://telegram.org/blog/passport">Telegram Passport</a>.</li>
+				<li><a href="https://www.digitaltrends.com/computing/google-titan-security-key-eliminates-passwords/">USB токен от Google</a>.</li>
+				<li><a href="https://www.scientificamerican.com/article/23andme-is-sharing-genetic-data-with-drug-giant/">23andMe нашел себе подозрительных друзей</a>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast608.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-608.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast608.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt608.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast608.mp3" type="audio/mp3" length="114907584" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast608.mp3" fileSize="114907584" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 607
+			</title>
+			<description>
+				<![CDATA[
+				<ul>
+				<li><a href="https://www.inverse.com/article/47169-macbook-pro-2018-throttling-complaints-reddit">MacBook Pro 2018 неприятно удивил</a></li>
+				<li><a href="http://europa.eu/rapid/press-release_IP-18-4581_en.htm">Google наказывают за все хорошее</a></li>
+				<li><a href="https://mashable.com/2018/07/18/corning-gorilla-glass-6-textured-glass/">Телефон из стекла</a></li>
+				<li><a href="https://quariety.com/2018/07/20/peertube-the-decentralized-youtube-succeeds-in-crowdfunding/">PeerTube собрал немного денег на убийство YouTube</a></li>
+				<li><a href="https://thenextweb.com/insider/2018/07/18/walmart-is-reportedly-building-a-video-streaming-service-to-take-on-netflix/">Walmart тоже хочет быть видео сервисом</a></li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast607.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-607.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast607.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/07/22/podcast-607/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/07/22//podcast-607/
+			</guid>
+			<pubDate>
+				Sun, 22 Jul 2018 00:09:00 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<ul>
+				<li><a href="https://www.inverse.com/article/47169-macbook-pro-2018-throttling-complaints-reddit">MacBook Pro 2018 неприятно удивил</a></li>
+				<li><a href="http://europa.eu/rapid/press-release_IP-18-4581_en.htm">Google наказывают за все хорошее</a></li>
+				<li><a href="https://mashable.com/2018/07/18/corning-gorilla-glass-6-textured-glass/">Телефон из стекла</a></li>
+				<li><a href="https://quariety.com/2018/07/20/peertube-the-decentralized-youtube-succeeds-in-crowdfunding/">PeerTube собрал немного денег на убийство YouTube</a></li>
+				<li><a href="https://thenextweb.com/insider/2018/07/18/walmart-is-reportedly-building-a-video-streaming-service-to-take-on-netflix/">Walmart тоже хочет быть видео сервисом</a></li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast607.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-607.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast607.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt607.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast607.mp3" type="audio/mp3" length="66279099" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast607.mp3" fileSize="66279099" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<item>
+			<title>
+				Радио-Т 606
+			</title>
+			<description>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt606.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/07/12/apples-macbook-refresh-puts-the-focus-back-on-creative-pros/">Новые MacBook Pro не совсем то, что ожидали</a> - <em>00:02:16</em>.</li>
+				<li><a href="https://www.theverge.com/2018/7/12/17563640/apple-macbook-pro-touchbar-2018-intel-processor-siri-true-tone">Клавиатура тише, процессоры шустрее</a> - <em>00:18:33</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=48959">Гвидо ван Россум решил отстраниться</a> - <em>00:30:22</em>.</li>
+				<li><a href="https://techcrunch.com/2018/07/12/microsoft-teams-gets-a-free-version/">Microsoft Teams доступна в бесплатном варианте</a> - <em>01:00:06</em>.</li>
+				<li><a href="https://gizmodo.com/twitter-is-suspending-more-than-one-million-accounts-pe-1827409235">Twitter удалят спамеров миллионами</a> - <em>01:07:58</em>.</li>
+				<li><a href="https://blog.ubuntu.com/2018/07/09/minimal-ubuntu-released">Minimal Ubuntu не так уж и мала</a> - <em>01:19:27</em>.</li>
+				<li><a href="https://bgr.com/2018/07/10/apple-1password-acquisition-deal/amp/">Apple купил много 1Password, но может купить еще больше</a> - <em>01:30:16</em>.</li>
+				<li><a href="http://blog.memsql.com/nosql/">Прощай, NoSQL. Твое время ушло</a> - <em>01:36:56</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast606.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-606.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast606.mp3" preload="none"></audio>
+				]]>
+			</description>
+			<link>
+				https://radio-t.com/p/2018/07/14/podcast-606/
+			</link>
+			<guid>
+				https://radio-t.com/p/2018/07/14//podcast-606/
+			</guid>
+			<pubDate>
+				Sat, 14 Jul 2018 18:17:43 EST
+			</pubDate>
+			<author>
+				Umputun, Bobuk, Gray, Ksenks
+			</author>
+			<summary>
+				<![CDATA[
+				<p><img src="https://radio-t.com/images/radio-t/rt606.jpg" alt=""></p>
+				<ul>
+				<li><a href="https://techcrunch.com/2018/07/12/apples-macbook-refresh-puts-the-focus-back-on-creative-pros/">Новые MacBook Pro не совсем то, что ожидали</a> - <em>00:02:16</em>.</li>
+				<li><a href="https://www.theverge.com/2018/7/12/17563640/apple-macbook-pro-touchbar-2018-intel-processor-siri-true-tone">Клавиатура тише, процессоры шустрее</a> - <em>00:18:33</em>.</li>
+				<li><a href="http://www.opennet.ru/opennews/art.shtml?num=48959">Гвидо ван Россум решил отстраниться</a> - <em>00:30:22</em>.</li>
+				<li><a href="https://techcrunch.com/2018/07/12/microsoft-teams-gets-a-free-version/">Microsoft Teams доступна в бесплатном варианте</a> - <em>01:00:06</em>.</li>
+				<li><a href="https://gizmodo.com/twitter-is-suspending-more-than-one-million-accounts-pe-1827409235">Twitter удалят спамеров миллионами</a> - <em>01:07:58</em>.</li>
+				<li><a href="https://blog.ubuntu.com/2018/07/09/minimal-ubuntu-released">Minimal Ubuntu не так уж и мала</a> - <em>01:19:27</em>.</li>
+				<li><a href="https://bgr.com/2018/07/10/apple-1password-acquisition-deal/amp/">Apple купил много 1Password, но может купить еще больше</a> - <em>01:30:16</em>.</li>
+				<li><a href="http://blog.memsql.com/nosql/">Прощай, NoSQL. Твое время ушло</a> - <em>01:36:56</em>.</li>
+				<li>Темы наших слушателей</li>
+				</ul>
+				<p><em>Спонсор этого выпуска <a href="https://www.digitalocean.com">DigitalOcean</a></em></p>
+				<p><a href="http://cdn.radio-t.com/rt_podcast606.mp3">аудио</a> ● <a href="http://chat.radio-t.com/logs/radio-t-606.html">лог чата</a></p>
+				<audio src="http://cdn.radio-t.com/rt_podcast606.mp3" preload="none"></audio>
+				]]>
+			</summary>
+			<image href="https://radio-t.com/images/radio-t/rt606.jpg" />
+			<enclosure url="http://cdn.radio-t.com/rt_podcast606.mp3" type="audio/mp3" length="88343426" />
+			<author>
+				podcast@radio-t.com (Umputun, Bobuk, Gray, Ksenks)
+			</author>
+			<content url="http://cdn.radio-t.com/rt_podcast606.mp3" fileSize="88343426" type="audio/mp3" />
+			<explicit>
+				no
+			</explicit>
+			<subtitle>
+				Подкаст выходного дня - импровизации на темы высоких технологий
+			</subtitle>
+			<keywords>
+				hitech,russian,radiot,tech,news,радио
+			</keywords>
+		</item>
+		<credit role="author">
+			Umputun, Bobuk, Gray, Ksenks
+		</credit>
+		<rating>
+			nonadult
+		</rating>
+		<description type="plain">
+			Еженедельные импровизации на хай–тек темы
+		</description>
+	</channel>
+</rss>

+ 1 - 0
vendor/github.com/PuerkitoBio/goquery/.gitattributes

@@ -0,0 +1 @@
+testdata/* linguist-vendored

+ 16 - 0
vendor/github.com/PuerkitoBio/goquery/.gitignore

@@ -0,0 +1,16 @@
+# editor temporary files
+*.sublime-*
+.DS_Store
+*.swp
+#*.*#
+tags
+
+# direnv config
+.env*
+
+# test binaries
+*.test
+
+# coverage and profilte outputs
+*.out
+

+ 16 - 0
vendor/github.com/PuerkitoBio/goquery/.travis.yml

@@ -0,0 +1,16 @@
+language: go
+
+go:
+    - 1.1
+    - 1.2.x
+    - 1.3.x
+    - 1.4.x
+    - 1.5.x
+    - 1.6.x
+    - 1.7.x
+    - 1.8.x
+    - 1.9.x
+    - "1.10.x"
+    - 1.11.x
+    - tip
+

+ 12 - 0
vendor/github.com/PuerkitoBio/goquery/LICENSE

@@ -0,0 +1,12 @@
+Copyright (c) 2012-2016, Martin Angers & Contributors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+* Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 179 - 0
vendor/github.com/PuerkitoBio/goquery/README.md

@@ -0,0 +1,179 @@
+# goquery - a little like that j-thing, only in Go
+[![build status](https://secure.travis-ci.org/PuerkitoBio/goquery.svg?branch=master)](http://travis-ci.org/PuerkitoBio/goquery) [![GoDoc](https://godoc.org/github.com/PuerkitoBio/goquery?status.png)](http://godoc.org/github.com/PuerkitoBio/goquery) [![Sourcegraph Badge](https://sourcegraph.com/github.com/PuerkitoBio/goquery/-/badge.svg)](https://sourcegraph.com/github.com/PuerkitoBio/goquery?badge)
+
+goquery brings a syntax and a set of features similar to [jQuery][] to the [Go language][go]. It is based on Go's [net/html package][html] and the CSS Selector library [cascadia][]. Since the net/html parser returns nodes, and not a full-featured DOM tree, jQuery's stateful manipulation functions (like height(), css(), detach()) have been left off.
+
+Also, because the net/html parser requires UTF-8 encoding, so does goquery: it is the caller's responsibility to ensure that the source document provides UTF-8 encoded HTML. See the [wiki][] for various options to do this.
+
+Syntax-wise, it is as close as possible to jQuery, with the same function names when possible, and that warm and fuzzy chainable interface. jQuery being the ultra-popular library that it is, I felt that writing a similar HTML-manipulating library was better to follow its API than to start anew (in the same spirit as Go's `fmt` package), even though some of its methods are less than intuitive (looking at you, [index()][index]...).
+
+## Table of Contents
+
+* [Installation](#installation)
+* [Changelog](#changelog)
+* [API](#api)
+* [Examples](#examples)
+* [Related Projects](#related-projects)
+* [Support](#support)
+* [License](#license)
+
+## Installation
+
+Please note that because of the net/html dependency, goquery requires Go1.1+.
+
+    $ go get github.com/PuerkitoBio/goquery
+
+(optional) To run unit tests:
+
+    $ cd $GOPATH/src/github.com/PuerkitoBio/goquery
+    $ go test
+
+(optional) To run benchmarks (warning: it runs for a few minutes):
+
+    $ cd $GOPATH/src/github.com/PuerkitoBio/goquery
+    $ go test -bench=".*"
+
+## Changelog
+
+**Note that goquery's API is now stable, and will not break.**
+
+*    **2018-11-15 (v1.5.0)** : Go module support (thanks @Zaba505).
+*    **2018-06-07 (v1.4.1)** : Add `NewDocumentFromReader` examples.
+*    **2018-03-24 (v1.4.0)** : Deprecate `NewDocument(url)` and `NewDocumentFromResponse(response)`.
+*    **2018-01-28 (v1.3.0)** : Add `ToEnd` constant to `Slice` until the end of the selection (thanks to @davidjwilkins for raising the issue).
+*    **2018-01-11 (v1.2.0)** : Add `AddBack*` and deprecate `AndSelf` (thanks to @davidjwilkins).
+*    **2017-02-12 (v1.1.0)** : Add `SetHtml` and `SetText` (thanks to @glebtv).
+*    **2016-12-29 (v1.0.2)** : Optimize allocations for `Selection.Text` (thanks to @radovskyb).
+*    **2016-08-28 (v1.0.1)** : Optimize performance for large documents.
+*    **2016-07-27 (v1.0.0)** : Tag version 1.0.0.
+*    **2016-06-15** : Invalid selector strings internally compile to a `Matcher` implementation that never matches any node (instead of a panic). So for example, `doc.Find("~")` returns an empty `*Selection` object.
+*    **2016-02-02** : Add `NodeName` utility function similar to the DOM's `nodeName` property. It returns the tag name of the first element in a selection, and other relevant values of non-element nodes (see godoc for details). Add `OuterHtml` utility function similar to the DOM's `outerHTML` property (named `OuterHtml` in small caps for consistency with the existing `Html` method on the `Selection`).
+*    **2015-04-20** : Add `AttrOr` helper method to return the attribute's value or a default value if absent. Thanks to [piotrkowalczuk][piotr].
+*    **2015-02-04** : Add more manipulation functions - Prepend* - thanks again to [Andrew Stone][thatguystone].
+*    **2014-11-28** : Add more manipulation functions - ReplaceWith*, Wrap* and Unwrap - thanks again to [Andrew Stone][thatguystone].
+*    **2014-11-07** : Add manipulation functions (thanks to [Andrew Stone][thatguystone]) and `*Matcher` functions, that receive compiled cascadia selectors instead of selector strings, thus avoiding potential panics thrown by goquery via `cascadia.MustCompile` calls. This results in better performance (selectors can be compiled once and reused) and more idiomatic error handling (you can handle cascadia's compilation errors, instead of recovering from panics, which had been bugging me for a long time). Note that the actual type expected is a `Matcher` interface, that `cascadia.Selector` implements. Other matcher implementations could be used.
+*    **2014-11-06** : Change import paths of net/html to golang.org/x/net/html (see https://groups.google.com/forum/#!topic/golang-nuts/eD8dh3T9yyA). Make sure to update your code to use the new import path too when you call goquery with `html.Node`s.
+*    **v0.3.2** : Add `NewDocumentFromReader()` (thanks jweir) which allows creating a goquery document from an io.Reader.
+*    **v0.3.1** : Add `NewDocumentFromResponse()` (thanks assassingj) which allows creating a goquery document from an http response.
+*    **v0.3.0** : Add `EachWithBreak()` which allows to break out of an `Each()` loop by returning false. This function was added instead of changing the existing `Each()` to avoid breaking compatibility.
+*    **v0.2.1** : Make go-getable, now that [go.net/html is Go1.0-compatible][gonet] (thanks to @matrixik for pointing this out).
+*    **v0.2.0** : Add support for negative indices in Slice(). **BREAKING CHANGE** `Document.Root` is removed, `Document` is now a `Selection` itself (a selection of one, the root element, just like `Document.Root` was before). Add jQuery's Closest() method.
+*    **v0.1.1** : Add benchmarks to use as baseline for refactorings, refactor Next...() and Prev...() methods to use the new html package's linked list features (Next/PrevSibling, FirstChild). Good performance boost (40+% in some cases).
+*    **v0.1.0** : Initial release.
+
+## API
+
+goquery exposes two structs, `Document` and `Selection`, and the `Matcher` interface. Unlike jQuery, which is loaded as part of a DOM document, and thus acts on its containing document, goquery doesn't know which HTML document to act upon. So it needs to be told, and that's what the `Document` type is for. It holds the root document node as the initial Selection value to manipulate.
+
+jQuery often has many variants for the same function (no argument, a selector string argument, a jQuery object argument, a DOM element argument, ...). Instead of exposing the same features in goquery as a single method with variadic empty interface arguments, statically-typed signatures are used following this naming convention:
+
+*    When the jQuery equivalent can be called with no argument, it has the same name as jQuery for the no argument signature (e.g.: `Prev()`), and the version with a selector string argument is called `XxxFiltered()` (e.g.: `PrevFiltered()`)
+*    When the jQuery equivalent **requires** one argument, the same name as jQuery is used for the selector string version (e.g.: `Is()`)
+*    The signatures accepting a jQuery object as argument are defined in goquery as `XxxSelection()` and take a `*Selection` object as argument (e.g.: `FilterSelection()`)
+*    The signatures accepting a DOM element as argument in jQuery are defined in goquery as `XxxNodes()` and take a variadic argument of type `*html.Node` (e.g.: `FilterNodes()`)
+*    The signatures accepting a function as argument in jQuery are defined in goquery as `XxxFunction()` and take a function as argument (e.g.: `FilterFunction()`)
+*    The goquery methods that can be called with a selector string have a corresponding version that take a `Matcher` interface and are defined as `XxxMatcher()` (e.g.: `IsMatcher()`)
+
+Utility functions that are not in jQuery but are useful in Go are implemented as functions (that take a `*Selection` as parameter), to avoid a potential naming clash on the `*Selection`'s methods (reserved for jQuery-equivalent behaviour).
+
+The complete [godoc reference documentation can be found here][doc].
+
+Please note that Cascadia's selectors do not necessarily match all supported selectors of jQuery (Sizzle). See the [cascadia project][cascadia] for details. Invalid selector strings compile to a `Matcher` that fails to match any node. Behaviour of the various functions that take a selector string as argument follows from that fact, e.g. (where `~` is an invalid selector string):
+
+* `Find("~")` returns an empty selection because the selector string doesn't match anything.
+* `Add("~")` returns a new selection that holds the same nodes as the original selection, because it didn't add any node (selector string didn't match anything).
+* `ParentsFiltered("~")` returns an empty selection because the selector string doesn't match anything.
+* `ParentsUntil("~")` returns all parents of the selection because the selector string didn't match any element to stop before the top element.
+
+## Examples
+
+See some tips and tricks in the [wiki][].
+
+Adapted from example_test.go:
+
+```Go
+package main
+
+import (
+  "fmt"
+  "log"
+  "net/http"
+
+  "github.com/PuerkitoBio/goquery"
+)
+
+func ExampleScrape() {
+  // Request the HTML page.
+  res, err := http.Get("http://metalsucks.net")
+  if err != nil {
+    log.Fatal(err)
+  }
+  defer res.Body.Close()
+  if res.StatusCode != 200 {
+    log.Fatalf("status code error: %d %s", res.StatusCode, res.Status)
+  }
+
+  // Load the HTML document
+  doc, err := goquery.NewDocumentFromReader(res.Body)
+  if err != nil {
+    log.Fatal(err)
+  }
+
+  // Find the review items
+  doc.Find(".sidebar-reviews article .content-block").Each(func(i int, s *goquery.Selection) {
+    // For each item found, get the band and title
+    band := s.Find("a").Text()
+    title := s.Find("i").Text()
+    fmt.Printf("Review %d: %s - %s\n", i, band, title)
+  })
+}
+
+func main() {
+  ExampleScrape()
+}
+```
+
+## Related Projects
+
+- [Goq][goq], an HTML deserialization and scraping library based on goquery and struct tags.
+- [andybalholm/cascadia][cascadia], the CSS selector library used by goquery.
+- [suntong/cascadia][cascadiacli], a command-line interface to the cascadia CSS selector library, useful to test selectors.
+- [asciimoo/colly](https://github.com/asciimoo/colly), a lightning fast and elegant Scraping Framework
+- [gnulnx/goperf](https://github.com/gnulnx/goperf), a website performance test tool that also fetches static assets.
+- [MontFerret/ferret](https://github.com/MontFerret/ferret), declarative web scraping.
+
+## Support
+
+There are a number of ways you can support the project:
+
+* Use it, star it, build something with it, spread the word!
+  - If you do build something open-source or otherwise publicly-visible, let me know so I can add it to the [Related Projects](#related-projects) section!
+* Raise issues to improve the project (note: doc typos and clarifications are issues too!)
+  - Please search existing issues before opening a new one - it may have already been adressed.
+* Pull requests: please discuss new code in an issue first, unless the fix is really trivial.
+  - Make sure new code is tested.
+  - Be mindful of existing code - PRs that break existing code have a high probability of being declined, unless it fixes a serious issue.
+
+If you desperately want to send money my way, I have a BuyMeACoffee.com page:
+
+<a href="https://www.buymeacoffee.com/mna" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
+
+## License
+
+The [BSD 3-Clause license][bsd], the same as the [Go language][golic]. Cascadia's license is [here][caslic].
+
+[jquery]: http://jquery.com/
+[go]: http://golang.org/
+[cascadia]: https://github.com/andybalholm/cascadia
+[cascadiacli]: https://github.com/suntong/cascadia
+[bsd]: http://opensource.org/licenses/BSD-3-Clause
+[golic]: http://golang.org/LICENSE
+[caslic]: https://github.com/andybalholm/cascadia/blob/master/LICENSE
+[doc]: http://godoc.org/github.com/PuerkitoBio/goquery
+[index]: http://api.jquery.com/index/
+[gonet]: https://github.com/golang/net/
+[html]: http://godoc.org/golang.org/x/net/html
+[wiki]: https://github.com/PuerkitoBio/goquery/wiki/Tips-and-tricks
+[thatguystone]: https://github.com/thatguystone
+[piotr]: https://github.com/piotrkowalczuk
+[goq]: https://github.com/andrewstuart/goq

+ 124 - 0
vendor/github.com/PuerkitoBio/goquery/array.go

@@ -0,0 +1,124 @@
+package goquery
+
+import (
+	"golang.org/x/net/html"
+)
+
+const (
+	maxUint = ^uint(0)
+	maxInt  = int(maxUint >> 1)
+
+	// ToEnd is a special index value that can be used as end index in a call
+	// to Slice so that all elements are selected until the end of the Selection.
+	// It is equivalent to passing (*Selection).Length().
+	ToEnd = maxInt
+)
+
+// First reduces the set of matched elements to the first in the set.
+// It returns a new Selection object, and an empty Selection object if the
+// the selection is empty.
+func (s *Selection) First() *Selection {
+	return s.Eq(0)
+}
+
+// Last reduces the set of matched elements to the last in the set.
+// It returns a new Selection object, and an empty Selection object if
+// the selection is empty.
+func (s *Selection) Last() *Selection {
+	return s.Eq(-1)
+}
+
+// Eq reduces the set of matched elements to the one at the specified index.
+// If a negative index is given, it counts backwards starting at the end of the
+// set. It returns a new Selection object, and an empty Selection object if the
+// index is invalid.
+func (s *Selection) Eq(index int) *Selection {
+	if index < 0 {
+		index += len(s.Nodes)
+	}
+
+	if index >= len(s.Nodes) || index < 0 {
+		return newEmptySelection(s.document)
+	}
+
+	return s.Slice(index, index+1)
+}
+
+// Slice reduces the set of matched elements to a subset specified by a range
+// of indices. The start index is 0-based and indicates the index of the first
+// element to select. The end index is 0-based and indicates the index at which
+// the elements stop being selected (the end index is not selected).
+//
+// The indices may be negative, in which case they represent an offset from the
+// end of the selection.
+//
+// The special value ToEnd may be specified as end index, in which case all elements
+// until the end are selected. This works both for a positive and negative start
+// index.
+func (s *Selection) Slice(start, end int) *Selection {
+	if start < 0 {
+		start += len(s.Nodes)
+	}
+	if end == ToEnd {
+		end = len(s.Nodes)
+	} else if end < 0 {
+		end += len(s.Nodes)
+	}
+	return pushStack(s, s.Nodes[start:end])
+}
+
+// Get retrieves the underlying node at the specified index.
+// Get without parameter is not implemented, since the node array is available
+// on the Selection object.
+func (s *Selection) Get(index int) *html.Node {
+	if index < 0 {
+		index += len(s.Nodes) // Negative index gets from the end
+	}
+	return s.Nodes[index]
+}
+
+// Index returns the position of the first element within the Selection object
+// relative to its sibling elements.
+func (s *Selection) Index() int {
+	if len(s.Nodes) > 0 {
+		return newSingleSelection(s.Nodes[0], s.document).PrevAll().Length()
+	}
+	return -1
+}
+
+// IndexSelector returns the position of the first element within the
+// Selection object relative to the elements matched by the selector, or -1 if
+// not found.
+func (s *Selection) IndexSelector(selector string) int {
+	if len(s.Nodes) > 0 {
+		sel := s.document.Find(selector)
+		return indexInSlice(sel.Nodes, s.Nodes[0])
+	}
+	return -1
+}
+
+// IndexMatcher returns the position of the first element within the
+// Selection object relative to the elements matched by the matcher, or -1 if
+// not found.
+func (s *Selection) IndexMatcher(m Matcher) int {
+	if len(s.Nodes) > 0 {
+		sel := s.document.FindMatcher(m)
+		return indexInSlice(sel.Nodes, s.Nodes[0])
+	}
+	return -1
+}
+
+// IndexOfNode returns the position of the specified node within the Selection
+// object, or -1 if not found.
+func (s *Selection) IndexOfNode(node *html.Node) int {
+	return indexInSlice(s.Nodes, node)
+}
+
+// IndexOfSelection returns the position of the first node in the specified
+// Selection object within this Selection object, or -1 if not found.
+func (s *Selection) IndexOfSelection(sel *Selection) int {
+	if sel != nil && len(sel.Nodes) > 0 {
+		return indexInSlice(s.Nodes, sel.Nodes[0])
+	}
+	return -1
+}

+ 123 - 0
vendor/github.com/PuerkitoBio/goquery/doc.go

@@ -0,0 +1,123 @@
+// Copyright (c) 2012-2016, Martin Angers & Contributors
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without modification,
+// are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright notice,
+// this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation and/or
+// other materials provided with the distribution.
+// * Neither the name of the author nor the names of its contributors may be used to
+// endorse or promote products derived from this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
+// OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
+// WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+/*
+Package goquery implements features similar to jQuery, including the chainable
+syntax, to manipulate and query an HTML document.
+
+It brings a syntax and a set of features similar to jQuery to the Go language.
+It is based on Go's net/html package and the CSS Selector library cascadia.
+Since the net/html parser returns nodes, and not a full-featured DOM
+tree, jQuery's stateful manipulation functions (like height(), css(), detach())
+have been left off.
+
+Also, because the net/html parser requires UTF-8 encoding, so does goquery: it is
+the caller's responsibility to ensure that the source document provides UTF-8 encoded HTML.
+See the repository's wiki for various options on how to do this.
+
+Syntax-wise, it is as close as possible to jQuery, with the same method names when
+possible, and that warm and fuzzy chainable interface. jQuery being the
+ultra-popular library that it is, writing a similar HTML-manipulating
+library was better to follow its API than to start anew (in the same spirit as
+Go's fmt package), even though some of its methods are less than intuitive (looking
+at you, index()...).
+
+It is hosted on GitHub, along with additional documentation in the README.md
+file: https://github.com/puerkitobio/goquery
+
+Please note that because of the net/html dependency, goquery requires Go1.1+.
+
+The various methods are split into files based on the category of behavior.
+The three dots (...) indicate that various "overloads" are available.
+
+* array.go : array-like positional manipulation of the selection.
+    - Eq()
+    - First()
+    - Get()
+    - Index...()
+    - Last()
+    - Slice()
+
+* expand.go : methods that expand or augment the selection's set.
+    - Add...()
+    - AndSelf()
+    - Union(), which is an alias for AddSelection()
+
+* filter.go : filtering methods, that reduce the selection's set.
+    - End()
+    - Filter...()
+    - Has...()
+    - Intersection(), which is an alias of FilterSelection()
+    - Not...()
+
+* iteration.go : methods to loop over the selection's nodes.
+    - Each()
+    - EachWithBreak()
+    - Map()
+
+* manipulation.go : methods for modifying the document
+    - After...()
+    - Append...()
+    - Before...()
+    - Clone()
+    - Empty()
+    - Prepend...()
+    - Remove...()
+    - ReplaceWith...()
+    - Unwrap()
+    - Wrap...()
+    - WrapAll...()
+    - WrapInner...()
+
+* property.go : methods that inspect and get the node's properties values.
+    - Attr*(), RemoveAttr(), SetAttr()
+    - AddClass(), HasClass(), RemoveClass(), ToggleClass()
+    - Html()
+    - Length()
+    - Size(), which is an alias for Length()
+    - Text()
+
+* query.go : methods that query, or reflect, a node's identity.
+    - Contains()
+    - Is...()
+
+* traversal.go : methods to traverse the HTML document tree.
+    - Children...()
+    - Contents()
+    - Find...()
+    - Next...()
+    - Parent[s]...()
+    - Prev...()
+    - Siblings...()
+
+* type.go : definition of the types exposed by goquery.
+    - Document
+    - Selection
+    - Matcher
+
+* utilities.go : definition of helper functions (and not methods on a *Selection)
+that are not part of jQuery, but are useful to goquery.
+    - NodeName
+    - OuterHtml
+*/
+package goquery

+ 70 - 0
vendor/github.com/PuerkitoBio/goquery/expand.go

@@ -0,0 +1,70 @@
+package goquery
+
+import "golang.org/x/net/html"
+
+// Add adds the selector string's matching nodes to those in the current
+// selection and returns a new Selection object.
+// The selector string is run in the context of the document of the current
+// Selection object.
+func (s *Selection) Add(selector string) *Selection {
+	return s.AddNodes(findWithMatcher([]*html.Node{s.document.rootNode}, compileMatcher(selector))...)
+}
+
+// AddMatcher adds the matcher's matching nodes to those in the current
+// selection and returns a new Selection object.
+// The matcher is run in the context of the document of the current
+// Selection object.
+func (s *Selection) AddMatcher(m Matcher) *Selection {
+	return s.AddNodes(findWithMatcher([]*html.Node{s.document.rootNode}, m)...)
+}
+
+// AddSelection adds the specified Selection object's nodes to those in the
+// current selection and returns a new Selection object.
+func (s *Selection) AddSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return s.AddNodes()
+	}
+	return s.AddNodes(sel.Nodes...)
+}
+
+// Union is an alias for AddSelection.
+func (s *Selection) Union(sel *Selection) *Selection {
+	return s.AddSelection(sel)
+}
+
+// AddNodes adds the specified nodes to those in the
+// current selection and returns a new Selection object.
+func (s *Selection) AddNodes(nodes ...*html.Node) *Selection {
+	return pushStack(s, appendWithoutDuplicates(s.Nodes, nodes, nil))
+}
+
+// AndSelf adds the previous set of elements on the stack to the current set.
+// It returns a new Selection object containing the current Selection combined
+// with the previous one.
+// Deprecated: This function has been deprecated and is now an alias for AddBack().
+func (s *Selection) AndSelf() *Selection {
+	return s.AddBack()
+}
+
+// AddBack adds the previous set of elements on the stack to the current set.
+// It returns a new Selection object containing the current Selection combined
+// with the previous one.
+func (s *Selection) AddBack() *Selection {
+	return s.AddSelection(s.prevSel)
+}
+
+// AddBackFiltered reduces the previous set of elements on the stack to those that
+// match the selector string, and adds them to the current set.
+// It returns a new Selection object containing the current Selection combined
+// with the filtered previous one
+func (s *Selection) AddBackFiltered(selector string) *Selection {
+	return s.AddSelection(s.prevSel.Filter(selector))
+}
+
+// AddBackMatcher reduces the previous set of elements on the stack to those that match
+// the mateher, and adds them to the curernt set.
+// It returns a new Selection object containing the current Selection combined
+// with the filtered previous one
+func (s *Selection) AddBackMatcher(m Matcher) *Selection {
+	return s.AddSelection(s.prevSel.FilterMatcher(m))
+}

+ 163 - 0
vendor/github.com/PuerkitoBio/goquery/filter.go

@@ -0,0 +1,163 @@
+package goquery
+
+import "golang.org/x/net/html"
+
+// Filter reduces the set of matched elements to those that match the selector string.
+// It returns a new Selection object for this subset of matching elements.
+func (s *Selection) Filter(selector string) *Selection {
+	return s.FilterMatcher(compileMatcher(selector))
+}
+
+// FilterMatcher reduces the set of matched elements to those that match
+// the given matcher. It returns a new Selection object for this subset
+// of matching elements.
+func (s *Selection) FilterMatcher(m Matcher) *Selection {
+	return pushStack(s, winnow(s, m, true))
+}
+
+// Not removes elements from the Selection that match the selector string.
+// It returns a new Selection object with the matching elements removed.
+func (s *Selection) Not(selector string) *Selection {
+	return s.NotMatcher(compileMatcher(selector))
+}
+
+// NotMatcher removes elements from the Selection that match the given matcher.
+// It returns a new Selection object with the matching elements removed.
+func (s *Selection) NotMatcher(m Matcher) *Selection {
+	return pushStack(s, winnow(s, m, false))
+}
+
+// FilterFunction reduces the set of matched elements to those that pass the function's test.
+// It returns a new Selection object for this subset of elements.
+func (s *Selection) FilterFunction(f func(int, *Selection) bool) *Selection {
+	return pushStack(s, winnowFunction(s, f, true))
+}
+
+// NotFunction removes elements from the Selection that pass the function's test.
+// It returns a new Selection object with the matching elements removed.
+func (s *Selection) NotFunction(f func(int, *Selection) bool) *Selection {
+	return pushStack(s, winnowFunction(s, f, false))
+}
+
+// FilterNodes reduces the set of matched elements to those that match the specified nodes.
+// It returns a new Selection object for this subset of elements.
+func (s *Selection) FilterNodes(nodes ...*html.Node) *Selection {
+	return pushStack(s, winnowNodes(s, nodes, true))
+}
+
+// NotNodes removes elements from the Selection that match the specified nodes.
+// It returns a new Selection object with the matching elements removed.
+func (s *Selection) NotNodes(nodes ...*html.Node) *Selection {
+	return pushStack(s, winnowNodes(s, nodes, false))
+}
+
+// FilterSelection reduces the set of matched elements to those that match a
+// node in the specified Selection object.
+// It returns a new Selection object for this subset of elements.
+func (s *Selection) FilterSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return pushStack(s, winnowNodes(s, nil, true))
+	}
+	return pushStack(s, winnowNodes(s, sel.Nodes, true))
+}
+
+// NotSelection removes elements from the Selection that match a node in the specified
+// Selection object. It returns a new Selection object with the matching elements removed.
+func (s *Selection) NotSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return pushStack(s, winnowNodes(s, nil, false))
+	}
+	return pushStack(s, winnowNodes(s, sel.Nodes, false))
+}
+
+// Intersection is an alias for FilterSelection.
+func (s *Selection) Intersection(sel *Selection) *Selection {
+	return s.FilterSelection(sel)
+}
+
+// Has reduces the set of matched elements to those that have a descendant
+// that matches the selector.
+// It returns a new Selection object with the matching elements.
+func (s *Selection) Has(selector string) *Selection {
+	return s.HasSelection(s.document.Find(selector))
+}
+
+// HasMatcher reduces the set of matched elements to those that have a descendant
+// that matches the matcher.
+// It returns a new Selection object with the matching elements.
+func (s *Selection) HasMatcher(m Matcher) *Selection {
+	return s.HasSelection(s.document.FindMatcher(m))
+}
+
+// HasNodes reduces the set of matched elements to those that have a
+// descendant that matches one of the nodes.
+// It returns a new Selection object with the matching elements.
+func (s *Selection) HasNodes(nodes ...*html.Node) *Selection {
+	return s.FilterFunction(func(_ int, sel *Selection) bool {
+		// Add all nodes that contain one of the specified nodes
+		for _, n := range nodes {
+			if sel.Contains(n) {
+				return true
+			}
+		}
+		return false
+	})
+}
+
+// HasSelection reduces the set of matched elements to those that have a
+// descendant that matches one of the nodes of the specified Selection object.
+// It returns a new Selection object with the matching elements.
+func (s *Selection) HasSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return s.HasNodes()
+	}
+	return s.HasNodes(sel.Nodes...)
+}
+
+// End ends the most recent filtering operation in the current chain and
+// returns the set of matched elements to its previous state.
+func (s *Selection) End() *Selection {
+	if s.prevSel != nil {
+		return s.prevSel
+	}
+	return newEmptySelection(s.document)
+}
+
+// Filter based on the matcher, and the indicator to keep (Filter) or
+// to get rid of (Not) the matching elements.
+func winnow(sel *Selection, m Matcher, keep bool) []*html.Node {
+	// Optimize if keep is requested
+	if keep {
+		return m.Filter(sel.Nodes)
+	}
+	// Use grep
+	return grep(sel, func(i int, s *Selection) bool {
+		return !m.Match(s.Get(0))
+	})
+}
+
+// Filter based on an array of nodes, and the indicator to keep (Filter) or
+// to get rid of (Not) the matching elements.
+func winnowNodes(sel *Selection, nodes []*html.Node, keep bool) []*html.Node {
+	if len(nodes)+len(sel.Nodes) < minNodesForSet {
+		return grep(sel, func(i int, s *Selection) bool {
+			return isInSlice(nodes, s.Get(0)) == keep
+		})
+	}
+
+	set := make(map[*html.Node]bool)
+	for _, n := range nodes {
+		set[n] = true
+	}
+	return grep(sel, func(i int, s *Selection) bool {
+		return set[s.Get(0)] == keep
+	})
+}
+
+// Filter based on a function test, and the indicator to keep (Filter) or
+// to get rid of (Not) the matching elements.
+func winnowFunction(sel *Selection, f func(int, *Selection) bool, keep bool) []*html.Node {
+	return grep(sel, func(i int, s *Selection) bool {
+		return f(i, s) == keep
+	})
+}

+ 6 - 0
vendor/github.com/PuerkitoBio/goquery/go.mod

@@ -0,0 +1,6 @@
+module github.com/PuerkitoBio/goquery
+
+require (
+	github.com/andybalholm/cascadia v1.0.0
+	golang.org/x/net v0.0.0-20181114220301-adae6a3d119a
+)

+ 5 - 0
vendor/github.com/PuerkitoBio/goquery/go.sum

@@ -0,0 +1,5 @@
+github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
+github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
+golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=

+ 39 - 0
vendor/github.com/PuerkitoBio/goquery/iteration.go

@@ -0,0 +1,39 @@
+package goquery
+
+// Each iterates over a Selection object, executing a function for each
+// matched element. It returns the current Selection object. The function
+// f is called for each element in the selection with the index of the
+// element in that selection starting at 0, and a *Selection that contains
+// only that element.
+func (s *Selection) Each(f func(int, *Selection)) *Selection {
+	for i, n := range s.Nodes {
+		f(i, newSingleSelection(n, s.document))
+	}
+	return s
+}
+
+// EachWithBreak iterates over a Selection object, executing a function for each
+// matched element. It is identical to Each except that it is possible to break
+// out of the loop by returning false in the callback function. It returns the
+// current Selection object.
+func (s *Selection) EachWithBreak(f func(int, *Selection) bool) *Selection {
+	for i, n := range s.Nodes {
+		if !f(i, newSingleSelection(n, s.document)) {
+			return s
+		}
+	}
+	return s
+}
+
+// Map passes each element in the current matched set through a function,
+// producing a slice of string holding the returned values. The function
+// f is called for each element in the selection with the index of the
+// element in that selection starting at 0, and a *Selection that contains
+// only that element.
+func (s *Selection) Map(f func(int, *Selection) string) (result []string) {
+	for i, n := range s.Nodes {
+		result = append(result, f(i, newSingleSelection(n, s.document)))
+	}
+
+	return result
+}

+ 574 - 0
vendor/github.com/PuerkitoBio/goquery/manipulation.go

@@ -0,0 +1,574 @@
+package goquery
+
+import (
+	"strings"
+
+	"golang.org/x/net/html"
+)
+
+// After applies the selector from the root document and inserts the matched elements
+// after the elements in the set of matched elements.
+//
+// If one of the matched elements in the selection is not currently in the
+// document, it's impossible to insert nodes after it, so it will be ignored.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) After(selector string) *Selection {
+	return s.AfterMatcher(compileMatcher(selector))
+}
+
+// AfterMatcher applies the matcher from the root document and inserts the matched elements
+// after the elements in the set of matched elements.
+//
+// If one of the matched elements in the selection is not currently in the
+// document, it's impossible to insert nodes after it, so it will be ignored.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) AfterMatcher(m Matcher) *Selection {
+	return s.AfterNodes(m.MatchAll(s.document.rootNode)...)
+}
+
+// AfterSelection inserts the elements in the selection after each element in the set of matched
+// elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) AfterSelection(sel *Selection) *Selection {
+	return s.AfterNodes(sel.Nodes...)
+}
+
+// AfterHtml parses the html and inserts it after the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) AfterHtml(html string) *Selection {
+	return s.AfterNodes(parseHtml(html)...)
+}
+
+// AfterNodes inserts the nodes after each element in the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) AfterNodes(ns ...*html.Node) *Selection {
+	return s.manipulateNodes(ns, true, func(sn *html.Node, n *html.Node) {
+		if sn.Parent != nil {
+			sn.Parent.InsertBefore(n, sn.NextSibling)
+		}
+	})
+}
+
+// Append appends the elements specified by the selector to the end of each element
+// in the set of matched elements, following those rules:
+//
+// 1) The selector is applied to the root document.
+//
+// 2) Elements that are part of the document will be moved to the new location.
+//
+// 3) If there are multiple locations to append to, cloned nodes will be
+// appended to all target locations except the last one, which will be moved
+// as noted in (2).
+func (s *Selection) Append(selector string) *Selection {
+	return s.AppendMatcher(compileMatcher(selector))
+}
+
+// AppendMatcher appends the elements specified by the matcher to the end of each element
+// in the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) AppendMatcher(m Matcher) *Selection {
+	return s.AppendNodes(m.MatchAll(s.document.rootNode)...)
+}
+
+// AppendSelection appends the elements in the selection to the end of each element
+// in the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) AppendSelection(sel *Selection) *Selection {
+	return s.AppendNodes(sel.Nodes...)
+}
+
+// AppendHtml parses the html and appends it to the set of matched elements.
+func (s *Selection) AppendHtml(html string) *Selection {
+	return s.AppendNodes(parseHtml(html)...)
+}
+
+// AppendNodes appends the specified nodes to each node in the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) AppendNodes(ns ...*html.Node) *Selection {
+	return s.manipulateNodes(ns, false, func(sn *html.Node, n *html.Node) {
+		sn.AppendChild(n)
+	})
+}
+
+// Before inserts the matched elements before each element in the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) Before(selector string) *Selection {
+	return s.BeforeMatcher(compileMatcher(selector))
+}
+
+// BeforeMatcher inserts the matched elements before each element in the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) BeforeMatcher(m Matcher) *Selection {
+	return s.BeforeNodes(m.MatchAll(s.document.rootNode)...)
+}
+
+// BeforeSelection inserts the elements in the selection before each element in the set of matched
+// elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) BeforeSelection(sel *Selection) *Selection {
+	return s.BeforeNodes(sel.Nodes...)
+}
+
+// BeforeHtml parses the html and inserts it before the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) BeforeHtml(html string) *Selection {
+	return s.BeforeNodes(parseHtml(html)...)
+}
+
+// BeforeNodes inserts the nodes before each element in the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) BeforeNodes(ns ...*html.Node) *Selection {
+	return s.manipulateNodes(ns, false, func(sn *html.Node, n *html.Node) {
+		if sn.Parent != nil {
+			sn.Parent.InsertBefore(n, sn)
+		}
+	})
+}
+
+// Clone creates a deep copy of the set of matched nodes. The new nodes will not be
+// attached to the document.
+func (s *Selection) Clone() *Selection {
+	ns := newEmptySelection(s.document)
+	ns.Nodes = cloneNodes(s.Nodes)
+	return ns
+}
+
+// Empty removes all children nodes from the set of matched elements.
+// It returns the children nodes in a new Selection.
+func (s *Selection) Empty() *Selection {
+	var nodes []*html.Node
+
+	for _, n := range s.Nodes {
+		for c := n.FirstChild; c != nil; c = n.FirstChild {
+			n.RemoveChild(c)
+			nodes = append(nodes, c)
+		}
+	}
+
+	return pushStack(s, nodes)
+}
+
+// Prepend prepends the elements specified by the selector to each element in
+// the set of matched elements, following the same rules as Append.
+func (s *Selection) Prepend(selector string) *Selection {
+	return s.PrependMatcher(compileMatcher(selector))
+}
+
+// PrependMatcher prepends the elements specified by the matcher to each
+// element in the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) PrependMatcher(m Matcher) *Selection {
+	return s.PrependNodes(m.MatchAll(s.document.rootNode)...)
+}
+
+// PrependSelection prepends the elements in the selection to each element in
+// the set of matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) PrependSelection(sel *Selection) *Selection {
+	return s.PrependNodes(sel.Nodes...)
+}
+
+// PrependHtml parses the html and prepends it to the set of matched elements.
+func (s *Selection) PrependHtml(html string) *Selection {
+	return s.PrependNodes(parseHtml(html)...)
+}
+
+// PrependNodes prepends the specified nodes to each node in the set of
+// matched elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) PrependNodes(ns ...*html.Node) *Selection {
+	return s.manipulateNodes(ns, true, func(sn *html.Node, n *html.Node) {
+		// sn.FirstChild may be nil, in which case this functions like
+		// sn.AppendChild()
+		sn.InsertBefore(n, sn.FirstChild)
+	})
+}
+
+// Remove removes the set of matched elements from the document.
+// It returns the same selection, now consisting of nodes not in the document.
+func (s *Selection) Remove() *Selection {
+	for _, n := range s.Nodes {
+		if n.Parent != nil {
+			n.Parent.RemoveChild(n)
+		}
+	}
+
+	return s
+}
+
+// RemoveFiltered removes the set of matched elements by selector.
+// It returns the Selection of removed nodes.
+func (s *Selection) RemoveFiltered(selector string) *Selection {
+	return s.RemoveMatcher(compileMatcher(selector))
+}
+
+// RemoveMatcher removes the set of matched elements.
+// It returns the Selection of removed nodes.
+func (s *Selection) RemoveMatcher(m Matcher) *Selection {
+	return s.FilterMatcher(m).Remove()
+}
+
+// ReplaceWith replaces each element in the set of matched elements with the
+// nodes matched by the given selector.
+// It returns the removed elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) ReplaceWith(selector string) *Selection {
+	return s.ReplaceWithMatcher(compileMatcher(selector))
+}
+
+// ReplaceWithMatcher replaces each element in the set of matched elements with
+// the nodes matched by the given Matcher.
+// It returns the removed elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) ReplaceWithMatcher(m Matcher) *Selection {
+	return s.ReplaceWithNodes(m.MatchAll(s.document.rootNode)...)
+}
+
+// ReplaceWithSelection replaces each element in the set of matched elements with
+// the nodes from the given Selection.
+// It returns the removed elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) ReplaceWithSelection(sel *Selection) *Selection {
+	return s.ReplaceWithNodes(sel.Nodes...)
+}
+
+// ReplaceWithHtml replaces each element in the set of matched elements with
+// the parsed HTML.
+// It returns the removed elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) ReplaceWithHtml(html string) *Selection {
+	return s.ReplaceWithNodes(parseHtml(html)...)
+}
+
+// ReplaceWithNodes replaces each element in the set of matched elements with
+// the given nodes.
+// It returns the removed elements.
+//
+// This follows the same rules as Selection.Append.
+func (s *Selection) ReplaceWithNodes(ns ...*html.Node) *Selection {
+	s.AfterNodes(ns...)
+	return s.Remove()
+}
+
+// SetHtml sets the html content of each element in the selection to
+// specified html string.
+func (s *Selection) SetHtml(html string) *Selection {
+	return setHtmlNodes(s, parseHtml(html)...)
+}
+
+// SetText sets the content of each element in the selection to specified content.
+// The provided text string is escaped.
+func (s *Selection) SetText(text string) *Selection {
+	return s.SetHtml(html.EscapeString(text))
+}
+
+// Unwrap removes the parents of the set of matched elements, leaving the matched
+// elements (and their siblings, if any) in their place.
+// It returns the original selection.
+func (s *Selection) Unwrap() *Selection {
+	s.Parent().Each(func(i int, ss *Selection) {
+		// For some reason, jquery allows unwrap to remove the <head> element, so
+		// allowing it here too. Same for <html>. Why it allows those elements to
+		// be unwrapped while not allowing body is a mystery to me.
+		if ss.Nodes[0].Data != "body" {
+			ss.ReplaceWithSelection(ss.Contents())
+		}
+	})
+
+	return s
+}
+
+// Wrap wraps each element in the set of matched elements inside the first
+// element matched by the given selector. The matched child is cloned before
+// being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) Wrap(selector string) *Selection {
+	return s.WrapMatcher(compileMatcher(selector))
+}
+
+// WrapMatcher wraps each element in the set of matched elements inside the
+// first element matched by the given matcher. The matched child is cloned
+// before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapMatcher(m Matcher) *Selection {
+	return s.wrapNodes(m.MatchAll(s.document.rootNode)...)
+}
+
+// WrapSelection wraps each element in the set of matched elements inside the
+// first element in the given Selection. The element is cloned before being
+// inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapSelection(sel *Selection) *Selection {
+	return s.wrapNodes(sel.Nodes...)
+}
+
+// WrapHtml wraps each element in the set of matched elements inside the inner-
+// most child of the given HTML.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapHtml(html string) *Selection {
+	return s.wrapNodes(parseHtml(html)...)
+}
+
+// WrapNode wraps each element in the set of matched elements inside the inner-
+// most child of the given node. The given node is copied before being inserted
+// into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapNode(n *html.Node) *Selection {
+	return s.wrapNodes(n)
+}
+
+func (s *Selection) wrapNodes(ns ...*html.Node) *Selection {
+	s.Each(func(i int, ss *Selection) {
+		ss.wrapAllNodes(ns...)
+	})
+
+	return s
+}
+
+// WrapAll wraps a single HTML structure, matched by the given selector, around
+// all elements in the set of matched elements. The matched child is cloned
+// before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapAll(selector string) *Selection {
+	return s.WrapAllMatcher(compileMatcher(selector))
+}
+
+// WrapAllMatcher wraps a single HTML structure, matched by the given Matcher,
+// around all elements in the set of matched elements. The matched child is
+// cloned before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapAllMatcher(m Matcher) *Selection {
+	return s.wrapAllNodes(m.MatchAll(s.document.rootNode)...)
+}
+
+// WrapAllSelection wraps a single HTML structure, the first node of the given
+// Selection, around all elements in the set of matched elements. The matched
+// child is cloned before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapAllSelection(sel *Selection) *Selection {
+	return s.wrapAllNodes(sel.Nodes...)
+}
+
+// WrapAllHtml wraps the given HTML structure around all elements in the set of
+// matched elements. The matched child is cloned before being inserted into the
+// document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapAllHtml(html string) *Selection {
+	return s.wrapAllNodes(parseHtml(html)...)
+}
+
+func (s *Selection) wrapAllNodes(ns ...*html.Node) *Selection {
+	if len(ns) > 0 {
+		return s.WrapAllNode(ns[0])
+	}
+	return s
+}
+
+// WrapAllNode wraps the given node around the first element in the Selection,
+// making all other nodes in the Selection children of the given node. The node
+// is cloned before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapAllNode(n *html.Node) *Selection {
+	if s.Size() == 0 {
+		return s
+	}
+
+	wrap := cloneNode(n)
+
+	first := s.Nodes[0]
+	if first.Parent != nil {
+		first.Parent.InsertBefore(wrap, first)
+		first.Parent.RemoveChild(first)
+	}
+
+	for c := getFirstChildEl(wrap); c != nil; c = getFirstChildEl(wrap) {
+		wrap = c
+	}
+
+	newSingleSelection(wrap, s.document).AppendSelection(s)
+
+	return s
+}
+
+// WrapInner wraps an HTML structure, matched by the given selector, around the
+// content of element in the set of matched elements. The matched child is
+// cloned before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapInner(selector string) *Selection {
+	return s.WrapInnerMatcher(compileMatcher(selector))
+}
+
+// WrapInnerMatcher wraps an HTML structure, matched by the given selector,
+// around the content of element in the set of matched elements. The matched
+// child is cloned before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapInnerMatcher(m Matcher) *Selection {
+	return s.wrapInnerNodes(m.MatchAll(s.document.rootNode)...)
+}
+
+// WrapInnerSelection wraps an HTML structure, matched by the given selector,
+// around the content of element in the set of matched elements. The matched
+// child is cloned before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapInnerSelection(sel *Selection) *Selection {
+	return s.wrapInnerNodes(sel.Nodes...)
+}
+
+// WrapInnerHtml wraps an HTML structure, matched by the given selector, around
+// the content of element in the set of matched elements. The matched child is
+// cloned before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapInnerHtml(html string) *Selection {
+	return s.wrapInnerNodes(parseHtml(html)...)
+}
+
+// WrapInnerNode wraps an HTML structure, matched by the given selector, around
+// the content of element in the set of matched elements. The matched child is
+// cloned before being inserted into the document.
+//
+// It returns the original set of elements.
+func (s *Selection) WrapInnerNode(n *html.Node) *Selection {
+	return s.wrapInnerNodes(n)
+}
+
+func (s *Selection) wrapInnerNodes(ns ...*html.Node) *Selection {
+	if len(ns) == 0 {
+		return s
+	}
+
+	s.Each(func(i int, s *Selection) {
+		contents := s.Contents()
+
+		if contents.Size() > 0 {
+			contents.wrapAllNodes(ns...)
+		} else {
+			s.AppendNodes(cloneNode(ns[0]))
+		}
+	})
+
+	return s
+}
+
+func parseHtml(h string) []*html.Node {
+	// Errors are only returned when the io.Reader returns any error besides
+	// EOF, but strings.Reader never will
+	nodes, err := html.ParseFragment(strings.NewReader(h), &html.Node{Type: html.ElementNode})
+	if err != nil {
+		panic("goquery: failed to parse HTML: " + err.Error())
+	}
+	return nodes
+}
+
+func setHtmlNodes(s *Selection, ns ...*html.Node) *Selection {
+	for _, n := range s.Nodes {
+		for c := n.FirstChild; c != nil; c = n.FirstChild {
+			n.RemoveChild(c)
+		}
+		for _, c := range ns {
+			n.AppendChild(cloneNode(c))
+		}
+	}
+	return s
+}
+
+// Get the first child that is an ElementNode
+func getFirstChildEl(n *html.Node) *html.Node {
+	c := n.FirstChild
+	for c != nil && c.Type != html.ElementNode {
+		c = c.NextSibling
+	}
+	return c
+}
+
+// Deep copy a slice of nodes.
+func cloneNodes(ns []*html.Node) []*html.Node {
+	cns := make([]*html.Node, 0, len(ns))
+
+	for _, n := range ns {
+		cns = append(cns, cloneNode(n))
+	}
+
+	return cns
+}
+
+// Deep copy a node. The new node has clones of all the original node's
+// children but none of its parents or siblings.
+func cloneNode(n *html.Node) *html.Node {
+	nn := &html.Node{
+		Type:     n.Type,
+		DataAtom: n.DataAtom,
+		Data:     n.Data,
+		Attr:     make([]html.Attribute, len(n.Attr)),
+	}
+
+	copy(nn.Attr, n.Attr)
+	for c := n.FirstChild; c != nil; c = c.NextSibling {
+		nn.AppendChild(cloneNode(c))
+	}
+
+	return nn
+}
+
+func (s *Selection) manipulateNodes(ns []*html.Node, reverse bool,
+	f func(sn *html.Node, n *html.Node)) *Selection {
+
+	lasti := s.Size() - 1
+
+	// net.Html doesn't provide document fragments for insertion, so to get
+	// things in the correct order with After() and Prepend(), the callback
+	// needs to be called on the reverse of the nodes.
+	if reverse {
+		for i, j := 0, len(ns)-1; i < j; i, j = i+1, j-1 {
+			ns[i], ns[j] = ns[j], ns[i]
+		}
+	}
+
+	for i, sn := range s.Nodes {
+		for _, n := range ns {
+			if i != lasti {
+				f(sn, cloneNode(n))
+			} else {
+				if n.Parent != nil {
+					n.Parent.RemoveChild(n)
+				}
+				f(sn, n)
+			}
+		}
+	}
+
+	return s
+}

+ 275 - 0
vendor/github.com/PuerkitoBio/goquery/property.go

@@ -0,0 +1,275 @@
+package goquery
+
+import (
+	"bytes"
+	"regexp"
+	"strings"
+
+	"golang.org/x/net/html"
+)
+
+var rxClassTrim = regexp.MustCompile("[\t\r\n]")
+
+// Attr gets the specified attribute's value for the first element in the
+// Selection. To get the value for each element individually, use a looping
+// construct such as Each or Map method.
+func (s *Selection) Attr(attrName string) (val string, exists bool) {
+	if len(s.Nodes) == 0 {
+		return
+	}
+	return getAttributeValue(attrName, s.Nodes[0])
+}
+
+// AttrOr works like Attr but returns default value if attribute is not present.
+func (s *Selection) AttrOr(attrName, defaultValue string) string {
+	if len(s.Nodes) == 0 {
+		return defaultValue
+	}
+
+	val, exists := getAttributeValue(attrName, s.Nodes[0])
+	if !exists {
+		return defaultValue
+	}
+
+	return val
+}
+
+// RemoveAttr removes the named attribute from each element in the set of matched elements.
+func (s *Selection) RemoveAttr(attrName string) *Selection {
+	for _, n := range s.Nodes {
+		removeAttr(n, attrName)
+	}
+
+	return s
+}
+
+// SetAttr sets the given attribute on each element in the set of matched elements.
+func (s *Selection) SetAttr(attrName, val string) *Selection {
+	for _, n := range s.Nodes {
+		attr := getAttributePtr(attrName, n)
+		if attr == nil {
+			n.Attr = append(n.Attr, html.Attribute{Key: attrName, Val: val})
+		} else {
+			attr.Val = val
+		}
+	}
+
+	return s
+}
+
+// Text gets the combined text contents of each element in the set of matched
+// elements, including their descendants.
+func (s *Selection) Text() string {
+	var buf bytes.Buffer
+
+	// Slightly optimized vs calling Each: no single selection object created
+	var f func(*html.Node)
+	f = func(n *html.Node) {
+		if n.Type == html.TextNode {
+			// Keep newlines and spaces, like jQuery
+			buf.WriteString(n.Data)
+		}
+		if n.FirstChild != nil {
+			for c := n.FirstChild; c != nil; c = c.NextSibling {
+				f(c)
+			}
+		}
+	}
+	for _, n := range s.Nodes {
+		f(n)
+	}
+
+	return buf.String()
+}
+
+// Size is an alias for Length.
+func (s *Selection) Size() int {
+	return s.Length()
+}
+
+// Length returns the number of elements in the Selection object.
+func (s *Selection) Length() int {
+	return len(s.Nodes)
+}
+
+// Html gets the HTML contents of the first element in the set of matched
+// elements. It includes text and comment nodes.
+func (s *Selection) Html() (ret string, e error) {
+	// Since there is no .innerHtml, the HTML content must be re-created from
+	// the nodes using html.Render.
+	var buf bytes.Buffer
+
+	if len(s.Nodes) > 0 {
+		for c := s.Nodes[0].FirstChild; c != nil; c = c.NextSibling {
+			e = html.Render(&buf, c)
+			if e != nil {
+				return
+			}
+		}
+		ret = buf.String()
+	}
+
+	return
+}
+
+// AddClass adds the given class(es) to each element in the set of matched elements.
+// Multiple class names can be specified, separated by a space or via multiple arguments.
+func (s *Selection) AddClass(class ...string) *Selection {
+	classStr := strings.TrimSpace(strings.Join(class, " "))
+
+	if classStr == "" {
+		return s
+	}
+
+	tcls := getClassesSlice(classStr)
+	for _, n := range s.Nodes {
+		curClasses, attr := getClassesAndAttr(n, true)
+		for _, newClass := range tcls {
+			if !strings.Contains(curClasses, " "+newClass+" ") {
+				curClasses += newClass + " "
+			}
+		}
+
+		setClasses(n, attr, curClasses)
+	}
+
+	return s
+}
+
+// HasClass determines whether any of the matched elements are assigned the
+// given class.
+func (s *Selection) HasClass(class string) bool {
+	class = " " + class + " "
+	for _, n := range s.Nodes {
+		classes, _ := getClassesAndAttr(n, false)
+		if strings.Contains(classes, class) {
+			return true
+		}
+	}
+	return false
+}
+
+// RemoveClass removes the given class(es) from each element in the set of matched elements.
+// Multiple class names can be specified, separated by a space or via multiple arguments.
+// If no class name is provided, all classes are removed.
+func (s *Selection) RemoveClass(class ...string) *Selection {
+	var rclasses []string
+
+	classStr := strings.TrimSpace(strings.Join(class, " "))
+	remove := classStr == ""
+
+	if !remove {
+		rclasses = getClassesSlice(classStr)
+	}
+
+	for _, n := range s.Nodes {
+		if remove {
+			removeAttr(n, "class")
+		} else {
+			classes, attr := getClassesAndAttr(n, true)
+			for _, rcl := range rclasses {
+				classes = strings.Replace(classes, " "+rcl+" ", " ", -1)
+			}
+
+			setClasses(n, attr, classes)
+		}
+	}
+
+	return s
+}
+
+// ToggleClass adds or removes the given class(es) for each element in the set of matched elements.
+// Multiple class names can be specified, separated by a space or via multiple arguments.
+func (s *Selection) ToggleClass(class ...string) *Selection {
+	classStr := strings.TrimSpace(strings.Join(class, " "))
+
+	if classStr == "" {
+		return s
+	}
+
+	tcls := getClassesSlice(classStr)
+
+	for _, n := range s.Nodes {
+		classes, attr := getClassesAndAttr(n, true)
+		for _, tcl := range tcls {
+			if strings.Contains(classes, " "+tcl+" ") {
+				classes = strings.Replace(classes, " "+tcl+" ", " ", -1)
+			} else {
+				classes += tcl + " "
+			}
+		}
+
+		setClasses(n, attr, classes)
+	}
+
+	return s
+}
+
+func getAttributePtr(attrName string, n *html.Node) *html.Attribute {
+	if n == nil {
+		return nil
+	}
+
+	for i, a := range n.Attr {
+		if a.Key == attrName {
+			return &n.Attr[i]
+		}
+	}
+	return nil
+}
+
+// Private function to get the specified attribute's value from a node.
+func getAttributeValue(attrName string, n *html.Node) (val string, exists bool) {
+	if a := getAttributePtr(attrName, n); a != nil {
+		val = a.Val
+		exists = true
+	}
+	return
+}
+
+// Get and normalize the "class" attribute from the node.
+func getClassesAndAttr(n *html.Node, create bool) (classes string, attr *html.Attribute) {
+	// Applies only to element nodes
+	if n.Type == html.ElementNode {
+		attr = getAttributePtr("class", n)
+		if attr == nil && create {
+			n.Attr = append(n.Attr, html.Attribute{
+				Key: "class",
+				Val: "",
+			})
+			attr = &n.Attr[len(n.Attr)-1]
+		}
+	}
+
+	if attr == nil {
+		classes = " "
+	} else {
+		classes = rxClassTrim.ReplaceAllString(" "+attr.Val+" ", " ")
+	}
+
+	return
+}
+
+func getClassesSlice(classes string) []string {
+	return strings.Split(rxClassTrim.ReplaceAllString(" "+classes+" ", " "), " ")
+}
+
+func removeAttr(n *html.Node, attrName string) {
+	for i, a := range n.Attr {
+		if a.Key == attrName {
+			n.Attr[i], n.Attr[len(n.Attr)-1], n.Attr =
+				n.Attr[len(n.Attr)-1], html.Attribute{}, n.Attr[:len(n.Attr)-1]
+			return
+		}
+	}
+}
+
+func setClasses(n *html.Node, attr *html.Attribute, classes string) {
+	classes = strings.TrimSpace(classes)
+	if classes == "" {
+		removeAttr(n, "class")
+		return
+	}
+
+	attr.Val = classes
+}

+ 49 - 0
vendor/github.com/PuerkitoBio/goquery/query.go

@@ -0,0 +1,49 @@
+package goquery
+
+import "golang.org/x/net/html"
+
+// Is checks the current matched set of elements against a selector and
+// returns true if at least one of these elements matches.
+func (s *Selection) Is(selector string) bool {
+	return s.IsMatcher(compileMatcher(selector))
+}
+
+// IsMatcher checks the current matched set of elements against a matcher and
+// returns true if at least one of these elements matches.
+func (s *Selection) IsMatcher(m Matcher) bool {
+	if len(s.Nodes) > 0 {
+		if len(s.Nodes) == 1 {
+			return m.Match(s.Nodes[0])
+		}
+		return len(m.Filter(s.Nodes)) > 0
+	}
+
+	return false
+}
+
+// IsFunction checks the current matched set of elements against a predicate and
+// returns true if at least one of these elements matches.
+func (s *Selection) IsFunction(f func(int, *Selection) bool) bool {
+	return s.FilterFunction(f).Length() > 0
+}
+
+// IsSelection checks the current matched set of elements against a Selection object
+// and returns true if at least one of these elements matches.
+func (s *Selection) IsSelection(sel *Selection) bool {
+	return s.FilterSelection(sel).Length() > 0
+}
+
+// IsNodes checks the current matched set of elements against the specified nodes
+// and returns true if at least one of these elements matches.
+func (s *Selection) IsNodes(nodes ...*html.Node) bool {
+	return s.FilterNodes(nodes...).Length() > 0
+}
+
+// Contains returns true if the specified Node is within,
+// at any depth, one of the nodes in the Selection object.
+// It is NOT inclusive, to behave like jQuery's implementation, and
+// unlike Javascript's .contains, so if the contained
+// node is itself in the selection, it returns false.
+func (s *Selection) Contains(n *html.Node) bool {
+	return sliceContains(s.Nodes, n)
+}

+ 698 - 0
vendor/github.com/PuerkitoBio/goquery/traversal.go

@@ -0,0 +1,698 @@
+package goquery
+
+import "golang.org/x/net/html"
+
+type siblingType int
+
+// Sibling type, used internally when iterating over children at the same
+// level (siblings) to specify which nodes are requested.
+const (
+	siblingPrevUntil siblingType = iota - 3
+	siblingPrevAll
+	siblingPrev
+	siblingAll
+	siblingNext
+	siblingNextAll
+	siblingNextUntil
+	siblingAllIncludingNonElements
+)
+
+// Find gets the descendants of each element in the current set of matched
+// elements, filtered by a selector. It returns a new Selection object
+// containing these matched elements.
+func (s *Selection) Find(selector string) *Selection {
+	return pushStack(s, findWithMatcher(s.Nodes, compileMatcher(selector)))
+}
+
+// FindMatcher gets the descendants of each element in the current set of matched
+// elements, filtered by the matcher. It returns a new Selection object
+// containing these matched elements.
+func (s *Selection) FindMatcher(m Matcher) *Selection {
+	return pushStack(s, findWithMatcher(s.Nodes, m))
+}
+
+// FindSelection gets the descendants of each element in the current
+// Selection, filtered by a Selection. It returns a new Selection object
+// containing these matched elements.
+func (s *Selection) FindSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return pushStack(s, nil)
+	}
+	return s.FindNodes(sel.Nodes...)
+}
+
+// FindNodes gets the descendants of each element in the current
+// Selection, filtered by some nodes. It returns a new Selection object
+// containing these matched elements.
+func (s *Selection) FindNodes(nodes ...*html.Node) *Selection {
+	return pushStack(s, mapNodes(nodes, func(i int, n *html.Node) []*html.Node {
+		if sliceContains(s.Nodes, n) {
+			return []*html.Node{n}
+		}
+		return nil
+	}))
+}
+
+// Contents gets the children of each element in the Selection,
+// including text and comment nodes. It returns a new Selection object
+// containing these elements.
+func (s *Selection) Contents() *Selection {
+	return pushStack(s, getChildrenNodes(s.Nodes, siblingAllIncludingNonElements))
+}
+
+// ContentsFiltered gets the children of each element in the Selection,
+// filtered by the specified selector. It returns a new Selection
+// object containing these elements. Since selectors only act on Element nodes,
+// this function is an alias to ChildrenFiltered unless the selector is empty,
+// in which case it is an alias to Contents.
+func (s *Selection) ContentsFiltered(selector string) *Selection {
+	if selector != "" {
+		return s.ChildrenFiltered(selector)
+	}
+	return s.Contents()
+}
+
+// ContentsMatcher gets the children of each element in the Selection,
+// filtered by the specified matcher. It returns a new Selection
+// object containing these elements. Since matchers only act on Element nodes,
+// this function is an alias to ChildrenMatcher.
+func (s *Selection) ContentsMatcher(m Matcher) *Selection {
+	return s.ChildrenMatcher(m)
+}
+
+// Children gets the child elements of each element in the Selection.
+// It returns a new Selection object containing these elements.
+func (s *Selection) Children() *Selection {
+	return pushStack(s, getChildrenNodes(s.Nodes, siblingAll))
+}
+
+// ChildrenFiltered gets the child elements of each element in the Selection,
+// filtered by the specified selector. It returns a new
+// Selection object containing these elements.
+func (s *Selection) ChildrenFiltered(selector string) *Selection {
+	return filterAndPush(s, getChildrenNodes(s.Nodes, siblingAll), compileMatcher(selector))
+}
+
+// ChildrenMatcher gets the child elements of each element in the Selection,
+// filtered by the specified matcher. It returns a new
+// Selection object containing these elements.
+func (s *Selection) ChildrenMatcher(m Matcher) *Selection {
+	return filterAndPush(s, getChildrenNodes(s.Nodes, siblingAll), m)
+}
+
+// Parent gets the parent of each element in the Selection. It returns a
+// new Selection object containing the matched elements.
+func (s *Selection) Parent() *Selection {
+	return pushStack(s, getParentNodes(s.Nodes))
+}
+
+// ParentFiltered gets the parent of each element in the Selection filtered by a
+// selector. It returns a new Selection object containing the matched elements.
+func (s *Selection) ParentFiltered(selector string) *Selection {
+	return filterAndPush(s, getParentNodes(s.Nodes), compileMatcher(selector))
+}
+
+// ParentMatcher gets the parent of each element in the Selection filtered by a
+// matcher. It returns a new Selection object containing the matched elements.
+func (s *Selection) ParentMatcher(m Matcher) *Selection {
+	return filterAndPush(s, getParentNodes(s.Nodes), m)
+}
+
+// Closest gets the first element that matches the selector by testing the
+// element itself and traversing up through its ancestors in the DOM tree.
+func (s *Selection) Closest(selector string) *Selection {
+	cs := compileMatcher(selector)
+	return s.ClosestMatcher(cs)
+}
+
+// ClosestMatcher gets the first element that matches the matcher by testing the
+// element itself and traversing up through its ancestors in the DOM tree.
+func (s *Selection) ClosestMatcher(m Matcher) *Selection {
+	return pushStack(s, mapNodes(s.Nodes, func(i int, n *html.Node) []*html.Node {
+		// For each node in the selection, test the node itself, then each parent
+		// until a match is found.
+		for ; n != nil; n = n.Parent {
+			if m.Match(n) {
+				return []*html.Node{n}
+			}
+		}
+		return nil
+	}))
+}
+
+// ClosestNodes gets the first element that matches one of the nodes by testing the
+// element itself and traversing up through its ancestors in the DOM tree.
+func (s *Selection) ClosestNodes(nodes ...*html.Node) *Selection {
+	set := make(map[*html.Node]bool)
+	for _, n := range nodes {
+		set[n] = true
+	}
+	return pushStack(s, mapNodes(s.Nodes, func(i int, n *html.Node) []*html.Node {
+		// For each node in the selection, test the node itself, then each parent
+		// until a match is found.
+		for ; n != nil; n = n.Parent {
+			if set[n] {
+				return []*html.Node{n}
+			}
+		}
+		return nil
+	}))
+}
+
+// ClosestSelection gets the first element that matches one of the nodes in the
+// Selection by testing the element itself and traversing up through its ancestors
+// in the DOM tree.
+func (s *Selection) ClosestSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return pushStack(s, nil)
+	}
+	return s.ClosestNodes(sel.Nodes...)
+}
+
+// Parents gets the ancestors of each element in the current Selection. It
+// returns a new Selection object with the matched elements.
+func (s *Selection) Parents() *Selection {
+	return pushStack(s, getParentsNodes(s.Nodes, nil, nil))
+}
+
+// ParentsFiltered gets the ancestors of each element in the current
+// Selection. It returns a new Selection object with the matched elements.
+func (s *Selection) ParentsFiltered(selector string) *Selection {
+	return filterAndPush(s, getParentsNodes(s.Nodes, nil, nil), compileMatcher(selector))
+}
+
+// ParentsMatcher gets the ancestors of each element in the current
+// Selection. It returns a new Selection object with the matched elements.
+func (s *Selection) ParentsMatcher(m Matcher) *Selection {
+	return filterAndPush(s, getParentsNodes(s.Nodes, nil, nil), m)
+}
+
+// ParentsUntil gets the ancestors of each element in the Selection, up to but
+// not including the element matched by the selector. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) ParentsUntil(selector string) *Selection {
+	return pushStack(s, getParentsNodes(s.Nodes, compileMatcher(selector), nil))
+}
+
+// ParentsUntilMatcher gets the ancestors of each element in the Selection, up to but
+// not including the element matched by the matcher. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) ParentsUntilMatcher(m Matcher) *Selection {
+	return pushStack(s, getParentsNodes(s.Nodes, m, nil))
+}
+
+// ParentsUntilSelection gets the ancestors of each element in the Selection,
+// up to but not including the elements in the specified Selection. It returns a
+// new Selection object containing the matched elements.
+func (s *Selection) ParentsUntilSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return s.Parents()
+	}
+	return s.ParentsUntilNodes(sel.Nodes...)
+}
+
+// ParentsUntilNodes gets the ancestors of each element in the Selection,
+// up to but not including the specified nodes. It returns a
+// new Selection object containing the matched elements.
+func (s *Selection) ParentsUntilNodes(nodes ...*html.Node) *Selection {
+	return pushStack(s, getParentsNodes(s.Nodes, nil, nodes))
+}
+
+// ParentsFilteredUntil is like ParentsUntil, with the option to filter the
+// results based on a selector string. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) ParentsFilteredUntil(filterSelector, untilSelector string) *Selection {
+	return filterAndPush(s, getParentsNodes(s.Nodes, compileMatcher(untilSelector), nil), compileMatcher(filterSelector))
+}
+
+// ParentsFilteredUntilMatcher is like ParentsUntilMatcher, with the option to filter the
+// results based on a matcher. It returns a new Selection object containing the matched elements.
+func (s *Selection) ParentsFilteredUntilMatcher(filter, until Matcher) *Selection {
+	return filterAndPush(s, getParentsNodes(s.Nodes, until, nil), filter)
+}
+
+// ParentsFilteredUntilSelection is like ParentsUntilSelection, with the
+// option to filter the results based on a selector string. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) ParentsFilteredUntilSelection(filterSelector string, sel *Selection) *Selection {
+	return s.ParentsMatcherUntilSelection(compileMatcher(filterSelector), sel)
+}
+
+// ParentsMatcherUntilSelection is like ParentsUntilSelection, with the
+// option to filter the results based on a matcher. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) ParentsMatcherUntilSelection(filter Matcher, sel *Selection) *Selection {
+	if sel == nil {
+		return s.ParentsMatcher(filter)
+	}
+	return s.ParentsMatcherUntilNodes(filter, sel.Nodes...)
+}
+
+// ParentsFilteredUntilNodes is like ParentsUntilNodes, with the
+// option to filter the results based on a selector string. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) ParentsFilteredUntilNodes(filterSelector string, nodes ...*html.Node) *Selection {
+	return filterAndPush(s, getParentsNodes(s.Nodes, nil, nodes), compileMatcher(filterSelector))
+}
+
+// ParentsMatcherUntilNodes is like ParentsUntilNodes, with the
+// option to filter the results based on a matcher. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) ParentsMatcherUntilNodes(filter Matcher, nodes ...*html.Node) *Selection {
+	return filterAndPush(s, getParentsNodes(s.Nodes, nil, nodes), filter)
+}
+
+// Siblings gets the siblings of each element in the Selection. It returns
+// a new Selection object containing the matched elements.
+func (s *Selection) Siblings() *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingAll, nil, nil))
+}
+
+// SiblingsFiltered gets the siblings of each element in the Selection
+// filtered by a selector. It returns a new Selection object containing the
+// matched elements.
+func (s *Selection) SiblingsFiltered(selector string) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingAll, nil, nil), compileMatcher(selector))
+}
+
+// SiblingsMatcher gets the siblings of each element in the Selection
+// filtered by a matcher. It returns a new Selection object containing the
+// matched elements.
+func (s *Selection) SiblingsMatcher(m Matcher) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingAll, nil, nil), m)
+}
+
+// Next gets the immediately following sibling of each element in the
+// Selection. It returns a new Selection object containing the matched elements.
+func (s *Selection) Next() *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingNext, nil, nil))
+}
+
+// NextFiltered gets the immediately following sibling of each element in the
+// Selection filtered by a selector. It returns a new Selection object
+// containing the matched elements.
+func (s *Selection) NextFiltered(selector string) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingNext, nil, nil), compileMatcher(selector))
+}
+
+// NextMatcher gets the immediately following sibling of each element in the
+// Selection filtered by a matcher. It returns a new Selection object
+// containing the matched elements.
+func (s *Selection) NextMatcher(m Matcher) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingNext, nil, nil), m)
+}
+
+// NextAll gets all the following siblings of each element in the
+// Selection. It returns a new Selection object containing the matched elements.
+func (s *Selection) NextAll() *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingNextAll, nil, nil))
+}
+
+// NextAllFiltered gets all the following siblings of each element in the
+// Selection filtered by a selector. It returns a new Selection object
+// containing the matched elements.
+func (s *Selection) NextAllFiltered(selector string) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingNextAll, nil, nil), compileMatcher(selector))
+}
+
+// NextAllMatcher gets all the following siblings of each element in the
+// Selection filtered by a matcher. It returns a new Selection object
+// containing the matched elements.
+func (s *Selection) NextAllMatcher(m Matcher) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingNextAll, nil, nil), m)
+}
+
+// Prev gets the immediately preceding sibling of each element in the
+// Selection. It returns a new Selection object containing the matched elements.
+func (s *Selection) Prev() *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingPrev, nil, nil))
+}
+
+// PrevFiltered gets the immediately preceding sibling of each element in the
+// Selection filtered by a selector. It returns a new Selection object
+// containing the matched elements.
+func (s *Selection) PrevFiltered(selector string) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingPrev, nil, nil), compileMatcher(selector))
+}
+
+// PrevMatcher gets the immediately preceding sibling of each element in the
+// Selection filtered by a matcher. It returns a new Selection object
+// containing the matched elements.
+func (s *Selection) PrevMatcher(m Matcher) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingPrev, nil, nil), m)
+}
+
+// PrevAll gets all the preceding siblings of each element in the
+// Selection. It returns a new Selection object containing the matched elements.
+func (s *Selection) PrevAll() *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingPrevAll, nil, nil))
+}
+
+// PrevAllFiltered gets all the preceding siblings of each element in the
+// Selection filtered by a selector. It returns a new Selection object
+// containing the matched elements.
+func (s *Selection) PrevAllFiltered(selector string) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingPrevAll, nil, nil), compileMatcher(selector))
+}
+
+// PrevAllMatcher gets all the preceding siblings of each element in the
+// Selection filtered by a matcher. It returns a new Selection object
+// containing the matched elements.
+func (s *Selection) PrevAllMatcher(m Matcher) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingPrevAll, nil, nil), m)
+}
+
+// NextUntil gets all following siblings of each element up to but not
+// including the element matched by the selector. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) NextUntil(selector string) *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingNextUntil,
+		compileMatcher(selector), nil))
+}
+
+// NextUntilMatcher gets all following siblings of each element up to but not
+// including the element matched by the matcher. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) NextUntilMatcher(m Matcher) *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingNextUntil,
+		m, nil))
+}
+
+// NextUntilSelection gets all following siblings of each element up to but not
+// including the element matched by the Selection. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) NextUntilSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return s.NextAll()
+	}
+	return s.NextUntilNodes(sel.Nodes...)
+}
+
+// NextUntilNodes gets all following siblings of each element up to but not
+// including the element matched by the nodes. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) NextUntilNodes(nodes ...*html.Node) *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingNextUntil,
+		nil, nodes))
+}
+
+// PrevUntil gets all preceding siblings of each element up to but not
+// including the element matched by the selector. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) PrevUntil(selector string) *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingPrevUntil,
+		compileMatcher(selector), nil))
+}
+
+// PrevUntilMatcher gets all preceding siblings of each element up to but not
+// including the element matched by the matcher. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) PrevUntilMatcher(m Matcher) *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingPrevUntil,
+		m, nil))
+}
+
+// PrevUntilSelection gets all preceding siblings of each element up to but not
+// including the element matched by the Selection. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) PrevUntilSelection(sel *Selection) *Selection {
+	if sel == nil {
+		return s.PrevAll()
+	}
+	return s.PrevUntilNodes(sel.Nodes...)
+}
+
+// PrevUntilNodes gets all preceding siblings of each element up to but not
+// including the element matched by the nodes. It returns a new Selection
+// object containing the matched elements.
+func (s *Selection) PrevUntilNodes(nodes ...*html.Node) *Selection {
+	return pushStack(s, getSiblingNodes(s.Nodes, siblingPrevUntil,
+		nil, nodes))
+}
+
+// NextFilteredUntil is like NextUntil, with the option to filter
+// the results based on a selector string.
+// It returns a new Selection object containing the matched elements.
+func (s *Selection) NextFilteredUntil(filterSelector, untilSelector string) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingNextUntil,
+		compileMatcher(untilSelector), nil), compileMatcher(filterSelector))
+}
+
+// NextFilteredUntilMatcher is like NextUntilMatcher, with the option to filter
+// the results based on a matcher.
+// It returns a new Selection object containing the matched elements.
+func (s *Selection) NextFilteredUntilMatcher(filter, until Matcher) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingNextUntil,
+		until, nil), filter)
+}
+
+// NextFilteredUntilSelection is like NextUntilSelection, with the
+// option to filter the results based on a selector string. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) NextFilteredUntilSelection(filterSelector string, sel *Selection) *Selection {
+	return s.NextMatcherUntilSelection(compileMatcher(filterSelector), sel)
+}
+
+// NextMatcherUntilSelection is like NextUntilSelection, with the
+// option to filter the results based on a matcher. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) NextMatcherUntilSelection(filter Matcher, sel *Selection) *Selection {
+	if sel == nil {
+		return s.NextMatcher(filter)
+	}
+	return s.NextMatcherUntilNodes(filter, sel.Nodes...)
+}
+
+// NextFilteredUntilNodes is like NextUntilNodes, with the
+// option to filter the results based on a selector string. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) NextFilteredUntilNodes(filterSelector string, nodes ...*html.Node) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingNextUntil,
+		nil, nodes), compileMatcher(filterSelector))
+}
+
+// NextMatcherUntilNodes is like NextUntilNodes, with the
+// option to filter the results based on a matcher. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) NextMatcherUntilNodes(filter Matcher, nodes ...*html.Node) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingNextUntil,
+		nil, nodes), filter)
+}
+
+// PrevFilteredUntil is like PrevUntil, with the option to filter
+// the results based on a selector string.
+// It returns a new Selection object containing the matched elements.
+func (s *Selection) PrevFilteredUntil(filterSelector, untilSelector string) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingPrevUntil,
+		compileMatcher(untilSelector), nil), compileMatcher(filterSelector))
+}
+
+// PrevFilteredUntilMatcher is like PrevUntilMatcher, with the option to filter
+// the results based on a matcher.
+// It returns a new Selection object containing the matched elements.
+func (s *Selection) PrevFilteredUntilMatcher(filter, until Matcher) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingPrevUntil,
+		until, nil), filter)
+}
+
+// PrevFilteredUntilSelection is like PrevUntilSelection, with the
+// option to filter the results based on a selector string. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) PrevFilteredUntilSelection(filterSelector string, sel *Selection) *Selection {
+	return s.PrevMatcherUntilSelection(compileMatcher(filterSelector), sel)
+}
+
+// PrevMatcherUntilSelection is like PrevUntilSelection, with the
+// option to filter the results based on a matcher. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) PrevMatcherUntilSelection(filter Matcher, sel *Selection) *Selection {
+	if sel == nil {
+		return s.PrevMatcher(filter)
+	}
+	return s.PrevMatcherUntilNodes(filter, sel.Nodes...)
+}
+
+// PrevFilteredUntilNodes is like PrevUntilNodes, with the
+// option to filter the results based on a selector string. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) PrevFilteredUntilNodes(filterSelector string, nodes ...*html.Node) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingPrevUntil,
+		nil, nodes), compileMatcher(filterSelector))
+}
+
+// PrevMatcherUntilNodes is like PrevUntilNodes, with the
+// option to filter the results based on a matcher. It returns a new
+// Selection object containing the matched elements.
+func (s *Selection) PrevMatcherUntilNodes(filter Matcher, nodes ...*html.Node) *Selection {
+	return filterAndPush(s, getSiblingNodes(s.Nodes, siblingPrevUntil,
+		nil, nodes), filter)
+}
+
+// Filter and push filters the nodes based on a matcher, and pushes the results
+// on the stack, with the srcSel as previous selection.
+func filterAndPush(srcSel *Selection, nodes []*html.Node, m Matcher) *Selection {
+	// Create a temporary Selection with the specified nodes to filter using winnow
+	sel := &Selection{nodes, srcSel.document, nil}
+	// Filter based on matcher and push on stack
+	return pushStack(srcSel, winnow(sel, m, true))
+}
+
+// Internal implementation of Find that return raw nodes.
+func findWithMatcher(nodes []*html.Node, m Matcher) []*html.Node {
+	// Map nodes to find the matches within the children of each node
+	return mapNodes(nodes, func(i int, n *html.Node) (result []*html.Node) {
+		// Go down one level, becausejQuery's Find selects only within descendants
+		for c := n.FirstChild; c != nil; c = c.NextSibling {
+			if c.Type == html.ElementNode {
+				result = append(result, m.MatchAll(c)...)
+			}
+		}
+		return
+	})
+}
+
+// Internal implementation to get all parent nodes, stopping at the specified
+// node (or nil if no stop).
+func getParentsNodes(nodes []*html.Node, stopm Matcher, stopNodes []*html.Node) []*html.Node {
+	return mapNodes(nodes, func(i int, n *html.Node) (result []*html.Node) {
+		for p := n.Parent; p != nil; p = p.Parent {
+			sel := newSingleSelection(p, nil)
+			if stopm != nil {
+				if sel.IsMatcher(stopm) {
+					break
+				}
+			} else if len(stopNodes) > 0 {
+				if sel.IsNodes(stopNodes...) {
+					break
+				}
+			}
+			if p.Type == html.ElementNode {
+				result = append(result, p)
+			}
+		}
+		return
+	})
+}
+
+// Internal implementation of sibling nodes that return a raw slice of matches.
+func getSiblingNodes(nodes []*html.Node, st siblingType, untilm Matcher, untilNodes []*html.Node) []*html.Node {
+	var f func(*html.Node) bool
+
+	// If the requested siblings are ...Until, create the test function to
+	// determine if the until condition is reached (returns true if it is)
+	if st == siblingNextUntil || st == siblingPrevUntil {
+		f = func(n *html.Node) bool {
+			if untilm != nil {
+				// Matcher-based condition
+				sel := newSingleSelection(n, nil)
+				return sel.IsMatcher(untilm)
+			} else if len(untilNodes) > 0 {
+				// Nodes-based condition
+				sel := newSingleSelection(n, nil)
+				return sel.IsNodes(untilNodes...)
+			}
+			return false
+		}
+	}
+
+	return mapNodes(nodes, func(i int, n *html.Node) []*html.Node {
+		return getChildrenWithSiblingType(n.Parent, st, n, f)
+	})
+}
+
+// Gets the children nodes of each node in the specified slice of nodes,
+// based on the sibling type request.
+func getChildrenNodes(nodes []*html.Node, st siblingType) []*html.Node {
+	return mapNodes(nodes, func(i int, n *html.Node) []*html.Node {
+		return getChildrenWithSiblingType(n, st, nil, nil)
+	})
+}
+
+// Gets the children of the specified parent, based on the requested sibling
+// type, skipping a specified node if required.
+func getChildrenWithSiblingType(parent *html.Node, st siblingType, skipNode *html.Node,
+	untilFunc func(*html.Node) bool) (result []*html.Node) {
+
+	// Create the iterator function
+	var iter = func(cur *html.Node) (ret *html.Node) {
+		// Based on the sibling type requested, iterate the right way
+		for {
+			switch st {
+			case siblingAll, siblingAllIncludingNonElements:
+				if cur == nil {
+					// First iteration, start with first child of parent
+					// Skip node if required
+					if ret = parent.FirstChild; ret == skipNode && skipNode != nil {
+						ret = skipNode.NextSibling
+					}
+				} else {
+					// Skip node if required
+					if ret = cur.NextSibling; ret == skipNode && skipNode != nil {
+						ret = skipNode.NextSibling
+					}
+				}
+			case siblingPrev, siblingPrevAll, siblingPrevUntil:
+				if cur == nil {
+					// Start with previous sibling of the skip node
+					ret = skipNode.PrevSibling
+				} else {
+					ret = cur.PrevSibling
+				}
+			case siblingNext, siblingNextAll, siblingNextUntil:
+				if cur == nil {
+					// Start with next sibling of the skip node
+					ret = skipNode.NextSibling
+				} else {
+					ret = cur.NextSibling
+				}
+			default:
+				panic("Invalid sibling type.")
+			}
+			if ret == nil || ret.Type == html.ElementNode || st == siblingAllIncludingNonElements {
+				return
+			}
+			// Not a valid node, try again from this one
+			cur = ret
+		}
+	}
+
+	for c := iter(nil); c != nil; c = iter(c) {
+		// If this is an ...Until case, test before append (returns true
+		// if the until condition is reached)
+		if st == siblingNextUntil || st == siblingPrevUntil {
+			if untilFunc(c) {
+				return
+			}
+		}
+		result = append(result, c)
+		if st == siblingNext || st == siblingPrev {
+			// Only one node was requested (immediate next or previous), so exit
+			return
+		}
+	}
+	return
+}
+
+// Internal implementation of parent nodes that return a raw slice of Nodes.
+func getParentNodes(nodes []*html.Node) []*html.Node {
+	return mapNodes(nodes, func(i int, n *html.Node) []*html.Node {
+		if n.Parent != nil && n.Parent.Type == html.ElementNode {
+			return []*html.Node{n.Parent}
+		}
+		return nil
+	})
+}
+
+// Internal map function used by many traversing methods. Takes the source nodes
+// to iterate on and the mapping function that returns an array of nodes.
+// Returns an array of nodes mapped by calling the callback function once for
+// each node in the source nodes.
+func mapNodes(nodes []*html.Node, f func(int, *html.Node) []*html.Node) (result []*html.Node) {
+	set := make(map[*html.Node]bool)
+	for i, n := range nodes {
+		if vals := f(i, n); len(vals) > 0 {
+			result = appendWithoutDuplicates(result, vals, set)
+		}
+	}
+	return result
+}

+ 141 - 0
vendor/github.com/PuerkitoBio/goquery/type.go

@@ -0,0 +1,141 @@
+package goquery
+
+import (
+	"errors"
+	"io"
+	"net/http"
+	"net/url"
+
+	"github.com/andybalholm/cascadia"
+
+	"golang.org/x/net/html"
+)
+
+// Document represents an HTML document to be manipulated. Unlike jQuery, which
+// is loaded as part of a DOM document, and thus acts upon its containing
+// document, GoQuery doesn't know which HTML document to act upon. So it needs
+// to be told, and that's what the Document class is for. It holds the root
+// document node to manipulate, and can make selections on this document.
+type Document struct {
+	*Selection
+	Url      *url.URL
+	rootNode *html.Node
+}
+
+// NewDocumentFromNode is a Document constructor that takes a root html Node
+// as argument.
+func NewDocumentFromNode(root *html.Node) *Document {
+	return newDocument(root, nil)
+}
+
+// NewDocument is a Document constructor that takes a string URL as argument.
+// It loads the specified document, parses it, and stores the root Document
+// node, ready to be manipulated.
+//
+// Deprecated: Use the net/http standard library package to make the request
+// and validate the response before calling goquery.NewDocumentFromReader
+// with the response's body.
+func NewDocument(url string) (*Document, error) {
+	// Load the URL
+	res, e := http.Get(url)
+	if e != nil {
+		return nil, e
+	}
+	return NewDocumentFromResponse(res)
+}
+
+// NewDocumentFromReader returns a Document from an io.Reader.
+// It returns an error as second value if the reader's data cannot be parsed
+// as html. It does not check if the reader is also an io.Closer, the
+// provided reader is never closed by this call. It is the responsibility
+// of the caller to close it if required.
+func NewDocumentFromReader(r io.Reader) (*Document, error) {
+	root, e := html.Parse(r)
+	if e != nil {
+		return nil, e
+	}
+	return newDocument(root, nil), nil
+}
+
+// NewDocumentFromResponse is another Document constructor that takes an http response as argument.
+// It loads the specified response's document, parses it, and stores the root Document
+// node, ready to be manipulated. The response's body is closed on return.
+//
+// Deprecated: Use goquery.NewDocumentFromReader with the response's body.
+func NewDocumentFromResponse(res *http.Response) (*Document, error) {
+	if res == nil {
+		return nil, errors.New("Response is nil")
+	}
+	defer res.Body.Close()
+	if res.Request == nil {
+		return nil, errors.New("Response.Request is nil")
+	}
+
+	// Parse the HTML into nodes
+	root, e := html.Parse(res.Body)
+	if e != nil {
+		return nil, e
+	}
+
+	// Create and fill the document
+	return newDocument(root, res.Request.URL), nil
+}
+
+// CloneDocument creates a deep-clone of a document.
+func CloneDocument(doc *Document) *Document {
+	return newDocument(cloneNode(doc.rootNode), doc.Url)
+}
+
+// Private constructor, make sure all fields are correctly filled.
+func newDocument(root *html.Node, url *url.URL) *Document {
+	// Create and fill the document
+	d := &Document{nil, url, root}
+	d.Selection = newSingleSelection(root, d)
+	return d
+}
+
+// Selection represents a collection of nodes matching some criteria. The
+// initial Selection can be created by using Document.Find, and then
+// manipulated using the jQuery-like chainable syntax and methods.
+type Selection struct {
+	Nodes    []*html.Node
+	document *Document
+	prevSel  *Selection
+}
+
+// Helper constructor to create an empty selection
+func newEmptySelection(doc *Document) *Selection {
+	return &Selection{nil, doc, nil}
+}
+
+// Helper constructor to create a selection of only one node
+func newSingleSelection(node *html.Node, doc *Document) *Selection {
+	return &Selection{[]*html.Node{node}, doc, nil}
+}
+
+// Matcher is an interface that defines the methods to match
+// HTML nodes against a compiled selector string. Cascadia's
+// Selector implements this interface.
+type Matcher interface {
+	Match(*html.Node) bool
+	MatchAll(*html.Node) []*html.Node
+	Filter([]*html.Node) []*html.Node
+}
+
+// compileMatcher compiles the selector string s and returns
+// the corresponding Matcher. If s is an invalid selector string,
+// it returns a Matcher that fails all matches.
+func compileMatcher(s string) Matcher {
+	cs, err := cascadia.Compile(s)
+	if err != nil {
+		return invalidMatcher{}
+	}
+	return cs
+}
+
+// invalidMatcher is a Matcher that always fails to match.
+type invalidMatcher struct{}
+
+func (invalidMatcher) Match(n *html.Node) bool             { return false }
+func (invalidMatcher) MatchAll(n *html.Node) []*html.Node  { return nil }
+func (invalidMatcher) Filter(ns []*html.Node) []*html.Node { return nil }

+ 161 - 0
vendor/github.com/PuerkitoBio/goquery/utilities.go

@@ -0,0 +1,161 @@
+package goquery
+
+import (
+	"bytes"
+
+	"golang.org/x/net/html"
+)
+
+// used to determine if a set (map[*html.Node]bool) should be used
+// instead of iterating over a slice. The set uses more memory and
+// is slower than slice iteration for small N.
+const minNodesForSet = 1000
+
+var nodeNames = []string{
+	html.ErrorNode:    "#error",
+	html.TextNode:     "#text",
+	html.DocumentNode: "#document",
+	html.CommentNode:  "#comment",
+}
+
+// NodeName returns the node name of the first element in the selection.
+// It tries to behave in a similar way as the DOM's nodeName property
+// (https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeName).
+//
+// Go's net/html package defines the following node types, listed with
+// the corresponding returned value from this function:
+//
+//     ErrorNode : #error
+//     TextNode : #text
+//     DocumentNode : #document
+//     ElementNode : the element's tag name
+//     CommentNode : #comment
+//     DoctypeNode : the name of the document type
+//
+func NodeName(s *Selection) string {
+	if s.Length() == 0 {
+		return ""
+	}
+	switch n := s.Get(0); n.Type {
+	case html.ElementNode, html.DoctypeNode:
+		return n.Data
+	default:
+		if n.Type >= 0 && int(n.Type) < len(nodeNames) {
+			return nodeNames[n.Type]
+		}
+		return ""
+	}
+}
+
+// OuterHtml returns the outer HTML rendering of the first item in
+// the selection - that is, the HTML including the first element's
+// tag and attributes.
+//
+// Unlike InnerHtml, this is a function and not a method on the Selection,
+// because this is not a jQuery method (in javascript-land, this is
+// a property provided by the DOM).
+func OuterHtml(s *Selection) (string, error) {
+	var buf bytes.Buffer
+
+	if s.Length() == 0 {
+		return "", nil
+	}
+	n := s.Get(0)
+	if err := html.Render(&buf, n); err != nil {
+		return "", err
+	}
+	return buf.String(), nil
+}
+
+// Loop through all container nodes to search for the target node.
+func sliceContains(container []*html.Node, contained *html.Node) bool {
+	for _, n := range container {
+		if nodeContains(n, contained) {
+			return true
+		}
+	}
+
+	return false
+}
+
+// Checks if the contained node is within the container node.
+func nodeContains(container *html.Node, contained *html.Node) bool {
+	// Check if the parent of the contained node is the container node, traversing
+	// upward until the top is reached, or the container is found.
+	for contained = contained.Parent; contained != nil; contained = contained.Parent {
+		if container == contained {
+			return true
+		}
+	}
+	return false
+}
+
+// Checks if the target node is in the slice of nodes.
+func isInSlice(slice []*html.Node, node *html.Node) bool {
+	return indexInSlice(slice, node) > -1
+}
+
+// Returns the index of the target node in the slice, or -1.
+func indexInSlice(slice []*html.Node, node *html.Node) int {
+	if node != nil {
+		for i, n := range slice {
+			if n == node {
+				return i
+			}
+		}
+	}
+	return -1
+}
+
+// Appends the new nodes to the target slice, making sure no duplicate is added.
+// There is no check to the original state of the target slice, so it may still
+// contain duplicates. The target slice is returned because append() may create
+// a new underlying array. If targetSet is nil, a local set is created with the
+// target if len(target) + len(nodes) is greater than minNodesForSet.
+func appendWithoutDuplicates(target []*html.Node, nodes []*html.Node, targetSet map[*html.Node]bool) []*html.Node {
+	// if there are not that many nodes, don't use the map, faster to just use nested loops
+	// (unless a non-nil targetSet is passed, in which case the caller knows better).
+	if targetSet == nil && len(target)+len(nodes) < minNodesForSet {
+		for _, n := range nodes {
+			if !isInSlice(target, n) {
+				target = append(target, n)
+			}
+		}
+		return target
+	}
+
+	// if a targetSet is passed, then assume it is reliable, otherwise create one
+	// and initialize it with the current target contents.
+	if targetSet == nil {
+		targetSet = make(map[*html.Node]bool, len(target))
+		for _, n := range target {
+			targetSet[n] = true
+		}
+	}
+	for _, n := range nodes {
+		if !targetSet[n] {
+			target = append(target, n)
+			targetSet[n] = true
+		}
+	}
+
+	return target
+}
+
+// Loop through a selection, returning only those nodes that pass the predicate
+// function.
+func grep(sel *Selection, predicate func(i int, s *Selection) bool) (result []*html.Node) {
+	for i, n := range sel.Nodes {
+		if predicate(i, newSingleSelection(n, sel.document)) {
+			result = append(result, n)
+		}
+	}
+	return result
+}
+
+// Creates a new Selection object based on the specified nodes, and keeps the
+// source Selection object on the stack (linked list).
+func pushStack(fromSel *Selection, nodes []*html.Node) *Selection {
+	result := &Selection{nodes, fromSel.document, fromSel}
+	return result
+}

+ 14 - 0
vendor/github.com/andybalholm/cascadia/.travis.yml

@@ -0,0 +1,14 @@
+language: go
+
+go:
+  - 1.3
+  - 1.4
+
+install:
+  - go get github.com/andybalholm/cascadia
+
+script:
+ - go test -v
+
+notifications:
+  email: false

+ 24 - 0
vendor/github.com/andybalholm/cascadia/LICENSE

@@ -0,0 +1,24 @@
+Copyright (c) 2011 Andy Balholm. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 7 - 0
vendor/github.com/andybalholm/cascadia/README.md

@@ -0,0 +1,7 @@
+# cascadia
+
+[![](https://travis-ci.org/andybalholm/cascadia.svg)](https://travis-ci.org/andybalholm/cascadia)
+
+The Cascadia package implements CSS selectors for use with the parse trees produced by the html package.
+
+To test CSS selectors without writing Go code, check out [cascadia](https://github.com/suntong/cascadia) the command line tool, a thin wrapper around this package.

+ 3 - 0
vendor/github.com/andybalholm/cascadia/go.mod

@@ -0,0 +1,3 @@
+module "github.com/andybalholm/cascadia"
+
+require "golang.org/x/net" v0.0.0-20180218175443-cbe0f9307d01

+ 835 - 0
vendor/github.com/andybalholm/cascadia/parser.go

@@ -0,0 +1,835 @@
+// Package cascadia is an implementation of CSS selectors.
+package cascadia
+
+import (
+	"errors"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"golang.org/x/net/html"
+)
+
+// a parser for CSS selectors
+type parser struct {
+	s string // the source text
+	i int    // the current position
+}
+
+// parseEscape parses a backslash escape.
+func (p *parser) parseEscape() (result string, err error) {
+	if len(p.s) < p.i+2 || p.s[p.i] != '\\' {
+		return "", errors.New("invalid escape sequence")
+	}
+
+	start := p.i + 1
+	c := p.s[start]
+	switch {
+	case c == '\r' || c == '\n' || c == '\f':
+		return "", errors.New("escaped line ending outside string")
+	case hexDigit(c):
+		// unicode escape (hex)
+		var i int
+		for i = start; i < p.i+6 && i < len(p.s) && hexDigit(p.s[i]); i++ {
+			// empty
+		}
+		v, _ := strconv.ParseUint(p.s[start:i], 16, 21)
+		if len(p.s) > i {
+			switch p.s[i] {
+			case '\r':
+				i++
+				if len(p.s) > i && p.s[i] == '\n' {
+					i++
+				}
+			case ' ', '\t', '\n', '\f':
+				i++
+			}
+		}
+		p.i = i
+		return string(rune(v)), nil
+	}
+
+	// Return the literal character after the backslash.
+	result = p.s[start : start+1]
+	p.i += 2
+	return result, nil
+}
+
+func hexDigit(c byte) bool {
+	return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F'
+}
+
+// nameStart returns whether c can be the first character of an identifier
+// (not counting an initial hyphen, or an escape sequence).
+func nameStart(c byte) bool {
+	return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || c == '_' || c > 127
+}
+
+// nameChar returns whether c can be a character within an identifier
+// (not counting an escape sequence).
+func nameChar(c byte) bool {
+	return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || c == '_' || c > 127 ||
+		c == '-' || '0' <= c && c <= '9'
+}
+
+// parseIdentifier parses an identifier.
+func (p *parser) parseIdentifier() (result string, err error) {
+	startingDash := false
+	if len(p.s) > p.i && p.s[p.i] == '-' {
+		startingDash = true
+		p.i++
+	}
+
+	if len(p.s) <= p.i {
+		return "", errors.New("expected identifier, found EOF instead")
+	}
+
+	if c := p.s[p.i]; !(nameStart(c) || c == '\\') {
+		return "", fmt.Errorf("expected identifier, found %c instead", c)
+	}
+
+	result, err = p.parseName()
+	if startingDash && err == nil {
+		result = "-" + result
+	}
+	return
+}
+
+// parseName parses a name (which is like an identifier, but doesn't have
+// extra restrictions on the first character).
+func (p *parser) parseName() (result string, err error) {
+	i := p.i
+loop:
+	for i < len(p.s) {
+		c := p.s[i]
+		switch {
+		case nameChar(c):
+			start := i
+			for i < len(p.s) && nameChar(p.s[i]) {
+				i++
+			}
+			result += p.s[start:i]
+		case c == '\\':
+			p.i = i
+			val, err := p.parseEscape()
+			if err != nil {
+				return "", err
+			}
+			i = p.i
+			result += val
+		default:
+			break loop
+		}
+	}
+
+	if result == "" {
+		return "", errors.New("expected name, found EOF instead")
+	}
+
+	p.i = i
+	return result, nil
+}
+
+// parseString parses a single- or double-quoted string.
+func (p *parser) parseString() (result string, err error) {
+	i := p.i
+	if len(p.s) < i+2 {
+		return "", errors.New("expected string, found EOF instead")
+	}
+
+	quote := p.s[i]
+	i++
+
+loop:
+	for i < len(p.s) {
+		switch p.s[i] {
+		case '\\':
+			if len(p.s) > i+1 {
+				switch c := p.s[i+1]; c {
+				case '\r':
+					if len(p.s) > i+2 && p.s[i+2] == '\n' {
+						i += 3
+						continue loop
+					}
+					fallthrough
+				case '\n', '\f':
+					i += 2
+					continue loop
+				}
+			}
+			p.i = i
+			val, err := p.parseEscape()
+			if err != nil {
+				return "", err
+			}
+			i = p.i
+			result += val
+		case quote:
+			break loop
+		case '\r', '\n', '\f':
+			return "", errors.New("unexpected end of line in string")
+		default:
+			start := i
+			for i < len(p.s) {
+				if c := p.s[i]; c == quote || c == '\\' || c == '\r' || c == '\n' || c == '\f' {
+					break
+				}
+				i++
+			}
+			result += p.s[start:i]
+		}
+	}
+
+	if i >= len(p.s) {
+		return "", errors.New("EOF in string")
+	}
+
+	// Consume the final quote.
+	i++
+
+	p.i = i
+	return result, nil
+}
+
+// parseRegex parses a regular expression; the end is defined by encountering an
+// unmatched closing ')' or ']' which is not consumed
+func (p *parser) parseRegex() (rx *regexp.Regexp, err error) {
+	i := p.i
+	if len(p.s) < i+2 {
+		return nil, errors.New("expected regular expression, found EOF instead")
+	}
+
+	// number of open parens or brackets;
+	// when it becomes negative, finished parsing regex
+	open := 0
+
+loop:
+	for i < len(p.s) {
+		switch p.s[i] {
+		case '(', '[':
+			open++
+		case ')', ']':
+			open--
+			if open < 0 {
+				break loop
+			}
+		}
+		i++
+	}
+
+	if i >= len(p.s) {
+		return nil, errors.New("EOF in regular expression")
+	}
+	rx, err = regexp.Compile(p.s[p.i:i])
+	p.i = i
+	return rx, err
+}
+
+// skipWhitespace consumes whitespace characters and comments.
+// It returns true if there was actually anything to skip.
+func (p *parser) skipWhitespace() bool {
+	i := p.i
+	for i < len(p.s) {
+		switch p.s[i] {
+		case ' ', '\t', '\r', '\n', '\f':
+			i++
+			continue
+		case '/':
+			if strings.HasPrefix(p.s[i:], "/*") {
+				end := strings.Index(p.s[i+len("/*"):], "*/")
+				if end != -1 {
+					i += end + len("/**/")
+					continue
+				}
+			}
+		}
+		break
+	}
+
+	if i > p.i {
+		p.i = i
+		return true
+	}
+
+	return false
+}
+
+// consumeParenthesis consumes an opening parenthesis and any following
+// whitespace. It returns true if there was actually a parenthesis to skip.
+func (p *parser) consumeParenthesis() bool {
+	if p.i < len(p.s) && p.s[p.i] == '(' {
+		p.i++
+		p.skipWhitespace()
+		return true
+	}
+	return false
+}
+
+// consumeClosingParenthesis consumes a closing parenthesis and any preceding
+// whitespace. It returns true if there was actually a parenthesis to skip.
+func (p *parser) consumeClosingParenthesis() bool {
+	i := p.i
+	p.skipWhitespace()
+	if p.i < len(p.s) && p.s[p.i] == ')' {
+		p.i++
+		return true
+	}
+	p.i = i
+	return false
+}
+
+// parseTypeSelector parses a type selector (one that matches by tag name).
+func (p *parser) parseTypeSelector() (result Selector, err error) {
+	tag, err := p.parseIdentifier()
+	if err != nil {
+		return nil, err
+	}
+
+	return typeSelector(tag), nil
+}
+
+// parseIDSelector parses a selector that matches by id attribute.
+func (p *parser) parseIDSelector() (Selector, error) {
+	if p.i >= len(p.s) {
+		return nil, fmt.Errorf("expected id selector (#id), found EOF instead")
+	}
+	if p.s[p.i] != '#' {
+		return nil, fmt.Errorf("expected id selector (#id), found '%c' instead", p.s[p.i])
+	}
+
+	p.i++
+	id, err := p.parseName()
+	if err != nil {
+		return nil, err
+	}
+
+	return attributeEqualsSelector("id", id), nil
+}
+
+// parseClassSelector parses a selector that matches by class attribute.
+func (p *parser) parseClassSelector() (Selector, error) {
+	if p.i >= len(p.s) {
+		return nil, fmt.Errorf("expected class selector (.class), found EOF instead")
+	}
+	if p.s[p.i] != '.' {
+		return nil, fmt.Errorf("expected class selector (.class), found '%c' instead", p.s[p.i])
+	}
+
+	p.i++
+	class, err := p.parseIdentifier()
+	if err != nil {
+		return nil, err
+	}
+
+	return attributeIncludesSelector("class", class), nil
+}
+
+// parseAttributeSelector parses a selector that matches by attribute value.
+func (p *parser) parseAttributeSelector() (Selector, error) {
+	if p.i >= len(p.s) {
+		return nil, fmt.Errorf("expected attribute selector ([attribute]), found EOF instead")
+	}
+	if p.s[p.i] != '[' {
+		return nil, fmt.Errorf("expected attribute selector ([attribute]), found '%c' instead", p.s[p.i])
+	}
+
+	p.i++
+	p.skipWhitespace()
+	key, err := p.parseIdentifier()
+	if err != nil {
+		return nil, err
+	}
+
+	p.skipWhitespace()
+	if p.i >= len(p.s) {
+		return nil, errors.New("unexpected EOF in attribute selector")
+	}
+
+	if p.s[p.i] == ']' {
+		p.i++
+		return attributeExistsSelector(key), nil
+	}
+
+	if p.i+2 >= len(p.s) {
+		return nil, errors.New("unexpected EOF in attribute selector")
+	}
+
+	op := p.s[p.i : p.i+2]
+	if op[0] == '=' {
+		op = "="
+	} else if op[1] != '=' {
+		return nil, fmt.Errorf(`expected equality operator, found "%s" instead`, op)
+	}
+	p.i += len(op)
+
+	p.skipWhitespace()
+	if p.i >= len(p.s) {
+		return nil, errors.New("unexpected EOF in attribute selector")
+	}
+	var val string
+	var rx *regexp.Regexp
+	if op == "#=" {
+		rx, err = p.parseRegex()
+	} else {
+		switch p.s[p.i] {
+		case '\'', '"':
+			val, err = p.parseString()
+		default:
+			val, err = p.parseIdentifier()
+		}
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	p.skipWhitespace()
+	if p.i >= len(p.s) {
+		return nil, errors.New("unexpected EOF in attribute selector")
+	}
+	if p.s[p.i] != ']' {
+		return nil, fmt.Errorf("expected ']', found '%c' instead", p.s[p.i])
+	}
+	p.i++
+
+	switch op {
+	case "=":
+		return attributeEqualsSelector(key, val), nil
+	case "!=":
+		return attributeNotEqualSelector(key, val), nil
+	case "~=":
+		return attributeIncludesSelector(key, val), nil
+	case "|=":
+		return attributeDashmatchSelector(key, val), nil
+	case "^=":
+		return attributePrefixSelector(key, val), nil
+	case "$=":
+		return attributeSuffixSelector(key, val), nil
+	case "*=":
+		return attributeSubstringSelector(key, val), nil
+	case "#=":
+		return attributeRegexSelector(key, rx), nil
+	}
+
+	return nil, fmt.Errorf("attribute operator %q is not supported", op)
+}
+
+var errExpectedParenthesis = errors.New("expected '(' but didn't find it")
+var errExpectedClosingParenthesis = errors.New("expected ')' but didn't find it")
+var errUnmatchedParenthesis = errors.New("unmatched '('")
+
+// parsePseudoclassSelector parses a pseudoclass selector like :not(p).
+func (p *parser) parsePseudoclassSelector() (Selector, error) {
+	if p.i >= len(p.s) {
+		return nil, fmt.Errorf("expected pseudoclass selector (:pseudoclass), found EOF instead")
+	}
+	if p.s[p.i] != ':' {
+		return nil, fmt.Errorf("expected attribute selector (:pseudoclass), found '%c' instead", p.s[p.i])
+	}
+
+	p.i++
+	name, err := p.parseIdentifier()
+	if err != nil {
+		return nil, err
+	}
+	name = toLowerASCII(name)
+
+	switch name {
+	case "not", "has", "haschild":
+		if !p.consumeParenthesis() {
+			return nil, errExpectedParenthesis
+		}
+		sel, parseErr := p.parseSelectorGroup()
+		if parseErr != nil {
+			return nil, parseErr
+		}
+		if !p.consumeClosingParenthesis() {
+			return nil, errExpectedClosingParenthesis
+		}
+
+		switch name {
+		case "not":
+			return negatedSelector(sel), nil
+		case "has":
+			return hasDescendantSelector(sel), nil
+		case "haschild":
+			return hasChildSelector(sel), nil
+		}
+
+	case "contains", "containsown":
+		if !p.consumeParenthesis() {
+			return nil, errExpectedParenthesis
+		}
+		if p.i == len(p.s) {
+			return nil, errUnmatchedParenthesis
+		}
+		var val string
+		switch p.s[p.i] {
+		case '\'', '"':
+			val, err = p.parseString()
+		default:
+			val, err = p.parseIdentifier()
+		}
+		if err != nil {
+			return nil, err
+		}
+		val = strings.ToLower(val)
+		p.skipWhitespace()
+		if p.i >= len(p.s) {
+			return nil, errors.New("unexpected EOF in pseudo selector")
+		}
+		if !p.consumeClosingParenthesis() {
+			return nil, errExpectedClosingParenthesis
+		}
+
+		switch name {
+		case "contains":
+			return textSubstrSelector(val), nil
+		case "containsown":
+			return ownTextSubstrSelector(val), nil
+		}
+
+	case "matches", "matchesown":
+		if !p.consumeParenthesis() {
+			return nil, errExpectedParenthesis
+		}
+		rx, err := p.parseRegex()
+		if err != nil {
+			return nil, err
+		}
+		if p.i >= len(p.s) {
+			return nil, errors.New("unexpected EOF in pseudo selector")
+		}
+		if !p.consumeClosingParenthesis() {
+			return nil, errExpectedClosingParenthesis
+		}
+
+		switch name {
+		case "matches":
+			return textRegexSelector(rx), nil
+		case "matchesown":
+			return ownTextRegexSelector(rx), nil
+		}
+
+	case "nth-child", "nth-last-child", "nth-of-type", "nth-last-of-type":
+		if !p.consumeParenthesis() {
+			return nil, errExpectedParenthesis
+		}
+		a, b, err := p.parseNth()
+		if err != nil {
+			return nil, err
+		}
+		if !p.consumeClosingParenthesis() {
+			return nil, errExpectedClosingParenthesis
+		}
+		if a == 0 {
+			switch name {
+			case "nth-child":
+				return simpleNthChildSelector(b, false), nil
+			case "nth-of-type":
+				return simpleNthChildSelector(b, true), nil
+			case "nth-last-child":
+				return simpleNthLastChildSelector(b, false), nil
+			case "nth-last-of-type":
+				return simpleNthLastChildSelector(b, true), nil
+			}
+		}
+		return nthChildSelector(a, b,
+				name == "nth-last-child" || name == "nth-last-of-type",
+				name == "nth-of-type" || name == "nth-last-of-type"),
+			nil
+
+	case "first-child":
+		return simpleNthChildSelector(1, false), nil
+	case "last-child":
+		return simpleNthLastChildSelector(1, false), nil
+	case "first-of-type":
+		return simpleNthChildSelector(1, true), nil
+	case "last-of-type":
+		return simpleNthLastChildSelector(1, true), nil
+	case "only-child":
+		return onlyChildSelector(false), nil
+	case "only-of-type":
+		return onlyChildSelector(true), nil
+	case "input":
+		return inputSelector, nil
+	case "empty":
+		return emptyElementSelector, nil
+	case "root":
+		return rootSelector, nil
+	}
+
+	return nil, fmt.Errorf("unknown pseudoclass :%s", name)
+}
+
+// parseInteger parses a  decimal integer.
+func (p *parser) parseInteger() (int, error) {
+	i := p.i
+	start := i
+	for i < len(p.s) && '0' <= p.s[i] && p.s[i] <= '9' {
+		i++
+	}
+	if i == start {
+		return 0, errors.New("expected integer, but didn't find it")
+	}
+	p.i = i
+
+	val, err := strconv.Atoi(p.s[start:i])
+	if err != nil {
+		return 0, err
+	}
+
+	return val, nil
+}
+
+// parseNth parses the argument for :nth-child (normally of the form an+b).
+func (p *parser) parseNth() (a, b int, err error) {
+	// initial state
+	if p.i >= len(p.s) {
+		goto eof
+	}
+	switch p.s[p.i] {
+	case '-':
+		p.i++
+		goto negativeA
+	case '+':
+		p.i++
+		goto positiveA
+	case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
+		goto positiveA
+	case 'n', 'N':
+		a = 1
+		p.i++
+		goto readN
+	case 'o', 'O', 'e', 'E':
+		id, nameErr := p.parseName()
+		if nameErr != nil {
+			return 0, 0, nameErr
+		}
+		id = toLowerASCII(id)
+		if id == "odd" {
+			return 2, 1, nil
+		}
+		if id == "even" {
+			return 2, 0, nil
+		}
+		return 0, 0, fmt.Errorf("expected 'odd' or 'even', but found '%s' instead", id)
+	default:
+		goto invalid
+	}
+
+positiveA:
+	if p.i >= len(p.s) {
+		goto eof
+	}
+	switch p.s[p.i] {
+	case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
+		a, err = p.parseInteger()
+		if err != nil {
+			return 0, 0, err
+		}
+		goto readA
+	case 'n', 'N':
+		a = 1
+		p.i++
+		goto readN
+	default:
+		goto invalid
+	}
+
+negativeA:
+	if p.i >= len(p.s) {
+		goto eof
+	}
+	switch p.s[p.i] {
+	case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
+		a, err = p.parseInteger()
+		if err != nil {
+			return 0, 0, err
+		}
+		a = -a
+		goto readA
+	case 'n', 'N':
+		a = -1
+		p.i++
+		goto readN
+	default:
+		goto invalid
+	}
+
+readA:
+	if p.i >= len(p.s) {
+		goto eof
+	}
+	switch p.s[p.i] {
+	case 'n', 'N':
+		p.i++
+		goto readN
+	default:
+		// The number we read as a is actually b.
+		return 0, a, nil
+	}
+
+readN:
+	p.skipWhitespace()
+	if p.i >= len(p.s) {
+		goto eof
+	}
+	switch p.s[p.i] {
+	case '+':
+		p.i++
+		p.skipWhitespace()
+		b, err = p.parseInteger()
+		if err != nil {
+			return 0, 0, err
+		}
+		return a, b, nil
+	case '-':
+		p.i++
+		p.skipWhitespace()
+		b, err = p.parseInteger()
+		if err != nil {
+			return 0, 0, err
+		}
+		return a, -b, nil
+	default:
+		return a, 0, nil
+	}
+
+eof:
+	return 0, 0, errors.New("unexpected EOF while attempting to parse expression of form an+b")
+
+invalid:
+	return 0, 0, errors.New("unexpected character while attempting to parse expression of form an+b")
+}
+
+// parseSimpleSelectorSequence parses a selector sequence that applies to
+// a single element.
+func (p *parser) parseSimpleSelectorSequence() (Selector, error) {
+	var result Selector
+
+	if p.i >= len(p.s) {
+		return nil, errors.New("expected selector, found EOF instead")
+	}
+
+	switch p.s[p.i] {
+	case '*':
+		// It's the universal selector. Just skip over it, since it doesn't affect the meaning.
+		p.i++
+	case '#', '.', '[', ':':
+		// There's no type selector. Wait to process the other till the main loop.
+	default:
+		r, err := p.parseTypeSelector()
+		if err != nil {
+			return nil, err
+		}
+		result = r
+	}
+
+loop:
+	for p.i < len(p.s) {
+		var ns Selector
+		var err error
+		switch p.s[p.i] {
+		case '#':
+			ns, err = p.parseIDSelector()
+		case '.':
+			ns, err = p.parseClassSelector()
+		case '[':
+			ns, err = p.parseAttributeSelector()
+		case ':':
+			ns, err = p.parsePseudoclassSelector()
+		default:
+			break loop
+		}
+		if err != nil {
+			return nil, err
+		}
+		if result == nil {
+			result = ns
+		} else {
+			result = intersectionSelector(result, ns)
+		}
+	}
+
+	if result == nil {
+		result = func(n *html.Node) bool {
+			return n.Type == html.ElementNode
+		}
+	}
+
+	return result, nil
+}
+
+// parseSelector parses a selector that may include combinators.
+func (p *parser) parseSelector() (result Selector, err error) {
+	p.skipWhitespace()
+	result, err = p.parseSimpleSelectorSequence()
+	if err != nil {
+		return
+	}
+
+	for {
+		var combinator byte
+		if p.skipWhitespace() {
+			combinator = ' '
+		}
+		if p.i >= len(p.s) {
+			return
+		}
+
+		switch p.s[p.i] {
+		case '+', '>', '~':
+			combinator = p.s[p.i]
+			p.i++
+			p.skipWhitespace()
+		case ',', ')':
+			// These characters can't begin a selector, but they can legally occur after one.
+			return
+		}
+
+		if combinator == 0 {
+			return
+		}
+
+		c, err := p.parseSimpleSelectorSequence()
+		if err != nil {
+			return nil, err
+		}
+
+		switch combinator {
+		case ' ':
+			result = descendantSelector(result, c)
+		case '>':
+			result = childSelector(result, c)
+		case '+':
+			result = siblingSelector(result, c, true)
+		case '~':
+			result = siblingSelector(result, c, false)
+		}
+	}
+
+	panic("unreachable")
+}
+
+// parseSelectorGroup parses a group of selectors, separated by commas.
+func (p *parser) parseSelectorGroup() (result Selector, err error) {
+	result, err = p.parseSelector()
+	if err != nil {
+		return
+	}
+
+	for p.i < len(p.s) {
+		if p.s[p.i] != ',' {
+			return result, nil
+		}
+		p.i++
+		c, err := p.parseSelector()
+		if err != nil {
+			return nil, err
+		}
+		result = unionSelector(result, c)
+	}
+
+	return
+}

+ 622 - 0
vendor/github.com/andybalholm/cascadia/selector.go

@@ -0,0 +1,622 @@
+package cascadia
+
+import (
+	"bytes"
+	"fmt"
+	"regexp"
+	"strings"
+
+	"golang.org/x/net/html"
+)
+
+// the Selector type, and functions for creating them
+
+// A Selector is a function which tells whether a node matches or not.
+type Selector func(*html.Node) bool
+
+// hasChildMatch returns whether n has any child that matches a.
+func hasChildMatch(n *html.Node, a Selector) bool {
+	for c := n.FirstChild; c != nil; c = c.NextSibling {
+		if a(c) {
+			return true
+		}
+	}
+	return false
+}
+
+// hasDescendantMatch performs a depth-first search of n's descendants,
+// testing whether any of them match a. It returns true as soon as a match is
+// found, or false if no match is found.
+func hasDescendantMatch(n *html.Node, a Selector) bool {
+	for c := n.FirstChild; c != nil; c = c.NextSibling {
+		if a(c) || (c.Type == html.ElementNode && hasDescendantMatch(c, a)) {
+			return true
+		}
+	}
+	return false
+}
+
+// Compile parses a selector and returns, if successful, a Selector object
+// that can be used to match against html.Node objects.
+func Compile(sel string) (Selector, error) {
+	p := &parser{s: sel}
+	compiled, err := p.parseSelectorGroup()
+	if err != nil {
+		return nil, err
+	}
+
+	if p.i < len(sel) {
+		return nil, fmt.Errorf("parsing %q: %d bytes left over", sel, len(sel)-p.i)
+	}
+
+	return compiled, nil
+}
+
+// MustCompile is like Compile, but panics instead of returning an error.
+func MustCompile(sel string) Selector {
+	compiled, err := Compile(sel)
+	if err != nil {
+		panic(err)
+	}
+	return compiled
+}
+
+// MatchAll returns a slice of the nodes that match the selector,
+// from n and its children.
+func (s Selector) MatchAll(n *html.Node) []*html.Node {
+	return s.matchAllInto(n, nil)
+}
+
+func (s Selector) matchAllInto(n *html.Node, storage []*html.Node) []*html.Node {
+	if s(n) {
+		storage = append(storage, n)
+	}
+
+	for child := n.FirstChild; child != nil; child = child.NextSibling {
+		storage = s.matchAllInto(child, storage)
+	}
+
+	return storage
+}
+
+// Match returns true if the node matches the selector.
+func (s Selector) Match(n *html.Node) bool {
+	return s(n)
+}
+
+// MatchFirst returns the first node that matches s, from n and its children.
+func (s Selector) MatchFirst(n *html.Node) *html.Node {
+	if s.Match(n) {
+		return n
+	}
+
+	for c := n.FirstChild; c != nil; c = c.NextSibling {
+		m := s.MatchFirst(c)
+		if m != nil {
+			return m
+		}
+	}
+	return nil
+}
+
+// Filter returns the nodes in nodes that match the selector.
+func (s Selector) Filter(nodes []*html.Node) (result []*html.Node) {
+	for _, n := range nodes {
+		if s(n) {
+			result = append(result, n)
+		}
+	}
+	return result
+}
+
+// typeSelector returns a Selector that matches elements with a given tag name.
+func typeSelector(tag string) Selector {
+	tag = toLowerASCII(tag)
+	return func(n *html.Node) bool {
+		return n.Type == html.ElementNode && n.Data == tag
+	}
+}
+
+// toLowerASCII returns s with all ASCII capital letters lowercased.
+func toLowerASCII(s string) string {
+	var b []byte
+	for i := 0; i < len(s); i++ {
+		if c := s[i]; 'A' <= c && c <= 'Z' {
+			if b == nil {
+				b = make([]byte, len(s))
+				copy(b, s)
+			}
+			b[i] = s[i] + ('a' - 'A')
+		}
+	}
+
+	if b == nil {
+		return s
+	}
+
+	return string(b)
+}
+
+// attributeSelector returns a Selector that matches elements
+// where the attribute named key satisifes the function f.
+func attributeSelector(key string, f func(string) bool) Selector {
+	key = toLowerASCII(key)
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+		for _, a := range n.Attr {
+			if a.Key == key && f(a.Val) {
+				return true
+			}
+		}
+		return false
+	}
+}
+
+// attributeExistsSelector returns a Selector that matches elements that have
+// an attribute named key.
+func attributeExistsSelector(key string) Selector {
+	return attributeSelector(key, func(string) bool { return true })
+}
+
+// attributeEqualsSelector returns a Selector that matches elements where
+// the attribute named key has the value val.
+func attributeEqualsSelector(key, val string) Selector {
+	return attributeSelector(key,
+		func(s string) bool {
+			return s == val
+		})
+}
+
+// attributeNotEqualSelector returns a Selector that matches elements where
+// the attribute named key does not have the value val.
+func attributeNotEqualSelector(key, val string) Selector {
+	key = toLowerASCII(key)
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+		for _, a := range n.Attr {
+			if a.Key == key && a.Val == val {
+				return false
+			}
+		}
+		return true
+	}
+}
+
+// attributeIncludesSelector returns a Selector that matches elements where
+// the attribute named key is a whitespace-separated list that includes val.
+func attributeIncludesSelector(key, val string) Selector {
+	return attributeSelector(key,
+		func(s string) bool {
+			for s != "" {
+				i := strings.IndexAny(s, " \t\r\n\f")
+				if i == -1 {
+					return s == val
+				}
+				if s[:i] == val {
+					return true
+				}
+				s = s[i+1:]
+			}
+			return false
+		})
+}
+
+// attributeDashmatchSelector returns a Selector that matches elements where
+// the attribute named key equals val or starts with val plus a hyphen.
+func attributeDashmatchSelector(key, val string) Selector {
+	return attributeSelector(key,
+		func(s string) bool {
+			if s == val {
+				return true
+			}
+			if len(s) <= len(val) {
+				return false
+			}
+			if s[:len(val)] == val && s[len(val)] == '-' {
+				return true
+			}
+			return false
+		})
+}
+
+// attributePrefixSelector returns a Selector that matches elements where
+// the attribute named key starts with val.
+func attributePrefixSelector(key, val string) Selector {
+	return attributeSelector(key,
+		func(s string) bool {
+			if strings.TrimSpace(s) == "" {
+				return false
+			}
+			return strings.HasPrefix(s, val)
+		})
+}
+
+// attributeSuffixSelector returns a Selector that matches elements where
+// the attribute named key ends with val.
+func attributeSuffixSelector(key, val string) Selector {
+	return attributeSelector(key,
+		func(s string) bool {
+			if strings.TrimSpace(s) == "" {
+				return false
+			}
+			return strings.HasSuffix(s, val)
+		})
+}
+
+// attributeSubstringSelector returns a Selector that matches nodes where
+// the attribute named key contains val.
+func attributeSubstringSelector(key, val string) Selector {
+	return attributeSelector(key,
+		func(s string) bool {
+			if strings.TrimSpace(s) == "" {
+				return false
+			}
+			return strings.Contains(s, val)
+		})
+}
+
+// attributeRegexSelector returns a Selector that matches nodes where
+// the attribute named key matches the regular expression rx
+func attributeRegexSelector(key string, rx *regexp.Regexp) Selector {
+	return attributeSelector(key,
+		func(s string) bool {
+			return rx.MatchString(s)
+		})
+}
+
+// intersectionSelector returns a selector that matches nodes that match
+// both a and b.
+func intersectionSelector(a, b Selector) Selector {
+	return func(n *html.Node) bool {
+		return a(n) && b(n)
+	}
+}
+
+// unionSelector returns a selector that matches elements that match
+// either a or b.
+func unionSelector(a, b Selector) Selector {
+	return func(n *html.Node) bool {
+		return a(n) || b(n)
+	}
+}
+
+// negatedSelector returns a selector that matches elements that do not match a.
+func negatedSelector(a Selector) Selector {
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+		return !a(n)
+	}
+}
+
+// writeNodeText writes the text contained in n and its descendants to b.
+func writeNodeText(n *html.Node, b *bytes.Buffer) {
+	switch n.Type {
+	case html.TextNode:
+		b.WriteString(n.Data)
+	case html.ElementNode:
+		for c := n.FirstChild; c != nil; c = c.NextSibling {
+			writeNodeText(c, b)
+		}
+	}
+}
+
+// nodeText returns the text contained in n and its descendants.
+func nodeText(n *html.Node) string {
+	var b bytes.Buffer
+	writeNodeText(n, &b)
+	return b.String()
+}
+
+// nodeOwnText returns the contents of the text nodes that are direct
+// children of n.
+func nodeOwnText(n *html.Node) string {
+	var b bytes.Buffer
+	for c := n.FirstChild; c != nil; c = c.NextSibling {
+		if c.Type == html.TextNode {
+			b.WriteString(c.Data)
+		}
+	}
+	return b.String()
+}
+
+// textSubstrSelector returns a selector that matches nodes that
+// contain the given text.
+func textSubstrSelector(val string) Selector {
+	return func(n *html.Node) bool {
+		text := strings.ToLower(nodeText(n))
+		return strings.Contains(text, val)
+	}
+}
+
+// ownTextSubstrSelector returns a selector that matches nodes that
+// directly contain the given text
+func ownTextSubstrSelector(val string) Selector {
+	return func(n *html.Node) bool {
+		text := strings.ToLower(nodeOwnText(n))
+		return strings.Contains(text, val)
+	}
+}
+
+// textRegexSelector returns a selector that matches nodes whose text matches
+// the specified regular expression
+func textRegexSelector(rx *regexp.Regexp) Selector {
+	return func(n *html.Node) bool {
+		return rx.MatchString(nodeText(n))
+	}
+}
+
+// ownTextRegexSelector returns a selector that matches nodes whose text
+// directly matches the specified regular expression
+func ownTextRegexSelector(rx *regexp.Regexp) Selector {
+	return func(n *html.Node) bool {
+		return rx.MatchString(nodeOwnText(n))
+	}
+}
+
+// hasChildSelector returns a selector that matches elements
+// with a child that matches a.
+func hasChildSelector(a Selector) Selector {
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+		return hasChildMatch(n, a)
+	}
+}
+
+// hasDescendantSelector returns a selector that matches elements
+// with any descendant that matches a.
+func hasDescendantSelector(a Selector) Selector {
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+		return hasDescendantMatch(n, a)
+	}
+}
+
+// nthChildSelector returns a selector that implements :nth-child(an+b).
+// If last is true, implements :nth-last-child instead.
+// If ofType is true, implements :nth-of-type instead.
+func nthChildSelector(a, b int, last, ofType bool) Selector {
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+
+		parent := n.Parent
+		if parent == nil {
+			return false
+		}
+
+		if parent.Type == html.DocumentNode {
+			return false
+		}
+
+		i := -1
+		count := 0
+		for c := parent.FirstChild; c != nil; c = c.NextSibling {
+			if (c.Type != html.ElementNode) || (ofType && c.Data != n.Data) {
+				continue
+			}
+			count++
+			if c == n {
+				i = count
+				if !last {
+					break
+				}
+			}
+		}
+
+		if i == -1 {
+			// This shouldn't happen, since n should always be one of its parent's children.
+			return false
+		}
+
+		if last {
+			i = count - i + 1
+		}
+
+		i -= b
+		if a == 0 {
+			return i == 0
+		}
+
+		return i%a == 0 && i/a >= 0
+	}
+}
+
+// simpleNthChildSelector returns a selector that implements :nth-child(b).
+// If ofType is true, implements :nth-of-type instead.
+func simpleNthChildSelector(b int, ofType bool) Selector {
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+
+		parent := n.Parent
+		if parent == nil {
+			return false
+		}
+
+		if parent.Type == html.DocumentNode {
+			return false
+		}
+
+		count := 0
+		for c := parent.FirstChild; c != nil; c = c.NextSibling {
+			if c.Type != html.ElementNode || (ofType && c.Data != n.Data) {
+				continue
+			}
+			count++
+			if c == n {
+				return count == b
+			}
+			if count >= b {
+				return false
+			}
+		}
+		return false
+	}
+}
+
+// simpleNthLastChildSelector returns a selector that implements
+// :nth-last-child(b). If ofType is true, implements :nth-last-of-type
+// instead.
+func simpleNthLastChildSelector(b int, ofType bool) Selector {
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+
+		parent := n.Parent
+		if parent == nil {
+			return false
+		}
+
+		if parent.Type == html.DocumentNode {
+			return false
+		}
+
+		count := 0
+		for c := parent.LastChild; c != nil; c = c.PrevSibling {
+			if c.Type != html.ElementNode || (ofType && c.Data != n.Data) {
+				continue
+			}
+			count++
+			if c == n {
+				return count == b
+			}
+			if count >= b {
+				return false
+			}
+		}
+		return false
+	}
+}
+
+// onlyChildSelector returns a selector that implements :only-child.
+// If ofType is true, it implements :only-of-type instead.
+func onlyChildSelector(ofType bool) Selector {
+	return func(n *html.Node) bool {
+		if n.Type != html.ElementNode {
+			return false
+		}
+
+		parent := n.Parent
+		if parent == nil {
+			return false
+		}
+
+		if parent.Type == html.DocumentNode {
+			return false
+		}
+
+		count := 0
+		for c := parent.FirstChild; c != nil; c = c.NextSibling {
+			if (c.Type != html.ElementNode) || (ofType && c.Data != n.Data) {
+				continue
+			}
+			count++
+			if count > 1 {
+				return false
+			}
+		}
+
+		return count == 1
+	}
+}
+
+// inputSelector is a Selector that matches input, select, textarea and button elements.
+func inputSelector(n *html.Node) bool {
+	return n.Type == html.ElementNode && (n.Data == "input" || n.Data == "select" || n.Data == "textarea" || n.Data == "button")
+}
+
+// emptyElementSelector is a Selector that matches empty elements.
+func emptyElementSelector(n *html.Node) bool {
+	if n.Type != html.ElementNode {
+		return false
+	}
+
+	for c := n.FirstChild; c != nil; c = c.NextSibling {
+		switch c.Type {
+		case html.ElementNode, html.TextNode:
+			return false
+		}
+	}
+
+	return true
+}
+
+// descendantSelector returns a Selector that matches an element if
+// it matches d and has an ancestor that matches a.
+func descendantSelector(a, d Selector) Selector {
+	return func(n *html.Node) bool {
+		if !d(n) {
+			return false
+		}
+
+		for p := n.Parent; p != nil; p = p.Parent {
+			if a(p) {
+				return true
+			}
+		}
+
+		return false
+	}
+}
+
+// childSelector returns a Selector that matches an element if
+// it matches d and its parent matches a.
+func childSelector(a, d Selector) Selector {
+	return func(n *html.Node) bool {
+		return d(n) && n.Parent != nil && a(n.Parent)
+	}
+}
+
+// siblingSelector returns a Selector that matches an element
+// if it matches s2 and in is preceded by an element that matches s1.
+// If adjacent is true, the sibling must be immediately before the element.
+func siblingSelector(s1, s2 Selector, adjacent bool) Selector {
+	return func(n *html.Node) bool {
+		if !s2(n) {
+			return false
+		}
+
+		if adjacent {
+			for n = n.PrevSibling; n != nil; n = n.PrevSibling {
+				if n.Type == html.TextNode || n.Type == html.CommentNode {
+					continue
+				}
+				return s1(n)
+			}
+			return false
+		}
+
+		// Walk backwards looking for element that matches s1
+		for c := n.PrevSibling; c != nil; c = c.PrevSibling {
+			if s1(c) {
+				return true
+			}
+		}
+
+		return false
+	}
+}
+
+// rootSelector implements :root
+func rootSelector(n *html.Node) bool {
+	if n.Type != html.ElementNode {
+		return false
+	}
+	if n.Parent == nil {
+		return false
+	}
+	return n.Parent.Type == html.DocumentNode
+}

+ 15 - 0
vendor/github.com/davecgh/go-spew/LICENSE

@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2012-2016 Dave Collins <dave@davec.name>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

+ 145 - 0
vendor/github.com/davecgh/go-spew/spew/bypass.go

@@ -0,0 +1,145 @@
+// Copyright (c) 2015-2016 Dave Collins <dave@davec.name>
+//
+// Permission to use, copy, modify, and distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+// NOTE: Due to the following build constraints, this file will only be compiled
+// when the code is not running on Google App Engine, compiled by GopherJS, and
+// "-tags safe" is not added to the go build command line.  The "disableunsafe"
+// tag is deprecated and thus should not be used.
+// Go versions prior to 1.4 are disabled because they use a different layout
+// for interfaces which make the implementation of unsafeReflectValue more complex.
+// +build !js,!appengine,!safe,!disableunsafe,go1.4
+
+package spew
+
+import (
+	"reflect"
+	"unsafe"
+)
+
+const (
+	// UnsafeDisabled is a build-time constant which specifies whether or
+	// not access to the unsafe package is available.
+	UnsafeDisabled = false
+
+	// ptrSize is the size of a pointer on the current arch.
+	ptrSize = unsafe.Sizeof((*byte)(nil))
+)
+
+type flag uintptr
+
+var (
+	// flagRO indicates whether the value field of a reflect.Value
+	// is read-only.
+	flagRO flag
+
+	// flagAddr indicates whether the address of the reflect.Value's
+	// value may be taken.
+	flagAddr flag
+)
+
+// flagKindMask holds the bits that make up the kind
+// part of the flags field. In all the supported versions,
+// it is in the lower 5 bits.
+const flagKindMask = flag(0x1f)
+
+// Different versions of Go have used different
+// bit layouts for the flags type. This table
+// records the known combinations.
+var okFlags = []struct {
+	ro, addr flag
+}{{
+	// From Go 1.4 to 1.5
+	ro:   1 << 5,
+	addr: 1 << 7,
+}, {
+	// Up to Go tip.
+	ro:   1<<5 | 1<<6,
+	addr: 1 << 8,
+}}
+
+var flagValOffset = func() uintptr {
+	field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag")
+	if !ok {
+		panic("reflect.Value has no flag field")
+	}
+	return field.Offset
+}()
+
+// flagField returns a pointer to the flag field of a reflect.Value.
+func flagField(v *reflect.Value) *flag {
+	return (*flag)(unsafe.Pointer(uintptr(unsafe.Pointer(v)) + flagValOffset))
+}
+
+// unsafeReflectValue converts the passed reflect.Value into a one that bypasses
+// the typical safety restrictions preventing access to unaddressable and
+// unexported data.  It works by digging the raw pointer to the underlying
+// value out of the protected value and generating a new unprotected (unsafe)
+// reflect.Value to it.
+//
+// This allows us to check for implementations of the Stringer and error
+// interfaces to be used for pretty printing ordinarily unaddressable and
+// inaccessible values such as unexported struct fields.
+func unsafeReflectValue(v reflect.Value) reflect.Value {
+	if !v.IsValid() || (v.CanInterface() && v.CanAddr()) {
+		return v
+	}
+	flagFieldPtr := flagField(&v)
+	*flagFieldPtr &^= flagRO
+	*flagFieldPtr |= flagAddr
+	return v
+}
+
+// Sanity checks against future reflect package changes
+// to the type or semantics of the Value.flag field.
+func init() {
+	field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag")
+	if !ok {
+		panic("reflect.Value has no flag field")
+	}
+	if field.Type.Kind() != reflect.TypeOf(flag(0)).Kind() {
+		panic("reflect.Value flag field has changed kind")
+	}
+	type t0 int
+	var t struct {
+		A t0
+		// t0 will have flagEmbedRO set.
+		t0
+		// a will have flagStickyRO set
+		a t0
+	}
+	vA := reflect.ValueOf(t).FieldByName("A")
+	va := reflect.ValueOf(t).FieldByName("a")
+	vt0 := reflect.ValueOf(t).FieldByName("t0")
+
+	// Infer flagRO from the difference between the flags
+	// for the (otherwise identical) fields in t.
+	flagPublic := *flagField(&vA)
+	flagWithRO := *flagField(&va) | *flagField(&vt0)
+	flagRO = flagPublic ^ flagWithRO
+
+	// Infer flagAddr from the difference between a value
+	// taken from a pointer and not.
+	vPtrA := reflect.ValueOf(&t).Elem().FieldByName("A")
+	flagNoPtr := *flagField(&vA)
+	flagPtr := *flagField(&vPtrA)
+	flagAddr = flagNoPtr ^ flagPtr
+
+	// Check that the inferred flags tally with one of the known versions.
+	for _, f := range okFlags {
+		if flagRO == f.ro && flagAddr == f.addr {
+			return
+		}
+	}
+	panic("reflect.Value read-only flag has changed semantics")
+}

+ 38 - 0
vendor/github.com/davecgh/go-spew/spew/bypasssafe.go

@@ -0,0 +1,38 @@
+// Copyright (c) 2015-2016 Dave Collins <dave@davec.name>
+//
+// Permission to use, copy, modify, and distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+// NOTE: Due to the following build constraints, this file will only be compiled
+// when the code is running on Google App Engine, compiled by GopherJS, or
+// "-tags safe" is added to the go build command line.  The "disableunsafe"
+// tag is deprecated and thus should not be used.
+// +build js appengine safe disableunsafe !go1.4
+
+package spew
+
+import "reflect"
+
+const (
+	// UnsafeDisabled is a build-time constant which specifies whether or
+	// not access to the unsafe package is available.
+	UnsafeDisabled = true
+)
+
+// unsafeReflectValue typically converts the passed reflect.Value into a one
+// that bypasses the typical safety restrictions preventing access to
+// unaddressable and unexported data.  However, doing this relies on access to
+// the unsafe package.  This is a stub version which simply returns the passed
+// reflect.Value when the unsafe package is not available.
+func unsafeReflectValue(v reflect.Value) reflect.Value {
+	return v
+}

+ 341 - 0
vendor/github.com/davecgh/go-spew/spew/common.go

@@ -0,0 +1,341 @@
+/*
+ * Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+package spew
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"reflect"
+	"sort"
+	"strconv"
+)
+
+// Some constants in the form of bytes to avoid string overhead.  This mirrors
+// the technique used in the fmt package.
+var (
+	panicBytes            = []byte("(PANIC=")
+	plusBytes             = []byte("+")
+	iBytes                = []byte("i")
+	trueBytes             = []byte("true")
+	falseBytes            = []byte("false")
+	interfaceBytes        = []byte("(interface {})")
+	commaNewlineBytes     = []byte(",\n")
+	newlineBytes          = []byte("\n")
+	openBraceBytes        = []byte("{")
+	openBraceNewlineBytes = []byte("{\n")
+	closeBraceBytes       = []byte("}")
+	asteriskBytes         = []byte("*")
+	colonBytes            = []byte(":")
+	colonSpaceBytes       = []byte(": ")
+	openParenBytes        = []byte("(")
+	closeParenBytes       = []byte(")")
+	spaceBytes            = []byte(" ")
+	pointerChainBytes     = []byte("->")
+	nilAngleBytes         = []byte("<nil>")
+	maxNewlineBytes       = []byte("<max depth reached>\n")
+	maxShortBytes         = []byte("<max>")
+	circularBytes         = []byte("<already shown>")
+	circularShortBytes    = []byte("<shown>")
+	invalidAngleBytes     = []byte("<invalid>")
+	openBracketBytes      = []byte("[")
+	closeBracketBytes     = []byte("]")
+	percentBytes          = []byte("%")
+	precisionBytes        = []byte(".")
+	openAngleBytes        = []byte("<")
+	closeAngleBytes       = []byte(">")
+	openMapBytes          = []byte("map[")
+	closeMapBytes         = []byte("]")
+	lenEqualsBytes        = []byte("len=")
+	capEqualsBytes        = []byte("cap=")
+)
+
+// hexDigits is used to map a decimal value to a hex digit.
+var hexDigits = "0123456789abcdef"
+
+// catchPanic handles any panics that might occur during the handleMethods
+// calls.
+func catchPanic(w io.Writer, v reflect.Value) {
+	if err := recover(); err != nil {
+		w.Write(panicBytes)
+		fmt.Fprintf(w, "%v", err)
+		w.Write(closeParenBytes)
+	}
+}
+
+// handleMethods attempts to call the Error and String methods on the underlying
+// type the passed reflect.Value represents and outputes the result to Writer w.
+//
+// It handles panics in any called methods by catching and displaying the error
+// as the formatted value.
+func handleMethods(cs *ConfigState, w io.Writer, v reflect.Value) (handled bool) {
+	// We need an interface to check if the type implements the error or
+	// Stringer interface.  However, the reflect package won't give us an
+	// interface on certain things like unexported struct fields in order
+	// to enforce visibility rules.  We use unsafe, when it's available,
+	// to bypass these restrictions since this package does not mutate the
+	// values.
+	if !v.CanInterface() {
+		if UnsafeDisabled {
+			return false
+		}
+
+		v = unsafeReflectValue(v)
+	}
+
+	// Choose whether or not to do error and Stringer interface lookups against
+	// the base type or a pointer to the base type depending on settings.
+	// Technically calling one of these methods with a pointer receiver can
+	// mutate the value, however, types which choose to satisify an error or
+	// Stringer interface with a pointer receiver should not be mutating their
+	// state inside these interface methods.
+	if !cs.DisablePointerMethods && !UnsafeDisabled && !v.CanAddr() {
+		v = unsafeReflectValue(v)
+	}
+	if v.CanAddr() {
+		v = v.Addr()
+	}
+
+	// Is it an error or Stringer?
+	switch iface := v.Interface().(type) {
+	case error:
+		defer catchPanic(w, v)
+		if cs.ContinueOnMethod {
+			w.Write(openParenBytes)
+			w.Write([]byte(iface.Error()))
+			w.Write(closeParenBytes)
+			w.Write(spaceBytes)
+			return false
+		}
+
+		w.Write([]byte(iface.Error()))
+		return true
+
+	case fmt.Stringer:
+		defer catchPanic(w, v)
+		if cs.ContinueOnMethod {
+			w.Write(openParenBytes)
+			w.Write([]byte(iface.String()))
+			w.Write(closeParenBytes)
+			w.Write(spaceBytes)
+			return false
+		}
+		w.Write([]byte(iface.String()))
+		return true
+	}
+	return false
+}
+
+// printBool outputs a boolean value as true or false to Writer w.
+func printBool(w io.Writer, val bool) {
+	if val {
+		w.Write(trueBytes)
+	} else {
+		w.Write(falseBytes)
+	}
+}
+
+// printInt outputs a signed integer value to Writer w.
+func printInt(w io.Writer, val int64, base int) {
+	w.Write([]byte(strconv.FormatInt(val, base)))
+}
+
+// printUint outputs an unsigned integer value to Writer w.
+func printUint(w io.Writer, val uint64, base int) {
+	w.Write([]byte(strconv.FormatUint(val, base)))
+}
+
+// printFloat outputs a floating point value using the specified precision,
+// which is expected to be 32 or 64bit, to Writer w.
+func printFloat(w io.Writer, val float64, precision int) {
+	w.Write([]byte(strconv.FormatFloat(val, 'g', -1, precision)))
+}
+
+// printComplex outputs a complex value using the specified float precision
+// for the real and imaginary parts to Writer w.
+func printComplex(w io.Writer, c complex128, floatPrecision int) {
+	r := real(c)
+	w.Write(openParenBytes)
+	w.Write([]byte(strconv.FormatFloat(r, 'g', -1, floatPrecision)))
+	i := imag(c)
+	if i >= 0 {
+		w.Write(plusBytes)
+	}
+	w.Write([]byte(strconv.FormatFloat(i, 'g', -1, floatPrecision)))
+	w.Write(iBytes)
+	w.Write(closeParenBytes)
+}
+
+// printHexPtr outputs a uintptr formatted as hexadecimal with a leading '0x'
+// prefix to Writer w.
+func printHexPtr(w io.Writer, p uintptr) {
+	// Null pointer.
+	num := uint64(p)
+	if num == 0 {
+		w.Write(nilAngleBytes)
+		return
+	}
+
+	// Max uint64 is 16 bytes in hex + 2 bytes for '0x' prefix
+	buf := make([]byte, 18)
+
+	// It's simpler to construct the hex string right to left.
+	base := uint64(16)
+	i := len(buf) - 1
+	for num >= base {
+		buf[i] = hexDigits[num%base]
+		num /= base
+		i--
+	}
+	buf[i] = hexDigits[num]
+
+	// Add '0x' prefix.
+	i--
+	buf[i] = 'x'
+	i--
+	buf[i] = '0'
+
+	// Strip unused leading bytes.
+	buf = buf[i:]
+	w.Write(buf)
+}
+
+// valuesSorter implements sort.Interface to allow a slice of reflect.Value
+// elements to be sorted.
+type valuesSorter struct {
+	values  []reflect.Value
+	strings []string // either nil or same len and values
+	cs      *ConfigState
+}
+
+// newValuesSorter initializes a valuesSorter instance, which holds a set of
+// surrogate keys on which the data should be sorted.  It uses flags in
+// ConfigState to decide if and how to populate those surrogate keys.
+func newValuesSorter(values []reflect.Value, cs *ConfigState) sort.Interface {
+	vs := &valuesSorter{values: values, cs: cs}
+	if canSortSimply(vs.values[0].Kind()) {
+		return vs
+	}
+	if !cs.DisableMethods {
+		vs.strings = make([]string, len(values))
+		for i := range vs.values {
+			b := bytes.Buffer{}
+			if !handleMethods(cs, &b, vs.values[i]) {
+				vs.strings = nil
+				break
+			}
+			vs.strings[i] = b.String()
+		}
+	}
+	if vs.strings == nil && cs.SpewKeys {
+		vs.strings = make([]string, len(values))
+		for i := range vs.values {
+			vs.strings[i] = Sprintf("%#v", vs.values[i].Interface())
+		}
+	}
+	return vs
+}
+
+// canSortSimply tests whether a reflect.Kind is a primitive that can be sorted
+// directly, or whether it should be considered for sorting by surrogate keys
+// (if the ConfigState allows it).
+func canSortSimply(kind reflect.Kind) bool {
+	// This switch parallels valueSortLess, except for the default case.
+	switch kind {
+	case reflect.Bool:
+		return true
+	case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
+		return true
+	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
+		return true
+	case reflect.Float32, reflect.Float64:
+		return true
+	case reflect.String:
+		return true
+	case reflect.Uintptr:
+		return true
+	case reflect.Array:
+		return true
+	}
+	return false
+}
+
+// Len returns the number of values in the slice.  It is part of the
+// sort.Interface implementation.
+func (s *valuesSorter) Len() int {
+	return len(s.values)
+}
+
+// Swap swaps the values at the passed indices.  It is part of the
+// sort.Interface implementation.
+func (s *valuesSorter) Swap(i, j int) {
+	s.values[i], s.values[j] = s.values[j], s.values[i]
+	if s.strings != nil {
+		s.strings[i], s.strings[j] = s.strings[j], s.strings[i]
+	}
+}
+
+// valueSortLess returns whether the first value should sort before the second
+// value.  It is used by valueSorter.Less as part of the sort.Interface
+// implementation.
+func valueSortLess(a, b reflect.Value) bool {
+	switch a.Kind() {
+	case reflect.Bool:
+		return !a.Bool() && b.Bool()
+	case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
+		return a.Int() < b.Int()
+	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
+		return a.Uint() < b.Uint()
+	case reflect.Float32, reflect.Float64:
+		return a.Float() < b.Float()
+	case reflect.String:
+		return a.String() < b.String()
+	case reflect.Uintptr:
+		return a.Uint() < b.Uint()
+	case reflect.Array:
+		// Compare the contents of both arrays.
+		l := a.Len()
+		for i := 0; i < l; i++ {
+			av := a.Index(i)
+			bv := b.Index(i)
+			if av.Interface() == bv.Interface() {
+				continue
+			}
+			return valueSortLess(av, bv)
+		}
+	}
+	return a.String() < b.String()
+}
+
+// Less returns whether the value at index i should sort before the
+// value at index j.  It is part of the sort.Interface implementation.
+func (s *valuesSorter) Less(i, j int) bool {
+	if s.strings == nil {
+		return valueSortLess(s.values[i], s.values[j])
+	}
+	return s.strings[i] < s.strings[j]
+}
+
+// sortValues is a sort function that handles both native types and any type that
+// can be converted to error or Stringer.  Other inputs are sorted according to
+// their Value.String() value to ensure display stability.
+func sortValues(values []reflect.Value, cs *ConfigState) {
+	if len(values) == 0 {
+		return
+	}
+	sort.Sort(newValuesSorter(values, cs))
+}

+ 306 - 0
vendor/github.com/davecgh/go-spew/spew/config.go

@@ -0,0 +1,306 @@
+/*
+ * Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+package spew
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"os"
+)
+
+// ConfigState houses the configuration options used by spew to format and
+// display values.  There is a global instance, Config, that is used to control
+// all top-level Formatter and Dump functionality.  Each ConfigState instance
+// provides methods equivalent to the top-level functions.
+//
+// The zero value for ConfigState provides no indentation.  You would typically
+// want to set it to a space or a tab.
+//
+// Alternatively, you can use NewDefaultConfig to get a ConfigState instance
+// with default settings.  See the documentation of NewDefaultConfig for default
+// values.
+type ConfigState struct {
+	// Indent specifies the string to use for each indentation level.  The
+	// global config instance that all top-level functions use set this to a
+	// single space by default.  If you would like more indentation, you might
+	// set this to a tab with "\t" or perhaps two spaces with "  ".
+	Indent string
+
+	// MaxDepth controls the maximum number of levels to descend into nested
+	// data structures.  The default, 0, means there is no limit.
+	//
+	// NOTE: Circular data structures are properly detected, so it is not
+	// necessary to set this value unless you specifically want to limit deeply
+	// nested data structures.
+	MaxDepth int
+
+	// DisableMethods specifies whether or not error and Stringer interfaces are
+	// invoked for types that implement them.
+	DisableMethods bool
+
+	// DisablePointerMethods specifies whether or not to check for and invoke
+	// error and Stringer interfaces on types which only accept a pointer
+	// receiver when the current type is not a pointer.
+	//
+	// NOTE: This might be an unsafe action since calling one of these methods
+	// with a pointer receiver could technically mutate the value, however,
+	// in practice, types which choose to satisify an error or Stringer
+	// interface with a pointer receiver should not be mutating their state
+	// inside these interface methods.  As a result, this option relies on
+	// access to the unsafe package, so it will not have any effect when
+	// running in environments without access to the unsafe package such as
+	// Google App Engine or with the "safe" build tag specified.
+	DisablePointerMethods bool
+
+	// DisablePointerAddresses specifies whether to disable the printing of
+	// pointer addresses. This is useful when diffing data structures in tests.
+	DisablePointerAddresses bool
+
+	// DisableCapacities specifies whether to disable the printing of capacities
+	// for arrays, slices, maps and channels. This is useful when diffing
+	// data structures in tests.
+	DisableCapacities bool
+
+	// ContinueOnMethod specifies whether or not recursion should continue once
+	// a custom error or Stringer interface is invoked.  The default, false,
+	// means it will print the results of invoking the custom error or Stringer
+	// interface and return immediately instead of continuing to recurse into
+	// the internals of the data type.
+	//
+	// NOTE: This flag does not have any effect if method invocation is disabled
+	// via the DisableMethods or DisablePointerMethods options.
+	ContinueOnMethod bool
+
+	// SortKeys specifies map keys should be sorted before being printed. Use
+	// this to have a more deterministic, diffable output.  Note that only
+	// native types (bool, int, uint, floats, uintptr and string) and types
+	// that support the error or Stringer interfaces (if methods are
+	// enabled) are supported, with other types sorted according to the
+	// reflect.Value.String() output which guarantees display stability.
+	SortKeys bool
+
+	// SpewKeys specifies that, as a last resort attempt, map keys should
+	// be spewed to strings and sorted by those strings.  This is only
+	// considered if SortKeys is true.
+	SpewKeys bool
+}
+
+// Config is the active configuration of the top-level functions.
+// The configuration can be changed by modifying the contents of spew.Config.
+var Config = ConfigState{Indent: " "}
+
+// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were
+// passed with a Formatter interface returned by c.NewFormatter.  It returns
+// the formatted string as a value that satisfies error.  See NewFormatter
+// for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Errorf(format, c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Errorf(format string, a ...interface{}) (err error) {
+	return fmt.Errorf(format, c.convertArgs(a)...)
+}
+
+// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were
+// passed with a Formatter interface returned by c.NewFormatter.  It returns
+// the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Fprint(w, c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Fprint(w io.Writer, a ...interface{}) (n int, err error) {
+	return fmt.Fprint(w, c.convertArgs(a)...)
+}
+
+// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were
+// passed with a Formatter interface returned by c.NewFormatter.  It returns
+// the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Fprintf(w, format, c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
+	return fmt.Fprintf(w, format, c.convertArgs(a)...)
+}
+
+// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it
+// passed with a Formatter interface returned by c.NewFormatter.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Fprintln(w, c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
+	return fmt.Fprintln(w, c.convertArgs(a)...)
+}
+
+// Print is a wrapper for fmt.Print that treats each argument as if it were
+// passed with a Formatter interface returned by c.NewFormatter.  It returns
+// the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Print(c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Print(a ...interface{}) (n int, err error) {
+	return fmt.Print(c.convertArgs(a)...)
+}
+
+// Printf is a wrapper for fmt.Printf that treats each argument as if it were
+// passed with a Formatter interface returned by c.NewFormatter.  It returns
+// the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Printf(format, c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Printf(format string, a ...interface{}) (n int, err error) {
+	return fmt.Printf(format, c.convertArgs(a)...)
+}
+
+// Println is a wrapper for fmt.Println that treats each argument as if it were
+// passed with a Formatter interface returned by c.NewFormatter.  It returns
+// the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Println(c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Println(a ...interface{}) (n int, err error) {
+	return fmt.Println(c.convertArgs(a)...)
+}
+
+// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were
+// passed with a Formatter interface returned by c.NewFormatter.  It returns
+// the resulting string.  See NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Sprint(c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Sprint(a ...interface{}) string {
+	return fmt.Sprint(c.convertArgs(a)...)
+}
+
+// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were
+// passed with a Formatter interface returned by c.NewFormatter.  It returns
+// the resulting string.  See NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Sprintf(format, c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Sprintf(format string, a ...interface{}) string {
+	return fmt.Sprintf(format, c.convertArgs(a)...)
+}
+
+// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it
+// were passed with a Formatter interface returned by c.NewFormatter.  It
+// returns the resulting string.  See NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Sprintln(c.NewFormatter(a), c.NewFormatter(b))
+func (c *ConfigState) Sprintln(a ...interface{}) string {
+	return fmt.Sprintln(c.convertArgs(a)...)
+}
+
+/*
+NewFormatter returns a custom formatter that satisfies the fmt.Formatter
+interface.  As a result, it integrates cleanly with standard fmt package
+printing functions.  The formatter is useful for inline printing of smaller data
+types similar to the standard %v format specifier.
+
+The custom formatter only responds to the %v (most compact), %+v (adds pointer
+addresses), %#v (adds types), and %#+v (adds types and pointer addresses) verb
+combinations.  Any other verbs such as %x and %q will be sent to the the
+standard fmt package for formatting.  In addition, the custom formatter ignores
+the width and precision arguments (however they will still work on the format
+specifiers not handled by the custom formatter).
+
+Typically this function shouldn't be called directly.  It is much easier to make
+use of the custom formatter by calling one of the convenience functions such as
+c.Printf, c.Println, or c.Printf.
+*/
+func (c *ConfigState) NewFormatter(v interface{}) fmt.Formatter {
+	return newFormatter(c, v)
+}
+
+// Fdump formats and displays the passed arguments to io.Writer w.  It formats
+// exactly the same as Dump.
+func (c *ConfigState) Fdump(w io.Writer, a ...interface{}) {
+	fdump(c, w, a...)
+}
+
+/*
+Dump displays the passed parameters to standard out with newlines, customizable
+indentation, and additional debug information such as complete types and all
+pointer addresses used to indirect to the final value.  It provides the
+following features over the built-in printing facilities provided by the fmt
+package:
+
+	* Pointers are dereferenced and followed
+	* Circular data structures are detected and handled properly
+	* Custom Stringer/error interfaces are optionally invoked, including
+	  on unexported types
+	* Custom types which only implement the Stringer/error interfaces via
+	  a pointer receiver are optionally invoked when passing non-pointer
+	  variables
+	* Byte arrays and slices are dumped like the hexdump -C command which
+	  includes offsets, byte values in hex, and ASCII output
+
+The configuration options are controlled by modifying the public members
+of c.  See ConfigState for options documentation.
+
+See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to
+get the formatted result as a string.
+*/
+func (c *ConfigState) Dump(a ...interface{}) {
+	fdump(c, os.Stdout, a...)
+}
+
+// Sdump returns a string with the passed arguments formatted exactly the same
+// as Dump.
+func (c *ConfigState) Sdump(a ...interface{}) string {
+	var buf bytes.Buffer
+	fdump(c, &buf, a...)
+	return buf.String()
+}
+
+// convertArgs accepts a slice of arguments and returns a slice of the same
+// length with each argument converted to a spew Formatter interface using
+// the ConfigState associated with s.
+func (c *ConfigState) convertArgs(args []interface{}) (formatters []interface{}) {
+	formatters = make([]interface{}, len(args))
+	for index, arg := range args {
+		formatters[index] = newFormatter(c, arg)
+	}
+	return formatters
+}
+
+// NewDefaultConfig returns a ConfigState with the following default settings.
+//
+// 	Indent: " "
+// 	MaxDepth: 0
+// 	DisableMethods: false
+// 	DisablePointerMethods: false
+// 	ContinueOnMethod: false
+// 	SortKeys: false
+func NewDefaultConfig() *ConfigState {
+	return &ConfigState{Indent: " "}
+}

+ 211 - 0
vendor/github.com/davecgh/go-spew/spew/doc.go

@@ -0,0 +1,211 @@
+/*
+ * Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+Package spew implements a deep pretty printer for Go data structures to aid in
+debugging.
+
+A quick overview of the additional features spew provides over the built-in
+printing facilities for Go data types are as follows:
+
+	* Pointers are dereferenced and followed
+	* Circular data structures are detected and handled properly
+	* Custom Stringer/error interfaces are optionally invoked, including
+	  on unexported types
+	* Custom types which only implement the Stringer/error interfaces via
+	  a pointer receiver are optionally invoked when passing non-pointer
+	  variables
+	* Byte arrays and slices are dumped like the hexdump -C command which
+	  includes offsets, byte values in hex, and ASCII output (only when using
+	  Dump style)
+
+There are two different approaches spew allows for dumping Go data structures:
+
+	* Dump style which prints with newlines, customizable indentation,
+	  and additional debug information such as types and all pointer addresses
+	  used to indirect to the final value
+	* A custom Formatter interface that integrates cleanly with the standard fmt
+	  package and replaces %v, %+v, %#v, and %#+v to provide inline printing
+	  similar to the default %v while providing the additional functionality
+	  outlined above and passing unsupported format verbs such as %x and %q
+	  along to fmt
+
+Quick Start
+
+This section demonstrates how to quickly get started with spew.  See the
+sections below for further details on formatting and configuration options.
+
+To dump a variable with full newlines, indentation, type, and pointer
+information use Dump, Fdump, or Sdump:
+	spew.Dump(myVar1, myVar2, ...)
+	spew.Fdump(someWriter, myVar1, myVar2, ...)
+	str := spew.Sdump(myVar1, myVar2, ...)
+
+Alternatively, if you would prefer to use format strings with a compacted inline
+printing style, use the convenience wrappers Printf, Fprintf, etc with
+%v (most compact), %+v (adds pointer addresses), %#v (adds types), or
+%#+v (adds types and pointer addresses):
+	spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2)
+	spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
+	spew.Fprintf(someWriter, "myVar1: %v -- myVar2: %+v", myVar1, myVar2)
+	spew.Fprintf(someWriter, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
+
+Configuration Options
+
+Configuration of spew is handled by fields in the ConfigState type.  For
+convenience, all of the top-level functions use a global state available
+via the spew.Config global.
+
+It is also possible to create a ConfigState instance that provides methods
+equivalent to the top-level functions.  This allows concurrent configuration
+options.  See the ConfigState documentation for more details.
+
+The following configuration options are available:
+	* Indent
+		String to use for each indentation level for Dump functions.
+		It is a single space by default.  A popular alternative is "\t".
+
+	* MaxDepth
+		Maximum number of levels to descend into nested data structures.
+		There is no limit by default.
+
+	* DisableMethods
+		Disables invocation of error and Stringer interface methods.
+		Method invocation is enabled by default.
+
+	* DisablePointerMethods
+		Disables invocation of error and Stringer interface methods on types
+		which only accept pointer receivers from non-pointer variables.
+		Pointer method invocation is enabled by default.
+
+	* DisablePointerAddresses
+		DisablePointerAddresses specifies whether to disable the printing of
+		pointer addresses. This is useful when diffing data structures in tests.
+
+	* DisableCapacities
+		DisableCapacities specifies whether to disable the printing of
+		capacities for arrays, slices, maps and channels. This is useful when
+		diffing data structures in tests.
+
+	* ContinueOnMethod
+		Enables recursion into types after invoking error and Stringer interface
+		methods. Recursion after method invocation is disabled by default.
+
+	* SortKeys
+		Specifies map keys should be sorted before being printed. Use
+		this to have a more deterministic, diffable output.  Note that
+		only native types (bool, int, uint, floats, uintptr and string)
+		and types which implement error or Stringer interfaces are
+		supported with other types sorted according to the
+		reflect.Value.String() output which guarantees display
+		stability.  Natural map order is used by default.
+
+	* SpewKeys
+		Specifies that, as a last resort attempt, map keys should be
+		spewed to strings and sorted by those strings.  This is only
+		considered if SortKeys is true.
+
+Dump Usage
+
+Simply call spew.Dump with a list of variables you want to dump:
+
+	spew.Dump(myVar1, myVar2, ...)
+
+You may also call spew.Fdump if you would prefer to output to an arbitrary
+io.Writer.  For example, to dump to standard error:
+
+	spew.Fdump(os.Stderr, myVar1, myVar2, ...)
+
+A third option is to call spew.Sdump to get the formatted output as a string:
+
+	str := spew.Sdump(myVar1, myVar2, ...)
+
+Sample Dump Output
+
+See the Dump example for details on the setup of the types and variables being
+shown here.
+
+	(main.Foo) {
+	 unexportedField: (*main.Bar)(0xf84002e210)({
+	  flag: (main.Flag) flagTwo,
+	  data: (uintptr) <nil>
+	 }),
+	 ExportedField: (map[interface {}]interface {}) (len=1) {
+	  (string) (len=3) "one": (bool) true
+	 }
+	}
+
+Byte (and uint8) arrays and slices are displayed uniquely like the hexdump -C
+command as shown.
+	([]uint8) (len=32 cap=32) {
+	 00000000  11 12 13 14 15 16 17 18  19 1a 1b 1c 1d 1e 1f 20  |............... |
+	 00000010  21 22 23 24 25 26 27 28  29 2a 2b 2c 2d 2e 2f 30  |!"#$%&'()*+,-./0|
+	 00000020  31 32                                             |12|
+	}
+
+Custom Formatter
+
+Spew provides a custom formatter that implements the fmt.Formatter interface
+so that it integrates cleanly with standard fmt package printing functions. The
+formatter is useful for inline printing of smaller data types similar to the
+standard %v format specifier.
+
+The custom formatter only responds to the %v (most compact), %+v (adds pointer
+addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb
+combinations.  Any other verbs such as %x and %q will be sent to the the
+standard fmt package for formatting.  In addition, the custom formatter ignores
+the width and precision arguments (however they will still work on the format
+specifiers not handled by the custom formatter).
+
+Custom Formatter Usage
+
+The simplest way to make use of the spew custom formatter is to call one of the
+convenience functions such as spew.Printf, spew.Println, or spew.Printf.  The
+functions have syntax you are most likely already familiar with:
+
+	spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2)
+	spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
+	spew.Println(myVar, myVar2)
+	spew.Fprintf(os.Stderr, "myVar1: %v -- myVar2: %+v", myVar1, myVar2)
+	spew.Fprintf(os.Stderr, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
+
+See the Index for the full list convenience functions.
+
+Sample Formatter Output
+
+Double pointer to a uint8:
+	  %v: <**>5
+	 %+v: <**>(0xf8400420d0->0xf8400420c8)5
+	 %#v: (**uint8)5
+	%#+v: (**uint8)(0xf8400420d0->0xf8400420c8)5
+
+Pointer to circular struct with a uint8 field and a pointer to itself:
+	  %v: <*>{1 <*><shown>}
+	 %+v: <*>(0xf84003e260){ui8:1 c:<*>(0xf84003e260)<shown>}
+	 %#v: (*main.circular){ui8:(uint8)1 c:(*main.circular)<shown>}
+	%#+v: (*main.circular)(0xf84003e260){ui8:(uint8)1 c:(*main.circular)(0xf84003e260)<shown>}
+
+See the Printf example for details on the setup of variables being shown
+here.
+
+Errors
+
+Since it is possible for custom Stringer/error interfaces to panic, spew
+detects them and handles them internally by printing the panic information
+inline with the output.  Since spew is intended to provide deep pretty printing
+capabilities on structures, it intentionally does not return any errors.
+*/
+package spew

+ 509 - 0
vendor/github.com/davecgh/go-spew/spew/dump.go

@@ -0,0 +1,509 @@
+/*
+ * Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+package spew
+
+import (
+	"bytes"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"os"
+	"reflect"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+var (
+	// uint8Type is a reflect.Type representing a uint8.  It is used to
+	// convert cgo types to uint8 slices for hexdumping.
+	uint8Type = reflect.TypeOf(uint8(0))
+
+	// cCharRE is a regular expression that matches a cgo char.
+	// It is used to detect character arrays to hexdump them.
+	cCharRE = regexp.MustCompile(`^.*\._Ctype_char$`)
+
+	// cUnsignedCharRE is a regular expression that matches a cgo unsigned
+	// char.  It is used to detect unsigned character arrays to hexdump
+	// them.
+	cUnsignedCharRE = regexp.MustCompile(`^.*\._Ctype_unsignedchar$`)
+
+	// cUint8tCharRE is a regular expression that matches a cgo uint8_t.
+	// It is used to detect uint8_t arrays to hexdump them.
+	cUint8tCharRE = regexp.MustCompile(`^.*\._Ctype_uint8_t$`)
+)
+
+// dumpState contains information about the state of a dump operation.
+type dumpState struct {
+	w                io.Writer
+	depth            int
+	pointers         map[uintptr]int
+	ignoreNextType   bool
+	ignoreNextIndent bool
+	cs               *ConfigState
+}
+
+// indent performs indentation according to the depth level and cs.Indent
+// option.
+func (d *dumpState) indent() {
+	if d.ignoreNextIndent {
+		d.ignoreNextIndent = false
+		return
+	}
+	d.w.Write(bytes.Repeat([]byte(d.cs.Indent), d.depth))
+}
+
+// unpackValue returns values inside of non-nil interfaces when possible.
+// This is useful for data types like structs, arrays, slices, and maps which
+// can contain varying types packed inside an interface.
+func (d *dumpState) unpackValue(v reflect.Value) reflect.Value {
+	if v.Kind() == reflect.Interface && !v.IsNil() {
+		v = v.Elem()
+	}
+	return v
+}
+
+// dumpPtr handles formatting of pointers by indirecting them as necessary.
+func (d *dumpState) dumpPtr(v reflect.Value) {
+	// Remove pointers at or below the current depth from map used to detect
+	// circular refs.
+	for k, depth := range d.pointers {
+		if depth >= d.depth {
+			delete(d.pointers, k)
+		}
+	}
+
+	// Keep list of all dereferenced pointers to show later.
+	pointerChain := make([]uintptr, 0)
+
+	// Figure out how many levels of indirection there are by dereferencing
+	// pointers and unpacking interfaces down the chain while detecting circular
+	// references.
+	nilFound := false
+	cycleFound := false
+	indirects := 0
+	ve := v
+	for ve.Kind() == reflect.Ptr {
+		if ve.IsNil() {
+			nilFound = true
+			break
+		}
+		indirects++
+		addr := ve.Pointer()
+		pointerChain = append(pointerChain, addr)
+		if pd, ok := d.pointers[addr]; ok && pd < d.depth {
+			cycleFound = true
+			indirects--
+			break
+		}
+		d.pointers[addr] = d.depth
+
+		ve = ve.Elem()
+		if ve.Kind() == reflect.Interface {
+			if ve.IsNil() {
+				nilFound = true
+				break
+			}
+			ve = ve.Elem()
+		}
+	}
+
+	// Display type information.
+	d.w.Write(openParenBytes)
+	d.w.Write(bytes.Repeat(asteriskBytes, indirects))
+	d.w.Write([]byte(ve.Type().String()))
+	d.w.Write(closeParenBytes)
+
+	// Display pointer information.
+	if !d.cs.DisablePointerAddresses && len(pointerChain) > 0 {
+		d.w.Write(openParenBytes)
+		for i, addr := range pointerChain {
+			if i > 0 {
+				d.w.Write(pointerChainBytes)
+			}
+			printHexPtr(d.w, addr)
+		}
+		d.w.Write(closeParenBytes)
+	}
+
+	// Display dereferenced value.
+	d.w.Write(openParenBytes)
+	switch {
+	case nilFound:
+		d.w.Write(nilAngleBytes)
+
+	case cycleFound:
+		d.w.Write(circularBytes)
+
+	default:
+		d.ignoreNextType = true
+		d.dump(ve)
+	}
+	d.w.Write(closeParenBytes)
+}
+
+// dumpSlice handles formatting of arrays and slices.  Byte (uint8 under
+// reflection) arrays and slices are dumped in hexdump -C fashion.
+func (d *dumpState) dumpSlice(v reflect.Value) {
+	// Determine whether this type should be hex dumped or not.  Also,
+	// for types which should be hexdumped, try to use the underlying data
+	// first, then fall back to trying to convert them to a uint8 slice.
+	var buf []uint8
+	doConvert := false
+	doHexDump := false
+	numEntries := v.Len()
+	if numEntries > 0 {
+		vt := v.Index(0).Type()
+		vts := vt.String()
+		switch {
+		// C types that need to be converted.
+		case cCharRE.MatchString(vts):
+			fallthrough
+		case cUnsignedCharRE.MatchString(vts):
+			fallthrough
+		case cUint8tCharRE.MatchString(vts):
+			doConvert = true
+
+		// Try to use existing uint8 slices and fall back to converting
+		// and copying if that fails.
+		case vt.Kind() == reflect.Uint8:
+			// We need an addressable interface to convert the type
+			// to a byte slice.  However, the reflect package won't
+			// give us an interface on certain things like
+			// unexported struct fields in order to enforce
+			// visibility rules.  We use unsafe, when available, to
+			// bypass these restrictions since this package does not
+			// mutate the values.
+			vs := v
+			if !vs.CanInterface() || !vs.CanAddr() {
+				vs = unsafeReflectValue(vs)
+			}
+			if !UnsafeDisabled {
+				vs = vs.Slice(0, numEntries)
+
+				// Use the existing uint8 slice if it can be
+				// type asserted.
+				iface := vs.Interface()
+				if slice, ok := iface.([]uint8); ok {
+					buf = slice
+					doHexDump = true
+					break
+				}
+			}
+
+			// The underlying data needs to be converted if it can't
+			// be type asserted to a uint8 slice.
+			doConvert = true
+		}
+
+		// Copy and convert the underlying type if needed.
+		if doConvert && vt.ConvertibleTo(uint8Type) {
+			// Convert and copy each element into a uint8 byte
+			// slice.
+			buf = make([]uint8, numEntries)
+			for i := 0; i < numEntries; i++ {
+				vv := v.Index(i)
+				buf[i] = uint8(vv.Convert(uint8Type).Uint())
+			}
+			doHexDump = true
+		}
+	}
+
+	// Hexdump the entire slice as needed.
+	if doHexDump {
+		indent := strings.Repeat(d.cs.Indent, d.depth)
+		str := indent + hex.Dump(buf)
+		str = strings.Replace(str, "\n", "\n"+indent, -1)
+		str = strings.TrimRight(str, d.cs.Indent)
+		d.w.Write([]byte(str))
+		return
+	}
+
+	// Recursively call dump for each item.
+	for i := 0; i < numEntries; i++ {
+		d.dump(d.unpackValue(v.Index(i)))
+		if i < (numEntries - 1) {
+			d.w.Write(commaNewlineBytes)
+		} else {
+			d.w.Write(newlineBytes)
+		}
+	}
+}
+
+// dump is the main workhorse for dumping a value.  It uses the passed reflect
+// value to figure out what kind of object we are dealing with and formats it
+// appropriately.  It is a recursive function, however circular data structures
+// are detected and handled properly.
+func (d *dumpState) dump(v reflect.Value) {
+	// Handle invalid reflect values immediately.
+	kind := v.Kind()
+	if kind == reflect.Invalid {
+		d.w.Write(invalidAngleBytes)
+		return
+	}
+
+	// Handle pointers specially.
+	if kind == reflect.Ptr {
+		d.indent()
+		d.dumpPtr(v)
+		return
+	}
+
+	// Print type information unless already handled elsewhere.
+	if !d.ignoreNextType {
+		d.indent()
+		d.w.Write(openParenBytes)
+		d.w.Write([]byte(v.Type().String()))
+		d.w.Write(closeParenBytes)
+		d.w.Write(spaceBytes)
+	}
+	d.ignoreNextType = false
+
+	// Display length and capacity if the built-in len and cap functions
+	// work with the value's kind and the len/cap itself is non-zero.
+	valueLen, valueCap := 0, 0
+	switch v.Kind() {
+	case reflect.Array, reflect.Slice, reflect.Chan:
+		valueLen, valueCap = v.Len(), v.Cap()
+	case reflect.Map, reflect.String:
+		valueLen = v.Len()
+	}
+	if valueLen != 0 || !d.cs.DisableCapacities && valueCap != 0 {
+		d.w.Write(openParenBytes)
+		if valueLen != 0 {
+			d.w.Write(lenEqualsBytes)
+			printInt(d.w, int64(valueLen), 10)
+		}
+		if !d.cs.DisableCapacities && valueCap != 0 {
+			if valueLen != 0 {
+				d.w.Write(spaceBytes)
+			}
+			d.w.Write(capEqualsBytes)
+			printInt(d.w, int64(valueCap), 10)
+		}
+		d.w.Write(closeParenBytes)
+		d.w.Write(spaceBytes)
+	}
+
+	// Call Stringer/error interfaces if they exist and the handle methods flag
+	// is enabled
+	if !d.cs.DisableMethods {
+		if (kind != reflect.Invalid) && (kind != reflect.Interface) {
+			if handled := handleMethods(d.cs, d.w, v); handled {
+				return
+			}
+		}
+	}
+
+	switch kind {
+	case reflect.Invalid:
+		// Do nothing.  We should never get here since invalid has already
+		// been handled above.
+
+	case reflect.Bool:
+		printBool(d.w, v.Bool())
+
+	case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
+		printInt(d.w, v.Int(), 10)
+
+	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
+		printUint(d.w, v.Uint(), 10)
+
+	case reflect.Float32:
+		printFloat(d.w, v.Float(), 32)
+
+	case reflect.Float64:
+		printFloat(d.w, v.Float(), 64)
+
+	case reflect.Complex64:
+		printComplex(d.w, v.Complex(), 32)
+
+	case reflect.Complex128:
+		printComplex(d.w, v.Complex(), 64)
+
+	case reflect.Slice:
+		if v.IsNil() {
+			d.w.Write(nilAngleBytes)
+			break
+		}
+		fallthrough
+
+	case reflect.Array:
+		d.w.Write(openBraceNewlineBytes)
+		d.depth++
+		if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
+			d.indent()
+			d.w.Write(maxNewlineBytes)
+		} else {
+			d.dumpSlice(v)
+		}
+		d.depth--
+		d.indent()
+		d.w.Write(closeBraceBytes)
+
+	case reflect.String:
+		d.w.Write([]byte(strconv.Quote(v.String())))
+
+	case reflect.Interface:
+		// The only time we should get here is for nil interfaces due to
+		// unpackValue calls.
+		if v.IsNil() {
+			d.w.Write(nilAngleBytes)
+		}
+
+	case reflect.Ptr:
+		// Do nothing.  We should never get here since pointers have already
+		// been handled above.
+
+	case reflect.Map:
+		// nil maps should be indicated as different than empty maps
+		if v.IsNil() {
+			d.w.Write(nilAngleBytes)
+			break
+		}
+
+		d.w.Write(openBraceNewlineBytes)
+		d.depth++
+		if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
+			d.indent()
+			d.w.Write(maxNewlineBytes)
+		} else {
+			numEntries := v.Len()
+			keys := v.MapKeys()
+			if d.cs.SortKeys {
+				sortValues(keys, d.cs)
+			}
+			for i, key := range keys {
+				d.dump(d.unpackValue(key))
+				d.w.Write(colonSpaceBytes)
+				d.ignoreNextIndent = true
+				d.dump(d.unpackValue(v.MapIndex(key)))
+				if i < (numEntries - 1) {
+					d.w.Write(commaNewlineBytes)
+				} else {
+					d.w.Write(newlineBytes)
+				}
+			}
+		}
+		d.depth--
+		d.indent()
+		d.w.Write(closeBraceBytes)
+
+	case reflect.Struct:
+		d.w.Write(openBraceNewlineBytes)
+		d.depth++
+		if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
+			d.indent()
+			d.w.Write(maxNewlineBytes)
+		} else {
+			vt := v.Type()
+			numFields := v.NumField()
+			for i := 0; i < numFields; i++ {
+				d.indent()
+				vtf := vt.Field(i)
+				d.w.Write([]byte(vtf.Name))
+				d.w.Write(colonSpaceBytes)
+				d.ignoreNextIndent = true
+				d.dump(d.unpackValue(v.Field(i)))
+				if i < (numFields - 1) {
+					d.w.Write(commaNewlineBytes)
+				} else {
+					d.w.Write(newlineBytes)
+				}
+			}
+		}
+		d.depth--
+		d.indent()
+		d.w.Write(closeBraceBytes)
+
+	case reflect.Uintptr:
+		printHexPtr(d.w, uintptr(v.Uint()))
+
+	case reflect.UnsafePointer, reflect.Chan, reflect.Func:
+		printHexPtr(d.w, v.Pointer())
+
+	// There were not any other types at the time this code was written, but
+	// fall back to letting the default fmt package handle it in case any new
+	// types are added.
+	default:
+		if v.CanInterface() {
+			fmt.Fprintf(d.w, "%v", v.Interface())
+		} else {
+			fmt.Fprintf(d.w, "%v", v.String())
+		}
+	}
+}
+
+// fdump is a helper function to consolidate the logic from the various public
+// methods which take varying writers and config states.
+func fdump(cs *ConfigState, w io.Writer, a ...interface{}) {
+	for _, arg := range a {
+		if arg == nil {
+			w.Write(interfaceBytes)
+			w.Write(spaceBytes)
+			w.Write(nilAngleBytes)
+			w.Write(newlineBytes)
+			continue
+		}
+
+		d := dumpState{w: w, cs: cs}
+		d.pointers = make(map[uintptr]int)
+		d.dump(reflect.ValueOf(arg))
+		d.w.Write(newlineBytes)
+	}
+}
+
+// Fdump formats and displays the passed arguments to io.Writer w.  It formats
+// exactly the same as Dump.
+func Fdump(w io.Writer, a ...interface{}) {
+	fdump(&Config, w, a...)
+}
+
+// Sdump returns a string with the passed arguments formatted exactly the same
+// as Dump.
+func Sdump(a ...interface{}) string {
+	var buf bytes.Buffer
+	fdump(&Config, &buf, a...)
+	return buf.String()
+}
+
+/*
+Dump displays the passed parameters to standard out with newlines, customizable
+indentation, and additional debug information such as complete types and all
+pointer addresses used to indirect to the final value.  It provides the
+following features over the built-in printing facilities provided by the fmt
+package:
+
+	* Pointers are dereferenced and followed
+	* Circular data structures are detected and handled properly
+	* Custom Stringer/error interfaces are optionally invoked, including
+	  on unexported types
+	* Custom types which only implement the Stringer/error interfaces via
+	  a pointer receiver are optionally invoked when passing non-pointer
+	  variables
+	* Byte arrays and slices are dumped like the hexdump -C command which
+	  includes offsets, byte values in hex, and ASCII output
+
+The configuration options are controlled by an exported package global,
+spew.Config.  See ConfigState for options documentation.
+
+See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to
+get the formatted result as a string.
+*/
+func Dump(a ...interface{}) {
+	fdump(&Config, os.Stdout, a...)
+}

+ 419 - 0
vendor/github.com/davecgh/go-spew/spew/format.go

@@ -0,0 +1,419 @@
+/*
+ * Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+package spew
+
+import (
+	"bytes"
+	"fmt"
+	"reflect"
+	"strconv"
+	"strings"
+)
+
+// supportedFlags is a list of all the character flags supported by fmt package.
+const supportedFlags = "0-+# "
+
+// formatState implements the fmt.Formatter interface and contains information
+// about the state of a formatting operation.  The NewFormatter function can
+// be used to get a new Formatter which can be used directly as arguments
+// in standard fmt package printing calls.
+type formatState struct {
+	value          interface{}
+	fs             fmt.State
+	depth          int
+	pointers       map[uintptr]int
+	ignoreNextType bool
+	cs             *ConfigState
+}
+
+// buildDefaultFormat recreates the original format string without precision
+// and width information to pass in to fmt.Sprintf in the case of an
+// unrecognized type.  Unless new types are added to the language, this
+// function won't ever be called.
+func (f *formatState) buildDefaultFormat() (format string) {
+	buf := bytes.NewBuffer(percentBytes)
+
+	for _, flag := range supportedFlags {
+		if f.fs.Flag(int(flag)) {
+			buf.WriteRune(flag)
+		}
+	}
+
+	buf.WriteRune('v')
+
+	format = buf.String()
+	return format
+}
+
+// constructOrigFormat recreates the original format string including precision
+// and width information to pass along to the standard fmt package.  This allows
+// automatic deferral of all format strings this package doesn't support.
+func (f *formatState) constructOrigFormat(verb rune) (format string) {
+	buf := bytes.NewBuffer(percentBytes)
+
+	for _, flag := range supportedFlags {
+		if f.fs.Flag(int(flag)) {
+			buf.WriteRune(flag)
+		}
+	}
+
+	if width, ok := f.fs.Width(); ok {
+		buf.WriteString(strconv.Itoa(width))
+	}
+
+	if precision, ok := f.fs.Precision(); ok {
+		buf.Write(precisionBytes)
+		buf.WriteString(strconv.Itoa(precision))
+	}
+
+	buf.WriteRune(verb)
+
+	format = buf.String()
+	return format
+}
+
+// unpackValue returns values inside of non-nil interfaces when possible and
+// ensures that types for values which have been unpacked from an interface
+// are displayed when the show types flag is also set.
+// This is useful for data types like structs, arrays, slices, and maps which
+// can contain varying types packed inside an interface.
+func (f *formatState) unpackValue(v reflect.Value) reflect.Value {
+	if v.Kind() == reflect.Interface {
+		f.ignoreNextType = false
+		if !v.IsNil() {
+			v = v.Elem()
+		}
+	}
+	return v
+}
+
+// formatPtr handles formatting of pointers by indirecting them as necessary.
+func (f *formatState) formatPtr(v reflect.Value) {
+	// Display nil if top level pointer is nil.
+	showTypes := f.fs.Flag('#')
+	if v.IsNil() && (!showTypes || f.ignoreNextType) {
+		f.fs.Write(nilAngleBytes)
+		return
+	}
+
+	// Remove pointers at or below the current depth from map used to detect
+	// circular refs.
+	for k, depth := range f.pointers {
+		if depth >= f.depth {
+			delete(f.pointers, k)
+		}
+	}
+
+	// Keep list of all dereferenced pointers to possibly show later.
+	pointerChain := make([]uintptr, 0)
+
+	// Figure out how many levels of indirection there are by derferencing
+	// pointers and unpacking interfaces down the chain while detecting circular
+	// references.
+	nilFound := false
+	cycleFound := false
+	indirects := 0
+	ve := v
+	for ve.Kind() == reflect.Ptr {
+		if ve.IsNil() {
+			nilFound = true
+			break
+		}
+		indirects++
+		addr := ve.Pointer()
+		pointerChain = append(pointerChain, addr)
+		if pd, ok := f.pointers[addr]; ok && pd < f.depth {
+			cycleFound = true
+			indirects--
+			break
+		}
+		f.pointers[addr] = f.depth
+
+		ve = ve.Elem()
+		if ve.Kind() == reflect.Interface {
+			if ve.IsNil() {
+				nilFound = true
+				break
+			}
+			ve = ve.Elem()
+		}
+	}
+
+	// Display type or indirection level depending on flags.
+	if showTypes && !f.ignoreNextType {
+		f.fs.Write(openParenBytes)
+		f.fs.Write(bytes.Repeat(asteriskBytes, indirects))
+		f.fs.Write([]byte(ve.Type().String()))
+		f.fs.Write(closeParenBytes)
+	} else {
+		if nilFound || cycleFound {
+			indirects += strings.Count(ve.Type().String(), "*")
+		}
+		f.fs.Write(openAngleBytes)
+		f.fs.Write([]byte(strings.Repeat("*", indirects)))
+		f.fs.Write(closeAngleBytes)
+	}
+
+	// Display pointer information depending on flags.
+	if f.fs.Flag('+') && (len(pointerChain) > 0) {
+		f.fs.Write(openParenBytes)
+		for i, addr := range pointerChain {
+			if i > 0 {
+				f.fs.Write(pointerChainBytes)
+			}
+			printHexPtr(f.fs, addr)
+		}
+		f.fs.Write(closeParenBytes)
+	}
+
+	// Display dereferenced value.
+	switch {
+	case nilFound:
+		f.fs.Write(nilAngleBytes)
+
+	case cycleFound:
+		f.fs.Write(circularShortBytes)
+
+	default:
+		f.ignoreNextType = true
+		f.format(ve)
+	}
+}
+
+// format is the main workhorse for providing the Formatter interface.  It
+// uses the passed reflect value to figure out what kind of object we are
+// dealing with and formats it appropriately.  It is a recursive function,
+// however circular data structures are detected and handled properly.
+func (f *formatState) format(v reflect.Value) {
+	// Handle invalid reflect values immediately.
+	kind := v.Kind()
+	if kind == reflect.Invalid {
+		f.fs.Write(invalidAngleBytes)
+		return
+	}
+
+	// Handle pointers specially.
+	if kind == reflect.Ptr {
+		f.formatPtr(v)
+		return
+	}
+
+	// Print type information unless already handled elsewhere.
+	if !f.ignoreNextType && f.fs.Flag('#') {
+		f.fs.Write(openParenBytes)
+		f.fs.Write([]byte(v.Type().String()))
+		f.fs.Write(closeParenBytes)
+	}
+	f.ignoreNextType = false
+
+	// Call Stringer/error interfaces if they exist and the handle methods
+	// flag is enabled.
+	if !f.cs.DisableMethods {
+		if (kind != reflect.Invalid) && (kind != reflect.Interface) {
+			if handled := handleMethods(f.cs, f.fs, v); handled {
+				return
+			}
+		}
+	}
+
+	switch kind {
+	case reflect.Invalid:
+		// Do nothing.  We should never get here since invalid has already
+		// been handled above.
+
+	case reflect.Bool:
+		printBool(f.fs, v.Bool())
+
+	case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
+		printInt(f.fs, v.Int(), 10)
+
+	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
+		printUint(f.fs, v.Uint(), 10)
+
+	case reflect.Float32:
+		printFloat(f.fs, v.Float(), 32)
+
+	case reflect.Float64:
+		printFloat(f.fs, v.Float(), 64)
+
+	case reflect.Complex64:
+		printComplex(f.fs, v.Complex(), 32)
+
+	case reflect.Complex128:
+		printComplex(f.fs, v.Complex(), 64)
+
+	case reflect.Slice:
+		if v.IsNil() {
+			f.fs.Write(nilAngleBytes)
+			break
+		}
+		fallthrough
+
+	case reflect.Array:
+		f.fs.Write(openBracketBytes)
+		f.depth++
+		if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
+			f.fs.Write(maxShortBytes)
+		} else {
+			numEntries := v.Len()
+			for i := 0; i < numEntries; i++ {
+				if i > 0 {
+					f.fs.Write(spaceBytes)
+				}
+				f.ignoreNextType = true
+				f.format(f.unpackValue(v.Index(i)))
+			}
+		}
+		f.depth--
+		f.fs.Write(closeBracketBytes)
+
+	case reflect.String:
+		f.fs.Write([]byte(v.String()))
+
+	case reflect.Interface:
+		// The only time we should get here is for nil interfaces due to
+		// unpackValue calls.
+		if v.IsNil() {
+			f.fs.Write(nilAngleBytes)
+		}
+
+	case reflect.Ptr:
+		// Do nothing.  We should never get here since pointers have already
+		// been handled above.
+
+	case reflect.Map:
+		// nil maps should be indicated as different than empty maps
+		if v.IsNil() {
+			f.fs.Write(nilAngleBytes)
+			break
+		}
+
+		f.fs.Write(openMapBytes)
+		f.depth++
+		if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
+			f.fs.Write(maxShortBytes)
+		} else {
+			keys := v.MapKeys()
+			if f.cs.SortKeys {
+				sortValues(keys, f.cs)
+			}
+			for i, key := range keys {
+				if i > 0 {
+					f.fs.Write(spaceBytes)
+				}
+				f.ignoreNextType = true
+				f.format(f.unpackValue(key))
+				f.fs.Write(colonBytes)
+				f.ignoreNextType = true
+				f.format(f.unpackValue(v.MapIndex(key)))
+			}
+		}
+		f.depth--
+		f.fs.Write(closeMapBytes)
+
+	case reflect.Struct:
+		numFields := v.NumField()
+		f.fs.Write(openBraceBytes)
+		f.depth++
+		if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
+			f.fs.Write(maxShortBytes)
+		} else {
+			vt := v.Type()
+			for i := 0; i < numFields; i++ {
+				if i > 0 {
+					f.fs.Write(spaceBytes)
+				}
+				vtf := vt.Field(i)
+				if f.fs.Flag('+') || f.fs.Flag('#') {
+					f.fs.Write([]byte(vtf.Name))
+					f.fs.Write(colonBytes)
+				}
+				f.format(f.unpackValue(v.Field(i)))
+			}
+		}
+		f.depth--
+		f.fs.Write(closeBraceBytes)
+
+	case reflect.Uintptr:
+		printHexPtr(f.fs, uintptr(v.Uint()))
+
+	case reflect.UnsafePointer, reflect.Chan, reflect.Func:
+		printHexPtr(f.fs, v.Pointer())
+
+	// There were not any other types at the time this code was written, but
+	// fall back to letting the default fmt package handle it if any get added.
+	default:
+		format := f.buildDefaultFormat()
+		if v.CanInterface() {
+			fmt.Fprintf(f.fs, format, v.Interface())
+		} else {
+			fmt.Fprintf(f.fs, format, v.String())
+		}
+	}
+}
+
+// Format satisfies the fmt.Formatter interface. See NewFormatter for usage
+// details.
+func (f *formatState) Format(fs fmt.State, verb rune) {
+	f.fs = fs
+
+	// Use standard formatting for verbs that are not v.
+	if verb != 'v' {
+		format := f.constructOrigFormat(verb)
+		fmt.Fprintf(fs, format, f.value)
+		return
+	}
+
+	if f.value == nil {
+		if fs.Flag('#') {
+			fs.Write(interfaceBytes)
+		}
+		fs.Write(nilAngleBytes)
+		return
+	}
+
+	f.format(reflect.ValueOf(f.value))
+}
+
+// newFormatter is a helper function to consolidate the logic from the various
+// public methods which take varying config states.
+func newFormatter(cs *ConfigState, v interface{}) fmt.Formatter {
+	fs := &formatState{value: v, cs: cs}
+	fs.pointers = make(map[uintptr]int)
+	return fs
+}
+
+/*
+NewFormatter returns a custom formatter that satisfies the fmt.Formatter
+interface.  As a result, it integrates cleanly with standard fmt package
+printing functions.  The formatter is useful for inline printing of smaller data
+types similar to the standard %v format specifier.
+
+The custom formatter only responds to the %v (most compact), %+v (adds pointer
+addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb
+combinations.  Any other verbs such as %x and %q will be sent to the the
+standard fmt package for formatting.  In addition, the custom formatter ignores
+the width and precision arguments (however they will still work on the format
+specifiers not handled by the custom formatter).
+
+Typically this function shouldn't be called directly.  It is much easier to make
+use of the custom formatter by calling one of the convenience functions such as
+Printf, Println, or Fprintf.
+*/
+func NewFormatter(v interface{}) fmt.Formatter {
+	return newFormatter(&Config, v)
+}

+ 148 - 0
vendor/github.com/davecgh/go-spew/spew/spew.go

@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+package spew
+
+import (
+	"fmt"
+	"io"
+)
+
+// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were
+// passed with a default Formatter interface returned by NewFormatter.  It
+// returns the formatted string as a value that satisfies error.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Errorf(format, spew.NewFormatter(a), spew.NewFormatter(b))
+func Errorf(format string, a ...interface{}) (err error) {
+	return fmt.Errorf(format, convertArgs(a)...)
+}
+
+// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were
+// passed with a default Formatter interface returned by NewFormatter.  It
+// returns the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Fprint(w, spew.NewFormatter(a), spew.NewFormatter(b))
+func Fprint(w io.Writer, a ...interface{}) (n int, err error) {
+	return fmt.Fprint(w, convertArgs(a)...)
+}
+
+// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were
+// passed with a default Formatter interface returned by NewFormatter.  It
+// returns the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Fprintf(w, format, spew.NewFormatter(a), spew.NewFormatter(b))
+func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
+	return fmt.Fprintf(w, format, convertArgs(a)...)
+}
+
+// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it
+// passed with a default Formatter interface returned by NewFormatter.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Fprintln(w, spew.NewFormatter(a), spew.NewFormatter(b))
+func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
+	return fmt.Fprintln(w, convertArgs(a)...)
+}
+
+// Print is a wrapper for fmt.Print that treats each argument as if it were
+// passed with a default Formatter interface returned by NewFormatter.  It
+// returns the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Print(spew.NewFormatter(a), spew.NewFormatter(b))
+func Print(a ...interface{}) (n int, err error) {
+	return fmt.Print(convertArgs(a)...)
+}
+
+// Printf is a wrapper for fmt.Printf that treats each argument as if it were
+// passed with a default Formatter interface returned by NewFormatter.  It
+// returns the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Printf(format, spew.NewFormatter(a), spew.NewFormatter(b))
+func Printf(format string, a ...interface{}) (n int, err error) {
+	return fmt.Printf(format, convertArgs(a)...)
+}
+
+// Println is a wrapper for fmt.Println that treats each argument as if it were
+// passed with a default Formatter interface returned by NewFormatter.  It
+// returns the number of bytes written and any write error encountered.  See
+// NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Println(spew.NewFormatter(a), spew.NewFormatter(b))
+func Println(a ...interface{}) (n int, err error) {
+	return fmt.Println(convertArgs(a)...)
+}
+
+// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were
+// passed with a default Formatter interface returned by NewFormatter.  It
+// returns the resulting string.  See NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Sprint(spew.NewFormatter(a), spew.NewFormatter(b))
+func Sprint(a ...interface{}) string {
+	return fmt.Sprint(convertArgs(a)...)
+}
+
+// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were
+// passed with a default Formatter interface returned by NewFormatter.  It
+// returns the resulting string.  See NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Sprintf(format, spew.NewFormatter(a), spew.NewFormatter(b))
+func Sprintf(format string, a ...interface{}) string {
+	return fmt.Sprintf(format, convertArgs(a)...)
+}
+
+// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it
+// were passed with a default Formatter interface returned by NewFormatter.  It
+// returns the resulting string.  See NewFormatter for formatting details.
+//
+// This function is shorthand for the following syntax:
+//
+//	fmt.Sprintln(spew.NewFormatter(a), spew.NewFormatter(b))
+func Sprintln(a ...interface{}) string {
+	return fmt.Sprintln(convertArgs(a)...)
+}
+
+// convertArgs accepts a slice of arguments and returns a slice of the same
+// length with each argument converted to a default spew Formatter interface.
+func convertArgs(args []interface{}) (formatters []interface{}) {
+	formatters = make([]interface{}, len(args))
+	for index, arg := range args {
+		formatters[index] = NewFormatter(arg)
+	}
+	return formatters
+}

+ 22 - 0
vendor/github.com/hashicorp/logutils/.gitignore

@@ -0,0 +1,22 @@
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe

+ 354 - 0
vendor/github.com/hashicorp/logutils/LICENSE

@@ -0,0 +1,354 @@
+Mozilla Public License, version 2.0
+
+1. Definitions
+
+1.1. “Contributor”
+
+     means each individual or legal entity that creates, contributes to the
+     creation of, or owns Covered Software.
+
+1.2. “Contributor Version”
+
+     means the combination of the Contributions of others (if any) used by a
+     Contributor and that particular Contributor’s Contribution.
+
+1.3. “Contribution”
+
+     means Covered Software of a particular Contributor.
+
+1.4. “Covered Software”
+
+     means Source Code Form to which the initial Contributor has attached the
+     notice in Exhibit A, the Executable Form of such Source Code Form, and
+     Modifications of such Source Code Form, in each case including portions
+     thereof.
+
+1.5. “Incompatible With Secondary Licenses”
+     means
+
+     a. that the initial Contributor has attached the notice described in
+        Exhibit B to the Covered Software; or
+
+     b. that the Covered Software was made available under the terms of version
+        1.1 or earlier of the License, but not also under the terms of a
+        Secondary License.
+
+1.6. “Executable Form”
+
+     means any form of the work other than Source Code Form.
+
+1.7. “Larger Work”
+
+     means a work that combines Covered Software with other material, in a separate
+     file or files, that is not Covered Software.
+
+1.8. “License”
+
+     means this document.
+
+1.9. “Licensable”
+
+     means having the right to grant, to the maximum extent possible, whether at the
+     time of the initial grant or subsequently, any and all of the rights conveyed by
+     this License.
+
+1.10. “Modifications”
+
+     means any of the following:
+
+     a. any file in Source Code Form that results from an addition to, deletion
+        from, or modification of the contents of Covered Software; or
+
+     b. any new file in Source Code Form that contains any Covered Software.
+
+1.11. “Patent Claims” of a Contributor
+
+      means any patent claim(s), including without limitation, method, process,
+      and apparatus claims, in any patent Licensable by such Contributor that
+      would be infringed, but for the grant of the License, by the making,
+      using, selling, offering for sale, having made, import, or transfer of
+      either its Contributions or its Contributor Version.
+
+1.12. “Secondary License”
+
+      means either the GNU General Public License, Version 2.0, the GNU Lesser
+      General Public License, Version 2.1, the GNU Affero General Public
+      License, Version 3.0, or any later versions of those licenses.
+
+1.13. “Source Code Form”
+
+      means the form of the work preferred for making modifications.
+
+1.14. “You” (or “Your”)
+
+      means an individual or a legal entity exercising rights under this
+      License. For legal entities, “You” includes any entity that controls, is
+      controlled by, or is under common control with You. For purposes of this
+      definition, “control” means (a) the power, direct or indirect, to cause
+      the direction or management of such entity, whether by contract or
+      otherwise, or (b) ownership of more than fifty percent (50%) of the
+      outstanding shares or beneficial ownership of such entity.
+
+
+2. License Grants and Conditions
+
+2.1. Grants
+
+     Each Contributor hereby grants You a world-wide, royalty-free,
+     non-exclusive license:
+
+     a. under intellectual property rights (other than patent or trademark)
+        Licensable by such Contributor to use, reproduce, make available,
+        modify, display, perform, distribute, and otherwise exploit its
+        Contributions, either on an unmodified basis, with Modifications, or as
+        part of a Larger Work; and
+
+     b. under Patent Claims of such Contributor to make, use, sell, offer for
+        sale, have made, import, and otherwise transfer either its Contributions
+        or its Contributor Version.
+
+2.2. Effective Date
+
+     The licenses granted in Section 2.1 with respect to any Contribution become
+     effective for each Contribution on the date the Contributor first distributes
+     such Contribution.
+
+2.3. Limitations on Grant Scope
+
+     The licenses granted in this Section 2 are the only rights granted under this
+     License. No additional rights or licenses will be implied from the distribution
+     or licensing of Covered Software under this License. Notwithstanding Section
+     2.1(b) above, no patent license is granted by a Contributor:
+
+     a. for any code that a Contributor has removed from Covered Software; or
+
+     b. for infringements caused by: (i) Your and any other third party’s
+        modifications of Covered Software, or (ii) the combination of its
+        Contributions with other software (except as part of its Contributor
+        Version); or
+
+     c. under Patent Claims infringed by Covered Software in the absence of its
+        Contributions.
+
+     This License does not grant any rights in the trademarks, service marks, or
+     logos of any Contributor (except as may be necessary to comply with the
+     notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+     No Contributor makes additional grants as a result of Your choice to
+     distribute the Covered Software under a subsequent version of this License
+     (see Section 10.2) or under the terms of a Secondary License (if permitted
+     under the terms of Section 3.3).
+
+2.5. Representation
+
+     Each Contributor represents that the Contributor believes its Contributions
+     are its original creation(s) or it has sufficient rights to grant the
+     rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+     This License is not intended to limit any rights You have under applicable
+     copyright doctrines of fair use, fair dealing, or other equivalents.
+
+2.7. Conditions
+
+     Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
+     Section 2.1.
+
+
+3. Responsibilities
+
+3.1. Distribution of Source Form
+
+     All distribution of Covered Software in Source Code Form, including any
+     Modifications that You create or to which You contribute, must be under the
+     terms of this License. You must inform recipients that the Source Code Form
+     of the Covered Software is governed by the terms of this License, and how
+     they can obtain a copy of this License. You may not attempt to alter or
+     restrict the recipients’ rights in the Source Code Form.
+
+3.2. Distribution of Executable Form
+
+     If You distribute Covered Software in Executable Form then:
+
+     a. such Covered Software must also be made available in Source Code Form,
+        as described in Section 3.1, and You must inform recipients of the
+        Executable Form how they can obtain a copy of such Source Code Form by
+        reasonable means in a timely manner, at a charge no more than the cost
+        of distribution to the recipient; and
+
+     b. You may distribute such Executable Form under the terms of this License,
+        or sublicense it under different terms, provided that the license for
+        the Executable Form does not attempt to limit or alter the recipients’
+        rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+     You may create and distribute a Larger Work under terms of Your choice,
+     provided that You also comply with the requirements of this License for the
+     Covered Software. If the Larger Work is a combination of Covered Software
+     with a work governed by one or more Secondary Licenses, and the Covered
+     Software is not Incompatible With Secondary Licenses, this License permits
+     You to additionally distribute such Covered Software under the terms of
+     such Secondary License(s), so that the recipient of the Larger Work may, at
+     their option, further distribute the Covered Software under the terms of
+     either this License or such Secondary License(s).
+
+3.4. Notices
+
+     You may not remove or alter the substance of any license notices (including
+     copyright notices, patent notices, disclaimers of warranty, or limitations
+     of liability) contained within the Source Code Form of the Covered
+     Software, except that You may alter any license notices to the extent
+     required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+     You may choose to offer, and to charge a fee for, warranty, support,
+     indemnity or liability obligations to one or more recipients of Covered
+     Software. However, You may do so only on Your own behalf, and not on behalf
+     of any Contributor. You must make it absolutely clear that any such
+     warranty, support, indemnity, or liability obligation is offered by You
+     alone, and You hereby agree to indemnify every Contributor for any
+     liability incurred by such Contributor as a result of warranty, support,
+     indemnity or liability terms You offer. You may include additional
+     disclaimers of warranty and limitations of liability specific to any
+     jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+
+   If it is impossible for You to comply with any of the terms of this License
+   with respect to some or all of the Covered Software due to statute, judicial
+   order, or regulation then You must: (a) comply with the terms of this License
+   to the maximum extent possible; and (b) describe the limitations and the code
+   they affect. Such description must be placed in a text file included with all
+   distributions of the Covered Software under this License. Except to the
+   extent prohibited by statute or regulation, such description must be
+   sufficiently detailed for a recipient of ordinary skill to be able to
+   understand it.
+
+5. Termination
+
+5.1. The rights granted under this License will terminate automatically if You
+     fail to comply with any of its terms. However, if You become compliant,
+     then the rights granted under this License from a particular Contributor
+     are reinstated (a) provisionally, unless and until such Contributor
+     explicitly and finally terminates Your grants, and (b) on an ongoing basis,
+     if such Contributor fails to notify You of the non-compliance by some
+     reasonable means prior to 60 days after You have come back into compliance.
+     Moreover, Your grants from a particular Contributor are reinstated on an
+     ongoing basis if such Contributor notifies You of the non-compliance by
+     some reasonable means, this is the first time You have received notice of
+     non-compliance with this License from such Contributor, and You become
+     compliant prior to 30 days after Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+     infringement claim (excluding declaratory judgment actions, counter-claims,
+     and cross-claims) alleging that a Contributor Version directly or
+     indirectly infringes any patent, then the rights granted to You by any and
+     all Contributors for the Covered Software under Section 2.1 of this License
+     shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
+     license agreements (excluding distributors and resellers) which have been
+     validly granted by You or Your distributors under this License prior to
+     termination shall survive termination.
+
+6. Disclaimer of Warranty
+
+   Covered Software is provided under this License on an “as is” basis, without
+   warranty of any kind, either expressed, implied, or statutory, including,
+   without limitation, warranties that the Covered Software is free of defects,
+   merchantable, fit for a particular purpose or non-infringing. The entire
+   risk as to the quality and performance of the Covered Software is with You.
+   Should any Covered Software prove defective in any respect, You (not any
+   Contributor) assume the cost of any necessary servicing, repair, or
+   correction. This disclaimer of warranty constitutes an essential part of this
+   License. No use of  any Covered Software is authorized under this License
+   except under this disclaimer.
+
+7. Limitation of Liability
+
+   Under no circumstances and under no legal theory, whether tort (including
+   negligence), contract, or otherwise, shall any Contributor, or anyone who
+   distributes Covered Software as permitted above, be liable to You for any
+   direct, indirect, special, incidental, or consequential damages of any
+   character including, without limitation, damages for lost profits, loss of
+   goodwill, work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses, even if such party shall have been
+   informed of the possibility of such damages. This limitation of liability
+   shall not apply to liability for death or personal injury resulting from such
+   party’s negligence to the extent applicable law prohibits such limitation.
+   Some jurisdictions do not allow the exclusion or limitation of incidental or
+   consequential damages, so this exclusion and limitation may not apply to You.
+
+8. Litigation
+
+   Any litigation relating to this License may be brought only in the courts of
+   a jurisdiction where the defendant maintains its principal place of business
+   and such litigation shall be governed by laws of that jurisdiction, without
+   reference to its conflict-of-law provisions. Nothing in this Section shall
+   prevent a party’s ability to bring cross-claims or counter-claims.
+
+9. Miscellaneous
+
+   This License represents the complete agreement concerning the subject matter
+   hereof. If any provision of this License is held to be unenforceable, such
+   provision shall be reformed only to the extent necessary to make it
+   enforceable. Any law or regulation which provides that the language of a
+   contract shall be construed against the drafter shall not be used to construe
+   this License against a Contributor.
+
+
+10. Versions of the License
+
+10.1. New Versions
+
+      Mozilla Foundation is the license steward. Except as provided in Section
+      10.3, no one other than the license steward has the right to modify or
+      publish new versions of this License. Each version will be given a
+      distinguishing version number.
+
+10.2. Effect of New Versions
+
+      You may distribute the Covered Software under the terms of the version of
+      the License under which You originally received the Covered Software, or
+      under the terms of any subsequent version published by the license
+      steward.
+
+10.3. Modified Versions
+
+      If you create software not governed by this License, and you want to
+      create a new license for such software, you may create and use a modified
+      version of this License if you rename the license and remove any
+      references to the name of the license steward (except to note that such
+      modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses
+      If You choose to distribute Source Code Form that is Incompatible With
+      Secondary Licenses under the terms of this version of the License, the
+      notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+
+      This Source Code Form is subject to the
+      terms of the Mozilla Public License, v.
+      2.0. If a copy of the MPL was not
+      distributed with this file, You can
+      obtain one at
+      http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular file, then
+You may include the notice in a location (such as a LICENSE file in a relevant
+directory) where a recipient would be likely to look for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - “Incompatible With Secondary Licenses” Notice
+
+      This Source Code Form is “Incompatible
+      With Secondary Licenses”, as defined by
+      the Mozilla Public License, v. 2.0.
+

+ 36 - 0
vendor/github.com/hashicorp/logutils/README.md

@@ -0,0 +1,36 @@
+# logutils
+
+logutils is a Go package that augments the standard library "log" package
+to make logging a bit more modern, without fragmenting the Go ecosystem
+with new logging packages.
+
+## The simplest thing that could possibly work
+
+Presumably your application already uses the default `log` package. To switch, you'll want your code to look like the following:
+
+```go
+package main
+
+import (
+	"log"
+	"os"
+
+	"github.com/hashicorp/logutils"
+)
+
+func main() {
+	filter := &logutils.LevelFilter{
+		Levels: []logutils.LogLevel{"DEBUG", "WARN", "ERROR"},
+		MinLevel: logutils.LogLevel("WARN"),
+		Writer: os.Stderr,
+	}
+	log.SetOutput(filter)
+
+	log.Print("[DEBUG] Debugging") // this will not print
+	log.Print("[WARN] Warning") // this will
+	log.Print("[ERROR] Erring") // and so will this
+	log.Print("Message I haven't updated") // and so will this
+}
+```
+
+This logs to standard error exactly like go's standard logger. Any log messages you haven't converted to have a level will continue to print as before.

+ 1 - 0
vendor/github.com/hashicorp/logutils/go.mod

@@ -0,0 +1 @@
+module github.com/hashicorp/logutils

+ 81 - 0
vendor/github.com/hashicorp/logutils/level.go

@@ -0,0 +1,81 @@
+// Package logutils augments the standard log package with levels.
+package logutils
+
+import (
+	"bytes"
+	"io"
+	"sync"
+)
+
+type LogLevel string
+
+// LevelFilter is an io.Writer that can be used with a logger that
+// will filter out log messages that aren't at least a certain level.
+//
+// Once the filter is in use somewhere, it is not safe to modify
+// the structure.
+type LevelFilter struct {
+	// Levels is the list of log levels, in increasing order of
+	// severity. Example might be: {"DEBUG", "WARN", "ERROR"}.
+	Levels []LogLevel
+
+	// MinLevel is the minimum level allowed through
+	MinLevel LogLevel
+
+	// The underlying io.Writer where log messages that pass the filter
+	// will be set.
+	Writer io.Writer
+
+	badLevels map[LogLevel]struct{}
+	once      sync.Once
+}
+
+// Check will check a given line if it would be included in the level
+// filter.
+func (f *LevelFilter) Check(line []byte) bool {
+	f.once.Do(f.init)
+
+	// Check for a log level
+	var level LogLevel
+	x := bytes.IndexByte(line, '[')
+	if x >= 0 {
+		y := bytes.IndexByte(line[x:], ']')
+		if y >= 0 {
+			level = LogLevel(line[x+1 : x+y])
+		}
+	}
+
+	_, ok := f.badLevels[level]
+	return !ok
+}
+
+func (f *LevelFilter) Write(p []byte) (n int, err error) {
+	// Note in general that io.Writer can receive any byte sequence
+	// to write, but the "log" package always guarantees that we only
+	// get a single line. We use that as a slight optimization within
+	// this method, assuming we're dealing with a single, complete line
+	// of log data.
+
+	if !f.Check(p) {
+		return len(p), nil
+	}
+
+	return f.Writer.Write(p)
+}
+
+// SetMinLevel is used to update the minimum log level
+func (f *LevelFilter) SetMinLevel(min LogLevel) {
+	f.MinLevel = min
+	f.init()
+}
+
+func (f *LevelFilter) init() {
+	badLevels := make(map[LogLevel]struct{})
+	for _, level := range f.Levels {
+		if level == f.MinLevel {
+			break
+		}
+		badLevels[level] = struct{}{}
+	}
+	f.badLevels = badLevels
+}

+ 44 - 0
vendor/github.com/jessevdk/go-flags/.travis.yml

@@ -0,0 +1,44 @@
+language: go
+
+os:
+  - linux
+  - osx
+
+go:
+  - 1.x
+  - 1.7.x
+  - 1.8.x
+  - 1.9.x
+  - 1.10.x
+
+install:
+  # go-flags
+  - go get -d -v ./...
+  - go build -v ./...
+
+  # linting
+  - go get github.com/golang/lint/golint
+
+  # code coverage
+  - go get golang.org/x/tools/cmd/cover
+  - go get github.com/onsi/ginkgo/ginkgo
+  - go get github.com/modocache/gover
+  - if [ "$TRAVIS_SECURE_ENV_VARS" = "true" ]; then go get github.com/mattn/goveralls; fi
+
+script:
+  # go-flags
+  - $(exit $(gofmt -l . | wc -l))
+  - go test -v ./...
+
+  # linting
+  - go tool vet -all=true -v=true . || true
+  - $(go env GOPATH | awk 'BEGIN{FS=":"} {print $1}')/bin/golint ./...
+
+  # code coverage
+  - $(go env GOPATH | awk 'BEGIN{FS=":"} {print $1}')/bin/ginkgo -r -cover
+  - $(go env GOPATH | awk 'BEGIN{FS=":"} {print $1}')/bin/gover
+  - if [ "$TRAVIS_SECURE_ENV_VARS" = "true" ]; then $(go env GOPATH | awk 'BEGIN{FS=":"} {print $1}')/bin/goveralls -coverprofile=gover.coverprofile -service=travis-ci -repotoken $COVERALLS_TOKEN; fi
+
+env:
+  # coveralls.io
+  secure: "RCYbiB4P0RjQRIoUx/vG/AjP3mmYCbzOmr86DCww1Z88yNcy3hYr3Cq8rpPtYU5v0g7wTpu4adaKIcqRE9xknYGbqj3YWZiCoBP1/n4Z+9sHW3Dsd9D/GRGeHUus0laJUGARjWoCTvoEtOgTdGQDoX7mH+pUUY0FBltNYUdOiiU="

+ 26 - 0
vendor/github.com/jessevdk/go-flags/LICENSE

@@ -0,0 +1,26 @@
+Copyright (c) 2012 Jesse van den Kieboom. All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+     notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+     copyright notice, this list of conditions and the following disclaimer
+     in the documentation and/or other materials provided with the
+     distribution.
+   * Neither the name of Google Inc. nor the names of its
+     contributors may be used to endorse or promote products derived from
+     this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 134 - 0
vendor/github.com/jessevdk/go-flags/README.md

@@ -0,0 +1,134 @@
+go-flags: a go library for parsing command line arguments
+=========================================================
+
+[![GoDoc](https://godoc.org/github.com/jessevdk/go-flags?status.png)](https://godoc.org/github.com/jessevdk/go-flags) [![Build Status](https://travis-ci.org/jessevdk/go-flags.svg?branch=master)](https://travis-ci.org/jessevdk/go-flags) [![Coverage Status](https://img.shields.io/coveralls/jessevdk/go-flags.svg)](https://coveralls.io/r/jessevdk/go-flags?branch=master)
+
+This library provides similar functionality to the builtin flag library of
+go, but provides much more functionality and nicer formatting. From the
+documentation:
+
+Package flags provides an extensive command line option parser.
+The flags package is similar in functionality to the go builtin flag package
+but provides more options and uses reflection to provide a convenient and
+succinct way of specifying command line options.
+
+Supported features:
+* Options with short names (-v)
+* Options with long names (--verbose)
+* Options with and without arguments (bool v.s. other type)
+* Options with optional arguments and default values
+* Multiple option groups each containing a set of options
+* Generate and print well-formatted help message
+* Passing remaining command line arguments after -- (optional)
+* Ignoring unknown command line options (optional)
+* Supports -I/usr/include -I=/usr/include -I /usr/include option argument specification
+* Supports multiple short options -aux
+* Supports all primitive go types (string, int{8..64}, uint{8..64}, float)
+* Supports same option multiple times (can store in slice or last option counts)
+* Supports maps
+* Supports function callbacks
+* Supports namespaces for (nested) option groups
+
+The flags package uses structs, reflection and struct field tags
+to allow users to specify command line options. This results in very simple
+and concise specification of your application options. For example:
+
+```go
+type Options struct {
+	Verbose []bool `short:"v" long:"verbose" description:"Show verbose debug information"`
+}
+```
+
+This specifies one option with a short name -v and a long name --verbose.
+When either -v or --verbose is found on the command line, a 'true' value
+will be appended to the Verbose field. e.g. when specifying -vvv, the
+resulting value of Verbose will be {[true, true, true]}.
+
+Example:
+--------
+```go
+var opts struct {
+	// Slice of bool will append 'true' each time the option
+	// is encountered (can be set multiple times, like -vvv)
+	Verbose []bool `short:"v" long:"verbose" description:"Show verbose debug information"`
+
+	// Example of automatic marshalling to desired type (uint)
+	Offset uint `long:"offset" description:"Offset"`
+
+	// Example of a callback, called each time the option is found.
+	Call func(string) `short:"c" description:"Call phone number"`
+
+	// Example of a required flag
+	Name string `short:"n" long:"name" description:"A name" required:"true"`
+
+	// Example of a value name
+	File string `short:"f" long:"file" description:"A file" value-name:"FILE"`
+
+	// Example of a pointer
+	Ptr *int `short:"p" description:"A pointer to an integer"`
+
+	// Example of a slice of strings
+	StringSlice []string `short:"s" description:"A slice of strings"`
+
+	// Example of a slice of pointers
+	PtrSlice []*string `long:"ptrslice" description:"A slice of pointers to string"`
+
+	// Example of a map
+	IntMap map[string]int `long:"intmap" description:"A map from string to int"`
+}
+
+// Callback which will invoke callto:<argument> to call a number.
+// Note that this works just on OS X (and probably only with
+// Skype) but it shows the idea.
+opts.Call = func(num string) {
+	cmd := exec.Command("open", "callto:"+num)
+	cmd.Start()
+	cmd.Process.Release()
+}
+
+// Make some fake arguments to parse.
+args := []string{
+	"-vv",
+	"--offset=5",
+	"-n", "Me",
+	"-p", "3",
+	"-s", "hello",
+	"-s", "world",
+	"--ptrslice", "hello",
+	"--ptrslice", "world",
+	"--intmap", "a:1",
+	"--intmap", "b:5",
+	"arg1",
+	"arg2",
+	"arg3",
+}
+
+// Parse flags from `args'. Note that here we use flags.ParseArgs for
+// the sake of making a working example. Normally, you would simply use
+// flags.Parse(&opts) which uses os.Args
+args, err := flags.ParseArgs(&opts, args)
+
+if err != nil {
+	panic(err)
+}
+
+fmt.Printf("Verbosity: %v\n", opts.Verbose)
+fmt.Printf("Offset: %d\n", opts.Offset)
+fmt.Printf("Name: %s\n", opts.Name)
+fmt.Printf("Ptr: %d\n", *opts.Ptr)
+fmt.Printf("StringSlice: %v\n", opts.StringSlice)
+fmt.Printf("PtrSlice: [%v %v]\n", *opts.PtrSlice[0], *opts.PtrSlice[1])
+fmt.Printf("IntMap: [a:%v b:%v]\n", opts.IntMap["a"], opts.IntMap["b"])
+fmt.Printf("Remaining args: %s\n", strings.Join(args, " "))
+
+// Output: Verbosity: [true true]
+// Offset: 5
+// Name: Me
+// Ptr: 3
+// StringSlice: [hello world]
+// PtrSlice: [hello world]
+// IntMap: [a:1 b:5]
+// Remaining args: arg1 arg2 arg3
+```
+
+More information can be found in the godocs: <http://godoc.org/github.com/jessevdk/go-flags>

+ 27 - 0
vendor/github.com/jessevdk/go-flags/arg.go

@@ -0,0 +1,27 @@
+package flags
+
+import (
+	"reflect"
+)
+
+// Arg represents a positional argument on the command line.
+type Arg struct {
+	// The name of the positional argument (used in the help)
+	Name string
+
+	// A description of the positional argument (used in the help)
+	Description string
+
+	// The minimal number of required positional arguments
+	Required int
+
+	// The maximum number of required positional arguments
+	RequiredMaximum int
+
+	value reflect.Value
+	tag   multiTag
+}
+
+func (a *Arg) isRemaining() bool {
+	return a.value.Type().Kind() == reflect.Slice
+}

+ 16 - 0
vendor/github.com/jessevdk/go-flags/check_crosscompile.sh

@@ -0,0 +1,16 @@
+#!/bin/bash
+
+set -e
+
+echo '# linux arm7'
+GOARM=7 GOARCH=arm GOOS=linux go build
+echo '# linux arm5'
+GOARM=5 GOARCH=arm GOOS=linux go build
+echo '# windows 386'
+GOARCH=386 GOOS=windows go build
+echo '# windows amd64'
+GOARCH=amd64 GOOS=windows go build
+echo '# darwin'
+GOARCH=amd64 GOOS=darwin go build
+echo '# freebsd'
+GOARCH=amd64 GOOS=freebsd go build

+ 59 - 0
vendor/github.com/jessevdk/go-flags/closest.go

@@ -0,0 +1,59 @@
+package flags
+
+func levenshtein(s string, t string) int {
+	if len(s) == 0 {
+		return len(t)
+	}
+
+	if len(t) == 0 {
+		return len(s)
+	}
+
+	dists := make([][]int, len(s)+1)
+	for i := range dists {
+		dists[i] = make([]int, len(t)+1)
+		dists[i][0] = i
+	}
+
+	for j := range t {
+		dists[0][j] = j
+	}
+
+	for i, sc := range s {
+		for j, tc := range t {
+			if sc == tc {
+				dists[i+1][j+1] = dists[i][j]
+			} else {
+				dists[i+1][j+1] = dists[i][j] + 1
+				if dists[i+1][j] < dists[i+1][j+1] {
+					dists[i+1][j+1] = dists[i+1][j] + 1
+				}
+				if dists[i][j+1] < dists[i+1][j+1] {
+					dists[i+1][j+1] = dists[i][j+1] + 1
+				}
+			}
+		}
+	}
+
+	return dists[len(s)][len(t)]
+}
+
+func closestChoice(cmd string, choices []string) (string, int) {
+	if len(choices) == 0 {
+		return "", 0
+	}
+
+	mincmd := -1
+	mindist := -1
+
+	for i, c := range choices {
+		l := levenshtein(cmd, c)
+
+		if mincmd < 0 || l < mindist {
+			mindist = l
+			mincmd = i
+		}
+	}
+
+	return choices[mincmd], mindist
+}

+ 465 - 0
vendor/github.com/jessevdk/go-flags/command.go

@@ -0,0 +1,465 @@
+package flags
+
+import (
+	"reflect"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+// Command represents an application command. Commands can be added to the
+// parser (which itself is a command) and are selected/executed when its name
+// is specified on the command line. The Command type embeds a Group and
+// therefore also carries a set of command specific options.
+type Command struct {
+	// Embedded, see Group for more information
+	*Group
+
+	// The name by which the command can be invoked
+	Name string
+
+	// The active sub command (set by parsing) or nil
+	Active *Command
+
+	// Whether subcommands are optional
+	SubcommandsOptional bool
+
+	// Aliases for the command
+	Aliases []string
+
+	// Whether positional arguments are required
+	ArgsRequired bool
+
+	commands            []*Command
+	hasBuiltinHelpGroup bool
+	args                []*Arg
+}
+
+// Commander is an interface which can be implemented by any command added in
+// the options. When implemented, the Execute method will be called for the last
+// specified (sub)command providing the remaining command line arguments.
+type Commander interface {
+	// Execute will be called for the last active (sub)command. The
+	// args argument contains the remaining command line arguments. The
+	// error that Execute returns will be eventually passed out of the
+	// Parse method of the Parser.
+	Execute(args []string) error
+}
+
+// Usage is an interface which can be implemented to show a custom usage string
+// in the help message shown for a command.
+type Usage interface {
+	// Usage is called for commands to allow customized printing of command
+	// usage in the generated help message.
+	Usage() string
+}
+
+type lookup struct {
+	shortNames map[string]*Option
+	longNames  map[string]*Option
+
+	commands map[string]*Command
+}
+
+// AddCommand adds a new command to the parser with the given name and data. The
+// data needs to be a pointer to a struct from which the fields indicate which
+// options are in the command. The provided data can implement the Command and
+// Usage interfaces.
+func (c *Command) AddCommand(command string, shortDescription string, longDescription string, data interface{}) (*Command, error) {
+	cmd := newCommand(command, shortDescription, longDescription, data)
+
+	cmd.parent = c
+
+	if err := cmd.scan(); err != nil {
+		return nil, err
+	}
+
+	c.commands = append(c.commands, cmd)
+	return cmd, nil
+}
+
+// AddGroup adds a new group to the command with the given name and data. The
+// data needs to be a pointer to a struct from which the fields indicate which
+// options are in the group.
+func (c *Command) AddGroup(shortDescription string, longDescription string, data interface{}) (*Group, error) {
+	group := newGroup(shortDescription, longDescription, data)
+
+	group.parent = c
+
+	if err := group.scanType(c.scanSubcommandHandler(group)); err != nil {
+		return nil, err
+	}
+
+	c.groups = append(c.groups, group)
+	return group, nil
+}
+
+// Commands returns a list of subcommands of this command.
+func (c *Command) Commands() []*Command {
+	return c.commands
+}
+
+// Find locates the subcommand with the given name and returns it. If no such
+// command can be found Find will return nil.
+func (c *Command) Find(name string) *Command {
+	for _, cc := range c.commands {
+		if cc.match(name) {
+			return cc
+		}
+	}
+
+	return nil
+}
+
+// FindOptionByLongName finds an option that is part of the command, or any of
+// its parent commands, by matching its long name (including the option
+// namespace).
+func (c *Command) FindOptionByLongName(longName string) (option *Option) {
+	for option == nil && c != nil {
+		option = c.Group.FindOptionByLongName(longName)
+
+		c, _ = c.parent.(*Command)
+	}
+
+	return option
+}
+
+// FindOptionByShortName finds an option that is part of the command, or any of
+// its parent commands, by matching its long name (including the option
+// namespace).
+func (c *Command) FindOptionByShortName(shortName rune) (option *Option) {
+	for option == nil && c != nil {
+		option = c.Group.FindOptionByShortName(shortName)
+
+		c, _ = c.parent.(*Command)
+	}
+
+	return option
+}
+
+// Args returns a list of positional arguments associated with this command.
+func (c *Command) Args() []*Arg {
+	ret := make([]*Arg, len(c.args))
+	copy(ret, c.args)
+
+	return ret
+}
+
+func newCommand(name string, shortDescription string, longDescription string, data interface{}) *Command {
+	return &Command{
+		Group: newGroup(shortDescription, longDescription, data),
+		Name:  name,
+	}
+}
+
+func (c *Command) scanSubcommandHandler(parentg *Group) scanHandler {
+	f := func(realval reflect.Value, sfield *reflect.StructField) (bool, error) {
+		mtag := newMultiTag(string(sfield.Tag))
+
+		if err := mtag.Parse(); err != nil {
+			return true, err
+		}
+
+		positional := mtag.Get("positional-args")
+
+		if len(positional) != 0 {
+			stype := realval.Type()
+
+			for i := 0; i < stype.NumField(); i++ {
+				field := stype.Field(i)
+
+				m := newMultiTag((string(field.Tag)))
+
+				if err := m.Parse(); err != nil {
+					return true, err
+				}
+
+				name := m.Get("positional-arg-name")
+
+				if len(name) == 0 {
+					name = field.Name
+				}
+
+				required := -1
+				requiredMaximum := -1
+
+				sreq := m.Get("required")
+
+				if sreq != "" {
+					required = 1
+
+					rng := strings.SplitN(sreq, "-", 2)
+
+					if len(rng) > 1 {
+						if preq, err := strconv.ParseInt(rng[0], 10, 32); err == nil {
+							required = int(preq)
+						}
+
+						if preq, err := strconv.ParseInt(rng[1], 10, 32); err == nil {
+							requiredMaximum = int(preq)
+						}
+					} else {
+						if preq, err := strconv.ParseInt(sreq, 10, 32); err == nil {
+							required = int(preq)
+						}
+					}
+				}
+
+				arg := &Arg{
+					Name:            name,
+					Description:     m.Get("description"),
+					Required:        required,
+					RequiredMaximum: requiredMaximum,
+
+					value: realval.Field(i),
+					tag:   m,
+				}
+
+				c.args = append(c.args, arg)
+
+				if len(mtag.Get("required")) != 0 {
+					c.ArgsRequired = true
+				}
+			}
+
+			return true, nil
+		}
+
+		subcommand := mtag.Get("command")
+
+		if len(subcommand) != 0 {
+			var ptrval reflect.Value
+
+			if realval.Kind() == reflect.Ptr {
+				ptrval = realval
+
+				if ptrval.IsNil() {
+					ptrval.Set(reflect.New(ptrval.Type().Elem()))
+				}
+			} else {
+				ptrval = realval.Addr()
+			}
+
+			shortDescription := mtag.Get("description")
+			longDescription := mtag.Get("long-description")
+			subcommandsOptional := mtag.Get("subcommands-optional")
+			aliases := mtag.GetMany("alias")
+
+			subc, err := c.AddCommand(subcommand, shortDescription, longDescription, ptrval.Interface())
+
+			if err != nil {
+				return true, err
+			}
+
+			subc.Hidden = mtag.Get("hidden") != ""
+
+			if len(subcommandsOptional) > 0 {
+				subc.SubcommandsOptional = true
+			}
+
+			if len(aliases) > 0 {
+				subc.Aliases = aliases
+			}
+
+			return true, nil
+		}
+
+		return parentg.scanSubGroupHandler(realval, sfield)
+	}
+
+	return f
+}
+
+func (c *Command) scan() error {
+	return c.scanType(c.scanSubcommandHandler(c.Group))
+}
+
+func (c *Command) eachOption(f func(*Command, *Group, *Option)) {
+	c.eachCommand(func(c *Command) {
+		c.eachGroup(func(g *Group) {
+			for _, option := range g.options {
+				f(c, g, option)
+			}
+		})
+	}, true)
+}
+
+func (c *Command) eachCommand(f func(*Command), recurse bool) {
+	f(c)
+
+	for _, cc := range c.commands {
+		if recurse {
+			cc.eachCommand(f, true)
+		} else {
+			f(cc)
+		}
+	}
+}
+
+func (c *Command) eachActiveGroup(f func(cc *Command, g *Group)) {
+	c.eachGroup(func(g *Group) {
+		f(c, g)
+	})
+
+	if c.Active != nil {
+		c.Active.eachActiveGroup(f)
+	}
+}
+
+func (c *Command) addHelpGroups(showHelp func() error) {
+	if !c.hasBuiltinHelpGroup {
+		c.addHelpGroup(showHelp)
+		c.hasBuiltinHelpGroup = true
+	}
+
+	for _, cc := range c.commands {
+		cc.addHelpGroups(showHelp)
+	}
+}
+
+func (c *Command) makeLookup() lookup {
+	ret := lookup{
+		shortNames: make(map[string]*Option),
+		longNames:  make(map[string]*Option),
+		commands:   make(map[string]*Command),
+	}
+
+	parent := c.parent
+
+	var parents []*Command
+
+	for parent != nil {
+		if cmd, ok := parent.(*Command); ok {
+			parents = append(parents, cmd)
+			parent = cmd.parent
+		} else {
+			parent = nil
+		}
+	}
+
+	for i := len(parents) - 1; i >= 0; i-- {
+		parents[i].fillLookup(&ret, true)
+	}
+
+	c.fillLookup(&ret, false)
+	return ret
+}
+
+func (c *Command) fillLookup(ret *lookup, onlyOptions bool) {
+	c.eachGroup(func(g *Group) {
+		for _, option := range g.options {
+			if option.ShortName != 0 {
+				ret.shortNames[string(option.ShortName)] = option
+			}
+
+			if len(option.LongName) > 0 {
+				ret.longNames[option.LongNameWithNamespace()] = option
+			}
+		}
+	})
+
+	if onlyOptions {
+		return
+	}
+
+	for _, subcommand := range c.commands {
+		ret.commands[subcommand.Name] = subcommand
+
+		for _, a := range subcommand.Aliases {
+			ret.commands[a] = subcommand
+		}
+	}
+}
+
+func (c *Command) groupByName(name string) *Group {
+	if grp := c.Group.groupByName(name); grp != nil {
+		return grp
+	}
+
+	for _, subc := range c.commands {
+		prefix := subc.Name + "."
+
+		if strings.HasPrefix(name, prefix) {
+			if grp := subc.groupByName(name[len(prefix):]); grp != nil {
+				return grp
+			}
+		} else if name == subc.Name {
+			return subc.Group
+		}
+	}
+
+	return nil
+}
+
+type commandList []*Command
+
+func (c commandList) Less(i, j int) bool {
+	return c[i].Name < c[j].Name
+}
+
+func (c commandList) Len() int {
+	return len(c)
+}
+
+func (c commandList) Swap(i, j int) {
+	c[i], c[j] = c[j], c[i]
+}
+
+func (c *Command) sortedVisibleCommands() []*Command {
+	ret := commandList(c.visibleCommands())
+	sort.Sort(ret)
+
+	return []*Command(ret)
+}
+
+func (c *Command) visibleCommands() []*Command {
+	ret := make([]*Command, 0, len(c.commands))
+
+	for _, cmd := range c.commands {
+		if !cmd.Hidden {
+			ret = append(ret, cmd)
+		}
+	}
+
+	return ret
+}
+
+func (c *Command) match(name string) bool {
+	if c.Name == name {
+		return true
+	}
+
+	for _, v := range c.Aliases {
+		if v == name {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (c *Command) hasCliOptions() bool {
+	ret := false
+
+	c.eachGroup(func(g *Group) {
+		if g.isBuiltinHelp {
+			return
+		}
+
+		for _, opt := range g.options {
+			if opt.canCli() {
+				ret = true
+			}
+		}
+	})
+
+	return ret
+}
+
+func (c *Command) fillParseState(s *parseState) {
+	s.positional = make([]*Arg, len(c.args))
+	copy(s.positional, c.args)
+
+	s.lookup = c.makeLookup()
+	s.command = c
+}

+ 309 - 0
vendor/github.com/jessevdk/go-flags/completion.go

@@ -0,0 +1,309 @@
+package flags
+
+import (
+	"fmt"
+	"path/filepath"
+	"reflect"
+	"sort"
+	"strings"
+	"unicode/utf8"
+)
+
+// Completion is a type containing information of a completion.
+type Completion struct {
+	// The completed item
+	Item string
+
+	// A description of the completed item (optional)
+	Description string
+}
+
+type completions []Completion
+
+func (c completions) Len() int {
+	return len(c)
+}
+
+func (c completions) Less(i, j int) bool {
+	return c[i].Item < c[j].Item
+}
+
+func (c completions) Swap(i, j int) {
+	c[i], c[j] = c[j], c[i]
+}
+
+// Completer is an interface which can be implemented by types
+// to provide custom command line argument completion.
+type Completer interface {
+	// Complete receives a prefix representing a (partial) value
+	// for its type and should provide a list of possible valid
+	// completions.
+	Complete(match string) []Completion
+}
+
+type completion struct {
+	parser *Parser
+}
+
+// Filename is a string alias which provides filename completion.
+type Filename string
+
+func completionsWithoutDescriptions(items []string) []Completion {
+	ret := make([]Completion, len(items))
+
+	for i, v := range items {
+		ret[i].Item = v
+	}
+
+	return ret
+}
+
+// Complete returns a list of existing files with the given
+// prefix.
+func (f *Filename) Complete(match string) []Completion {
+	ret, _ := filepath.Glob(match + "*")
+	return completionsWithoutDescriptions(ret)
+}
+
+func (c *completion) skipPositional(s *parseState, n int) {
+	if n >= len(s.positional) {
+		s.positional = nil
+	} else {
+		s.positional = s.positional[n:]
+	}
+}
+
+func (c *completion) completeOptionNames(s *parseState, prefix string, match string, short bool) []Completion {
+	if short && len(match) != 0 {
+		return []Completion{
+			Completion{
+				Item: prefix + match,
+			},
+		}
+	}
+
+	var results []Completion
+	repeats := map[string]bool{}
+
+	for name, opt := range s.lookup.longNames {
+		if strings.HasPrefix(name, match) && !opt.Hidden {
+			results = append(results, Completion{
+				Item:        defaultLongOptDelimiter + name,
+				Description: opt.Description,
+			})
+
+			if short {
+				repeats[string(opt.ShortName)] = true
+			}
+		}
+	}
+
+	if short {
+		for name, opt := range s.lookup.shortNames {
+			if _, exist := repeats[name]; !exist && strings.HasPrefix(name, match) && !opt.Hidden {
+				results = append(results, Completion{
+					Item:        string(defaultShortOptDelimiter) + name,
+					Description: opt.Description,
+				})
+			}
+		}
+	}
+
+	return results
+}
+
+func (c *completion) completeNamesForLongPrefix(s *parseState, prefix string, match string) []Completion {
+	return c.completeOptionNames(s, prefix, match, false)
+}
+
+func (c *completion) completeNamesForShortPrefix(s *parseState, prefix string, match string) []Completion {
+	return c.completeOptionNames(s, prefix, match, true)
+}
+
+func (c *completion) completeCommands(s *parseState, match string) []Completion {
+	n := make([]Completion, 0, len(s.command.commands))
+
+	for _, cmd := range s.command.commands {
+		if cmd.data != c && strings.HasPrefix(cmd.Name, match) {
+			n = append(n, Completion{
+				Item:        cmd.Name,
+				Description: cmd.ShortDescription,
+			})
+		}
+	}
+
+	return n
+}
+
+func (c *completion) completeValue(value reflect.Value, prefix string, match string) []Completion {
+	if value.Kind() == reflect.Slice {
+		value = reflect.New(value.Type().Elem())
+	}
+	i := value.Interface()
+
+	var ret []Completion
+
+	if cmp, ok := i.(Completer); ok {
+		ret = cmp.Complete(match)
+	} else if value.CanAddr() {
+		if cmp, ok = value.Addr().Interface().(Completer); ok {
+			ret = cmp.Complete(match)
+		}
+	}
+
+	for i, v := range ret {
+		ret[i].Item = prefix + v.Item
+	}
+
+	return ret
+}
+
+func (c *completion) complete(args []string) []Completion {
+	if len(args) == 0 {
+		args = []string{""}
+	}
+
+	s := &parseState{
+		args: args,
+	}
+
+	c.parser.fillParseState(s)
+
+	var opt *Option
+
+	for len(s.args) > 1 {
+		arg := s.pop()
+
+		if (c.parser.Options&PassDoubleDash) != None && arg == "--" {
+			opt = nil
+			c.skipPositional(s, len(s.args)-1)
+
+			break
+		}
+
+		if argumentIsOption(arg) {
+			prefix, optname, islong := stripOptionPrefix(arg)
+			optname, _, argument := splitOption(prefix, optname, islong)
+
+			if argument == nil {
+				var o *Option
+				canarg := true
+
+				if islong {
+					o = s.lookup.longNames[optname]
+				} else {
+					for i, r := range optname {
+						sname := string(r)
+						o = s.lookup.shortNames[sname]
+
+						if o == nil {
+							break
+						}
+
+						if i == 0 && o.canArgument() && len(optname) != len(sname) {
+							canarg = false
+							break
+						}
+					}
+				}
+
+				if o == nil && (c.parser.Options&PassAfterNonOption) != None {
+					opt = nil
+					c.skipPositional(s, len(s.args)-1)
+
+					break
+				} else if o != nil && o.canArgument() && !o.OptionalArgument && canarg {
+					if len(s.args) > 1 {
+						s.pop()
+					} else {
+						opt = o
+					}
+				}
+			}
+		} else {
+			if len(s.positional) > 0 {
+				if !s.positional[0].isRemaining() {
+					// Don't advance beyond a remaining positional arg (because
+					// it consumes all subsequent args).
+					s.positional = s.positional[1:]
+				}
+			} else if cmd, ok := s.lookup.commands[arg]; ok {
+				cmd.fillParseState(s)
+			}
+
+			opt = nil
+		}
+	}
+
+	lastarg := s.args[len(s.args)-1]
+	var ret []Completion
+
+	if opt != nil {
+		// Completion for the argument of 'opt'
+		ret = c.completeValue(opt.value, "", lastarg)
+	} else if argumentStartsOption(lastarg) {
+		// Complete the option
+		prefix, optname, islong := stripOptionPrefix(lastarg)
+		optname, split, argument := splitOption(prefix, optname, islong)
+
+		if argument == nil && !islong {
+			rname, n := utf8.DecodeRuneInString(optname)
+			sname := string(rname)
+
+			if opt := s.lookup.shortNames[sname]; opt != nil && opt.canArgument() {
+				ret = c.completeValue(opt.value, prefix+sname, optname[n:])
+			} else {
+				ret = c.completeNamesForShortPrefix(s, prefix, optname)
+			}
+		} else if argument != nil {
+			if islong {
+				opt = s.lookup.longNames[optname]
+			} else {
+				opt = s.lookup.shortNames[optname]
+			}
+
+			if opt != nil {
+				ret = c.completeValue(opt.value, prefix+optname+split, *argument)
+			}
+		} else if islong {
+			ret = c.completeNamesForLongPrefix(s, prefix, optname)
+		} else {
+			ret = c.completeNamesForShortPrefix(s, prefix, optname)
+		}
+	} else if len(s.positional) > 0 {
+		// Complete for positional argument
+		ret = c.completeValue(s.positional[0].value, "", lastarg)
+	} else if len(s.command.commands) > 0 {
+		// Complete for command
+		ret = c.completeCommands(s, lastarg)
+	}
+
+	sort.Sort(completions(ret))
+	return ret
+}
+
+func (c *completion) print(items []Completion, showDescriptions bool) {
+	if showDescriptions && len(items) > 1 {
+		maxl := 0
+
+		for _, v := range items {
+			if len(v.Item) > maxl {
+				maxl = len(v.Item)
+			}
+		}
+
+		for _, v := range items {
+			fmt.Printf("%s", v.Item)
+
+			if len(v.Description) > 0 {
+				fmt.Printf("%s  # %s", strings.Repeat(" ", maxl-len(v.Item)), v.Description)
+			}
+
+			fmt.Printf("\n")
+		}
+	} else {
+		for _, v := range items {
+			fmt.Println(v.Item)
+		}
+	}
+}

+ 348 - 0
vendor/github.com/jessevdk/go-flags/convert.go

@@ -0,0 +1,348 @@
+// Copyright 2012 Jesse van den Kieboom. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package flags
+
+import (
+	"fmt"
+	"reflect"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// Marshaler is the interface implemented by types that can marshal themselves
+// to a string representation of the flag.
+type Marshaler interface {
+	// MarshalFlag marshals a flag value to its string representation.
+	MarshalFlag() (string, error)
+}
+
+// Unmarshaler is the interface implemented by types that can unmarshal a flag
+// argument to themselves. The provided value is directly passed from the
+// command line.
+type Unmarshaler interface {
+	// UnmarshalFlag unmarshals a string value representation to the flag
+	// value (which therefore needs to be a pointer receiver).
+	UnmarshalFlag(value string) error
+}
+
+func getBase(options multiTag, base int) (int, error) {
+	sbase := options.Get("base")
+
+	var err error
+	var ivbase int64
+
+	if sbase != "" {
+		ivbase, err = strconv.ParseInt(sbase, 10, 32)
+		base = int(ivbase)
+	}
+
+	return base, err
+}
+
+func convertMarshal(val reflect.Value) (bool, string, error) {
+	// Check first for the Marshaler interface
+	if val.Type().NumMethod() > 0 && val.CanInterface() {
+		if marshaler, ok := val.Interface().(Marshaler); ok {
+			ret, err := marshaler.MarshalFlag()
+			return true, ret, err
+		}
+	}
+
+	return false, "", nil
+}
+
+func convertToString(val reflect.Value, options multiTag) (string, error) {
+	if ok, ret, err := convertMarshal(val); ok {
+		return ret, err
+	}
+
+	tp := val.Type()
+
+	// Support for time.Duration
+	if tp == reflect.TypeOf((*time.Duration)(nil)).Elem() {
+		stringer := val.Interface().(fmt.Stringer)
+		return stringer.String(), nil
+	}
+
+	switch tp.Kind() {
+	case reflect.String:
+		return val.String(), nil
+	case reflect.Bool:
+		if val.Bool() {
+			return "true", nil
+		}
+
+		return "false", nil
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		base, err := getBase(options, 10)
+
+		if err != nil {
+			return "", err
+		}
+
+		return strconv.FormatInt(val.Int(), base), nil
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		base, err := getBase(options, 10)
+
+		if err != nil {
+			return "", err
+		}
+
+		return strconv.FormatUint(val.Uint(), base), nil
+	case reflect.Float32, reflect.Float64:
+		return strconv.FormatFloat(val.Float(), 'g', -1, tp.Bits()), nil
+	case reflect.Slice:
+		if val.Len() == 0 {
+			return "", nil
+		}
+
+		ret := "["
+
+		for i := 0; i < val.Len(); i++ {
+			if i != 0 {
+				ret += ", "
+			}
+
+			item, err := convertToString(val.Index(i), options)
+
+			if err != nil {
+				return "", err
+			}
+
+			ret += item
+		}
+
+		return ret + "]", nil
+	case reflect.Map:
+		ret := "{"
+
+		for i, key := range val.MapKeys() {
+			if i != 0 {
+				ret += ", "
+			}
+
+			keyitem, err := convertToString(key, options)
+
+			if err != nil {
+				return "", err
+			}
+
+			item, err := convertToString(val.MapIndex(key), options)
+
+			if err != nil {
+				return "", err
+			}
+
+			ret += keyitem + ":" + item
+		}
+
+		return ret + "}", nil
+	case reflect.Ptr:
+		return convertToString(reflect.Indirect(val), options)
+	case reflect.Interface:
+		if !val.IsNil() {
+			return convertToString(val.Elem(), options)
+		}
+	}
+
+	return "", nil
+}
+
+func convertUnmarshal(val string, retval reflect.Value) (bool, error) {
+	if retval.Type().NumMethod() > 0 && retval.CanInterface() {
+		if unmarshaler, ok := retval.Interface().(Unmarshaler); ok {
+			if retval.IsNil() {
+				retval.Set(reflect.New(retval.Type().Elem()))
+
+				// Re-assign from the new value
+				unmarshaler = retval.Interface().(Unmarshaler)
+			}
+
+			return true, unmarshaler.UnmarshalFlag(val)
+		}
+	}
+
+	if retval.Type().Kind() != reflect.Ptr && retval.CanAddr() {
+		return convertUnmarshal(val, retval.Addr())
+	}
+
+	if retval.Type().Kind() == reflect.Interface && !retval.IsNil() {
+		return convertUnmarshal(val, retval.Elem())
+	}
+
+	return false, nil
+}
+
+func convert(val string, retval reflect.Value, options multiTag) error {
+	if ok, err := convertUnmarshal(val, retval); ok {
+		return err
+	}
+
+	tp := retval.Type()
+
+	// Support for time.Duration
+	if tp == reflect.TypeOf((*time.Duration)(nil)).Elem() {
+		parsed, err := time.ParseDuration(val)
+
+		if err != nil {
+			return err
+		}
+
+		retval.SetInt(int64(parsed))
+		return nil
+	}
+
+	switch tp.Kind() {
+	case reflect.String:
+		retval.SetString(val)
+	case reflect.Bool:
+		if val == "" {
+			retval.SetBool(true)
+		} else {
+			b, err := strconv.ParseBool(val)
+
+			if err != nil {
+				return err
+			}
+
+			retval.SetBool(b)
+		}
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		base, err := getBase(options, 10)
+
+		if err != nil {
+			return err
+		}
+
+		parsed, err := strconv.ParseInt(val, base, tp.Bits())
+
+		if err != nil {
+			return err
+		}
+
+		retval.SetInt(parsed)
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		base, err := getBase(options, 10)
+
+		if err != nil {
+			return err
+		}
+
+		parsed, err := strconv.ParseUint(val, base, tp.Bits())
+
+		if err != nil {
+			return err
+		}
+
+		retval.SetUint(parsed)
+	case reflect.Float32, reflect.Float64:
+		parsed, err := strconv.ParseFloat(val, tp.Bits())
+
+		if err != nil {
+			return err
+		}
+
+		retval.SetFloat(parsed)
+	case reflect.Slice:
+		elemtp := tp.Elem()
+
+		elemvalptr := reflect.New(elemtp)
+		elemval := reflect.Indirect(elemvalptr)
+
+		if err := convert(val, elemval, options); err != nil {
+			return err
+		}
+
+		retval.Set(reflect.Append(retval, elemval))
+	case reflect.Map:
+		parts := strings.SplitN(val, ":", 2)
+
+		key := parts[0]
+		var value string
+
+		if len(parts) == 2 {
+			value = parts[1]
+		}
+
+		keytp := tp.Key()
+		keyval := reflect.New(keytp)
+
+		if err := convert(key, keyval, options); err != nil {
+			return err
+		}
+
+		valuetp := tp.Elem()
+		valueval := reflect.New(valuetp)
+
+		if err := convert(value, valueval, options); err != nil {
+			return err
+		}
+
+		if retval.IsNil() {
+			retval.Set(reflect.MakeMap(tp))
+		}
+
+		retval.SetMapIndex(reflect.Indirect(keyval), reflect.Indirect(valueval))
+	case reflect.Ptr:
+		if retval.IsNil() {
+			retval.Set(reflect.New(retval.Type().Elem()))
+		}
+
+		return convert(val, reflect.Indirect(retval), options)
+	case reflect.Interface:
+		if !retval.IsNil() {
+			return convert(val, retval.Elem(), options)
+		}
+	}
+
+	return nil
+}
+
+func isPrint(s string) bool {
+	for _, c := range s {
+		if !strconv.IsPrint(c) {
+			return false
+		}
+	}
+
+	return true
+}
+
+func quoteIfNeeded(s string) string {
+	if !isPrint(s) {
+		return strconv.Quote(s)
+	}
+
+	return s
+}
+
+func quoteIfNeededV(s []string) []string {
+	ret := make([]string, len(s))
+
+	for i, v := range s {
+		ret[i] = quoteIfNeeded(v)
+	}
+
+	return ret
+}
+
+func quoteV(s []string) []string {
+	ret := make([]string, len(s))
+
+	for i, v := range s {
+		ret[i] = strconv.Quote(v)
+	}
+
+	return ret
+}
+
+func unquoteIfPossible(s string) (string, error) {
+	if len(s) == 0 || s[0] != '"' {
+		return s, nil
+	}
+
+	return strconv.Unquote(s)
+}

+ 134 - 0
vendor/github.com/jessevdk/go-flags/error.go

@@ -0,0 +1,134 @@
+package flags
+
+import (
+	"fmt"
+)
+
+// ErrorType represents the type of error.
+type ErrorType uint
+
+const (
+	// ErrUnknown indicates a generic error.
+	ErrUnknown ErrorType = iota
+
+	// ErrExpectedArgument indicates that an argument was expected.
+	ErrExpectedArgument
+
+	// ErrUnknownFlag indicates an unknown flag.
+	ErrUnknownFlag
+
+	// ErrUnknownGroup indicates an unknown group.
+	ErrUnknownGroup
+
+	// ErrMarshal indicates a marshalling error while converting values.
+	ErrMarshal
+
+	// ErrHelp indicates that the built-in help was shown (the error
+	// contains the help message).
+	ErrHelp
+
+	// ErrNoArgumentForBool indicates that an argument was given for a
+	// boolean flag (which don't not take any arguments).
+	ErrNoArgumentForBool
+
+	// ErrRequired indicates that a required flag was not provided.
+	ErrRequired
+
+	// ErrShortNameTooLong indicates that a short flag name was specified,
+	// longer than one character.
+	ErrShortNameTooLong
+
+	// ErrDuplicatedFlag indicates that a short or long flag has been
+	// defined more than once
+	ErrDuplicatedFlag
+
+	// ErrTag indicates an error while parsing flag tags.
+	ErrTag
+
+	// ErrCommandRequired indicates that a command was required but not
+	// specified
+	ErrCommandRequired
+
+	// ErrUnknownCommand indicates that an unknown command was specified.
+	ErrUnknownCommand
+
+	// ErrInvalidChoice indicates an invalid option value which only allows
+	// a certain number of choices.
+	ErrInvalidChoice
+
+	// ErrInvalidTag indicates an invalid tag or invalid use of an existing tag
+	ErrInvalidTag
+)
+
+func (e ErrorType) String() string {
+	switch e {
+	case ErrUnknown:
+		return "unknown"
+	case ErrExpectedArgument:
+		return "expected argument"
+	case ErrUnknownFlag:
+		return "unknown flag"
+	case ErrUnknownGroup:
+		return "unknown group"
+	case ErrMarshal:
+		return "marshal"
+	case ErrHelp:
+		return "help"
+	case ErrNoArgumentForBool:
+		return "no argument for bool"
+	case ErrRequired:
+		return "required"
+	case ErrShortNameTooLong:
+		return "short name too long"
+	case ErrDuplicatedFlag:
+		return "duplicated flag"
+	case ErrTag:
+		return "tag"
+	case ErrCommandRequired:
+		return "command required"
+	case ErrUnknownCommand:
+		return "unknown command"
+	case ErrInvalidChoice:
+		return "invalid choice"
+	case ErrInvalidTag:
+		return "invalid tag"
+	}
+
+	return "unrecognized error type"
+}
+
+// Error represents a parser error. The error returned from Parse is of this
+// type. The error contains both a Type and Message.
+type Error struct {
+	// The type of error
+	Type ErrorType
+
+	// The error message
+	Message string
+}
+
+// Error returns the error's message
+func (e *Error) Error() string {
+	return e.Message
+}
+
+func newError(tp ErrorType, message string) *Error {
+	return &Error{
+		Type:    tp,
+		Message: message,
+	}
+}
+
+func newErrorf(tp ErrorType, format string, args ...interface{}) *Error {
+	return newError(tp, fmt.Sprintf(format, args...))
+}
+
+func wrapError(err error) *Error {
+	ret, ok := err.(*Error)
+
+	if !ok {
+		return newError(ErrUnknown, err.Error())
+	}
+
+	return ret
+}

+ 258 - 0
vendor/github.com/jessevdk/go-flags/flags.go

@@ -0,0 +1,258 @@
+// Copyright 2012 Jesse van den Kieboom. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+Package flags provides an extensive command line option parser.
+The flags package is similar in functionality to the go built-in flag package
+but provides more options and uses reflection to provide a convenient and
+succinct way of specifying command line options.
+
+
+Supported features
+
+The following features are supported in go-flags:
+
+    Options with short names (-v)
+    Options with long names (--verbose)
+    Options with and without arguments (bool v.s. other type)
+    Options with optional arguments and default values
+    Option default values from ENVIRONMENT_VARIABLES, including slice and map values
+    Multiple option groups each containing a set of options
+    Generate and print well-formatted help message
+    Passing remaining command line arguments after -- (optional)
+    Ignoring unknown command line options (optional)
+    Supports -I/usr/include -I=/usr/include -I /usr/include option argument specification
+    Supports multiple short options -aux
+    Supports all primitive go types (string, int{8..64}, uint{8..64}, float)
+    Supports same option multiple times (can store in slice or last option counts)
+    Supports maps
+    Supports function callbacks
+    Supports namespaces for (nested) option groups
+
+Additional features specific to Windows:
+    Options with short names (/v)
+    Options with long names (/verbose)
+    Windows-style options with arguments use a colon as the delimiter
+    Modify generated help message with Windows-style / options
+    Windows style options can be disabled at build time using the "forceposix"
+    build tag
+
+
+Basic usage
+
+The flags package uses structs, reflection and struct field tags
+to allow users to specify command line options. This results in very simple
+and concise specification of your application options. For example:
+
+    type Options struct {
+        Verbose []bool `short:"v" long:"verbose" description:"Show verbose debug information"`
+    }
+
+This specifies one option with a short name -v and a long name --verbose.
+When either -v or --verbose is found on the command line, a 'true' value
+will be appended to the Verbose field. e.g. when specifying -vvv, the
+resulting value of Verbose will be {[true, true, true]}.
+
+Slice options work exactly the same as primitive type options, except that
+whenever the option is encountered, a value is appended to the slice.
+
+Map options from string to primitive type are also supported. On the command
+line, you specify the value for such an option as key:value. For example
+
+    type Options struct {
+        AuthorInfo string[string] `short:"a"`
+    }
+
+Then, the AuthorInfo map can be filled with something like
+-a name:Jesse -a "surname:van den Kieboom".
+
+Finally, for full control over the conversion between command line argument
+values and options, user defined types can choose to implement the Marshaler
+and Unmarshaler interfaces.
+
+
+Available field tags
+
+The following is a list of tags for struct fields supported by go-flags:
+
+    short:            the short name of the option (single character)
+    long:             the long name of the option
+    required:         if non empty, makes the option required to appear on the command
+                      line. If a required option is not present, the parser will
+                      return ErrRequired (optional)
+    description:      the description of the option (optional)
+    long-description: the long description of the option. Currently only
+                      displayed in generated man pages (optional)
+    no-flag:          if non-empty, this field is ignored as an option (optional)
+
+    optional:       if non-empty, makes the argument of the option optional. When an
+                    argument is optional it can only be specified using
+                    --option=argument (optional)
+    optional-value: the value of an optional option when the option occurs
+                    without an argument. This tag can be specified multiple
+                    times in the case of maps or slices (optional)
+    default:        the default value of an option. This tag can be specified
+                    multiple times in the case of slices or maps (optional)
+    default-mask:   when specified, this value will be displayed in the help
+                    instead of the actual default value. This is useful
+                    mostly for hiding otherwise sensitive information from
+                    showing up in the help. If default-mask takes the special
+                    value "-", then no default value will be shown at all
+                    (optional)
+    env:            the default value of the option is overridden from the
+                    specified environment variable, if one has been defined.
+                    (optional)
+    env-delim:      the 'env' default value from environment is split into
+                    multiple values with the given delimiter string, use with
+                    slices and maps (optional)
+    value-name:     the name of the argument value (to be shown in the help)
+                    (optional)
+    choice:         limits the values for an option to a set of values.
+                    This tag can be specified multiple times (optional)
+    hidden:         if non-empty, the option is not visible in the help or man page.
+
+    base: a base (radix) used to convert strings to integer values, the
+          default base is 10 (i.e. decimal) (optional)
+
+    ini-name:       the explicit ini option name (optional)
+    no-ini:         if non-empty this field is ignored as an ini option
+                    (optional)
+
+    group:                when specified on a struct field, makes the struct
+                          field a separate group with the given name (optional)
+    namespace:            when specified on a group struct field, the namespace
+                          gets prepended to every option's long name and
+                          subgroup's namespace of this group, separated by
+                          the parser's namespace delimiter (optional)
+    command:              when specified on a struct field, makes the struct
+                          field a (sub)command with the given name (optional)
+    subcommands-optional: when specified on a command struct field, makes
+                          any subcommands of that command optional (optional)
+    alias:                when specified on a command struct field, adds the
+                          specified name as an alias for the command. Can be
+                          be specified multiple times to add more than one
+                          alias (optional)
+    positional-args:      when specified on a field with a struct type,
+                          uses the fields of that struct to parse remaining
+                          positional command line arguments into (in order
+                          of the fields). If a field has a slice type,
+                          then all remaining arguments will be added to it.
+                          Positional arguments are optional by default,
+                          unless the "required" tag is specified together
+                          with the "positional-args" tag. The "required" tag
+                          can also be set on the individual rest argument
+                          fields, to require only the first N positional
+                          arguments. If the "required" tag is set on the
+                          rest arguments slice, then its value determines
+                          the minimum amount of rest arguments that needs to
+                          be provided (e.g. `required:"2"`) (optional)
+    positional-arg-name:  used on a field in a positional argument struct; name
+                          of the positional argument placeholder to be shown in
+                          the help (optional)
+
+Either the `short:` tag or the `long:` must be specified to make the field eligible as an
+option.
+
+
+Option groups
+
+Option groups are a simple way to semantically separate your options. All
+options in a particular group are shown together in the help under the name
+of the group. Namespaces can be used to specify option long names more
+precisely and emphasize the options affiliation to their group.
+
+There are currently three ways to specify option groups.
+
+    1. Use NewNamedParser specifying the various option groups.
+    2. Use AddGroup to add a group to an existing parser.
+    3. Add a struct field to the top-level options annotated with the
+       group:"group-name" tag.
+
+
+
+Commands
+
+The flags package also has basic support for commands. Commands are often
+used in monolithic applications that support various commands or actions.
+Take git for example, all of the add, commit, checkout, etc. are called
+commands. Using commands you can easily separate multiple functions of your
+application.
+
+There are currently two ways to specify a command.
+
+    1. Use AddCommand on an existing parser.
+    2. Add a struct field to your options struct annotated with the
+       command:"command-name" tag.
+
+The most common, idiomatic way to implement commands is to define a global
+parser instance and implement each command in a separate file. These
+command files should define a go init function which calls AddCommand on
+the global parser.
+
+When parsing ends and there is an active command and that command implements
+the Commander interface, then its Execute method will be run with the
+remaining command line arguments.
+
+Command structs can have options which become valid to parse after the
+command has been specified on the command line, in addition to the options
+of all the parent commands. I.e. considering a -v flag on the parser and an
+add command, the following are equivalent:
+
+    ./app -v add
+    ./app add -v
+
+However, if the -v flag is defined on the add command, then the first of
+the two examples above would fail since the -v flag is not defined before
+the add command.
+
+
+Completion
+
+go-flags has builtin support to provide bash completion of flags, commands
+and argument values. To use completion, the binary which uses go-flags
+can be invoked in a special environment to list completion of the current
+command line argument. It should be noted that this `executes` your application,
+and it is up to the user to make sure there are no negative side effects (for
+example from init functions).
+
+Setting the environment variable `GO_FLAGS_COMPLETION=1` enables completion
+by replacing the argument parsing routine with the completion routine which
+outputs completions for the passed arguments. The basic invocation to
+complete a set of arguments is therefore:
+
+    GO_FLAGS_COMPLETION=1 ./completion-example arg1 arg2 arg3
+
+where `completion-example` is the binary, `arg1` and `arg2` are
+the current arguments, and `arg3` (the last argument) is the argument
+to be completed. If the GO_FLAGS_COMPLETION is set to "verbose", then
+descriptions of possible completion items will also be shown, if there
+are more than 1 completion items.
+
+To use this with bash completion, a simple file can be written which
+calls the binary which supports go-flags completion:
+
+    _completion_example() {
+        # All arguments except the first one
+        args=("${COMP_WORDS[@]:1:$COMP_CWORD}")
+
+        # Only split on newlines
+        local IFS=$'\n'
+
+        # Call completion (note that the first element of COMP_WORDS is
+        # the executable itself)
+        COMPREPLY=($(GO_FLAGS_COMPLETION=1 ${COMP_WORDS[0]} "${args[@]}"))
+        return 0
+    }
+
+    complete -F _completion_example completion-example
+
+Completion requires the parser option PassDoubleDash and is therefore enforced if the environment variable GO_FLAGS_COMPLETION is set.
+
+Customized completion for argument values is supported by implementing
+the flags.Completer interface for the argument value type. An example
+of a type which does so is the flags.Filename type, an alias of string
+allowing simple filename completion. A slice or array argument value
+whose element type implements flags.Completer will also be completed.
+*/
+package flags

+ 406 - 0
vendor/github.com/jessevdk/go-flags/group.go

@@ -0,0 +1,406 @@
+// Copyright 2012 Jesse van den Kieboom. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package flags
+
+import (
+	"errors"
+	"reflect"
+	"strings"
+	"unicode/utf8"
+)
+
+// ErrNotPointerToStruct indicates that a provided data container is not
+// a pointer to a struct. Only pointers to structs are valid data containers
+// for options.
+var ErrNotPointerToStruct = errors.New("provided data is not a pointer to struct")
+
+// Group represents an option group. Option groups can be used to logically
+// group options together under a description. Groups are only used to provide
+// more structure to options both for the user (as displayed in the help message)
+// and for you, since groups can be nested.
+type Group struct {
+	// A short description of the group. The
+	// short description is primarily used in the built-in generated help
+	// message
+	ShortDescription string
+
+	// A long description of the group. The long
+	// description is primarily used to present information on commands
+	// (Command embeds Group) in the built-in generated help and man pages.
+	LongDescription string
+
+	// The namespace of the group
+	Namespace string
+
+	// If true, the group is not displayed in the help or man page
+	Hidden bool
+
+	// The parent of the group or nil if it has no parent
+	parent interface{}
+
+	// All the options in the group
+	options []*Option
+
+	// All the subgroups
+	groups []*Group
+
+	// Whether the group represents the built-in help group
+	isBuiltinHelp bool
+
+	data interface{}
+}
+
+type scanHandler func(reflect.Value, *reflect.StructField) (bool, error)
+
+// AddGroup adds a new group to the command with the given name and data. The
+// data needs to be a pointer to a struct from which the fields indicate which
+// options are in the group.
+func (g *Group) AddGroup(shortDescription string, longDescription string, data interface{}) (*Group, error) {
+	group := newGroup(shortDescription, longDescription, data)
+
+	group.parent = g
+
+	if err := group.scan(); err != nil {
+		return nil, err
+	}
+
+	g.groups = append(g.groups, group)
+	return group, nil
+}
+
+// Groups returns the list of groups embedded in this group.
+func (g *Group) Groups() []*Group {
+	return g.groups
+}
+
+// Options returns the list of options in this group.
+func (g *Group) Options() []*Option {
+	return g.options
+}
+
+// Find locates the subgroup with the given short description and returns it.
+// If no such group can be found Find will return nil. Note that the description
+// is matched case insensitively.
+func (g *Group) Find(shortDescription string) *Group {
+	lshortDescription := strings.ToLower(shortDescription)
+
+	var ret *Group
+
+	g.eachGroup(func(gg *Group) {
+		if gg != g && strings.ToLower(gg.ShortDescription) == lshortDescription {
+			ret = gg
+		}
+	})
+
+	return ret
+}
+
+func (g *Group) findOption(matcher func(*Option) bool) (option *Option) {
+	g.eachGroup(func(g *Group) {
+		for _, opt := range g.options {
+			if option == nil && matcher(opt) {
+				option = opt
+			}
+		}
+	})
+
+	return option
+}
+
+// FindOptionByLongName finds an option that is part of the group, or any of its
+// subgroups, by matching its long name (including the option namespace).
+func (g *Group) FindOptionByLongName(longName string) *Option {
+	return g.findOption(func(option *Option) bool {
+		return option.LongNameWithNamespace() == longName
+	})
+}
+
+// FindOptionByShortName finds an option that is part of the group, or any of
+// its subgroups, by matching its short name.
+func (g *Group) FindOptionByShortName(shortName rune) *Option {
+	return g.findOption(func(option *Option) bool {
+		return option.ShortName == shortName
+	})
+}
+
+func newGroup(shortDescription string, longDescription string, data interface{}) *Group {
+	return &Group{
+		ShortDescription: shortDescription,
+		LongDescription:  longDescription,
+
+		data: data,
+	}
+}
+
+func (g *Group) optionByName(name string, namematch func(*Option, string) bool) *Option {
+	prio := 0
+	var retopt *Option
+
+	g.eachGroup(func(g *Group) {
+		for _, opt := range g.options {
+			if namematch != nil && namematch(opt, name) && prio < 4 {
+				retopt = opt
+				prio = 4
+			}
+
+			if name == opt.field.Name && prio < 3 {
+				retopt = opt
+				prio = 3
+			}
+
+			if name == opt.LongNameWithNamespace() && prio < 2 {
+				retopt = opt
+				prio = 2
+			}
+
+			if opt.ShortName != 0 && name == string(opt.ShortName) && prio < 1 {
+				retopt = opt
+				prio = 1
+			}
+		}
+	})
+
+	return retopt
+}
+
+func (g *Group) eachGroup(f func(*Group)) {
+	f(g)
+
+	for _, gg := range g.groups {
+		gg.eachGroup(f)
+	}
+}
+
+func isStringFalsy(s string) bool {
+	return s == "" || s == "false" || s == "no" || s == "0"
+}
+
+func (g *Group) scanStruct(realval reflect.Value, sfield *reflect.StructField, handler scanHandler) error {
+	stype := realval.Type()
+
+	if sfield != nil {
+		if ok, err := handler(realval, sfield); err != nil {
+			return err
+		} else if ok {
+			return nil
+		}
+	}
+
+	for i := 0; i < stype.NumField(); i++ {
+		field := stype.Field(i)
+
+		// PkgName is set only for non-exported fields, which we ignore
+		if field.PkgPath != "" && !field.Anonymous {
+			continue
+		}
+
+		mtag := newMultiTag(string(field.Tag))
+
+		if err := mtag.Parse(); err != nil {
+			return err
+		}
+
+		// Skip fields with the no-flag tag
+		if mtag.Get("no-flag") != "" {
+			continue
+		}
+
+		// Dive deep into structs or pointers to structs
+		kind := field.Type.Kind()
+		fld := realval.Field(i)
+
+		if kind == reflect.Struct {
+			if err := g.scanStruct(fld, &field, handler); err != nil {
+				return err
+			}
+		} else if kind == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct {
+			flagCountBefore := len(g.options) + len(g.groups)
+
+			if fld.IsNil() {
+				fld = reflect.New(fld.Type().Elem())
+			}
+
+			if err := g.scanStruct(reflect.Indirect(fld), &field, handler); err != nil {
+				return err
+			}
+
+			if len(g.options)+len(g.groups) != flagCountBefore {
+				realval.Field(i).Set(fld)
+			}
+		}
+
+		longname := mtag.Get("long")
+		shortname := mtag.Get("short")
+
+		// Need at least either a short or long name
+		if longname == "" && shortname == "" && mtag.Get("ini-name") == "" {
+			continue
+		}
+
+		short := rune(0)
+		rc := utf8.RuneCountInString(shortname)
+
+		if rc > 1 {
+			return newErrorf(ErrShortNameTooLong,
+				"short names can only be 1 character long, not `%s'",
+				shortname)
+
+		} else if rc == 1 {
+			short, _ = utf8.DecodeRuneInString(shortname)
+		}
+
+		description := mtag.Get("description")
+		def := mtag.GetMany("default")
+
+		optionalValue := mtag.GetMany("optional-value")
+		valueName := mtag.Get("value-name")
+		defaultMask := mtag.Get("default-mask")
+
+		optional := !isStringFalsy(mtag.Get("optional"))
+		required := !isStringFalsy(mtag.Get("required"))
+		choices := mtag.GetMany("choice")
+		hidden := !isStringFalsy(mtag.Get("hidden"))
+
+		option := &Option{
+			Description:      description,
+			ShortName:        short,
+			LongName:         longname,
+			Default:          def,
+			EnvDefaultKey:    mtag.Get("env"),
+			EnvDefaultDelim:  mtag.Get("env-delim"),
+			OptionalArgument: optional,
+			OptionalValue:    optionalValue,
+			Required:         required,
+			ValueName:        valueName,
+			DefaultMask:      defaultMask,
+			Choices:          choices,
+			Hidden:           hidden,
+
+			group: g,
+
+			field: field,
+			value: realval.Field(i),
+			tag:   mtag,
+		}
+
+		if option.isBool() && option.Default != nil {
+			return newErrorf(ErrInvalidTag,
+				"boolean flag `%s' may not have default values, they always default to `false' and can only be turned on",
+				option.shortAndLongName())
+		}
+
+		g.options = append(g.options, option)
+	}
+
+	return nil
+}
+
+func (g *Group) checkForDuplicateFlags() *Error {
+	shortNames := make(map[rune]*Option)
+	longNames := make(map[string]*Option)
+
+	var duplicateError *Error
+
+	g.eachGroup(func(g *Group) {
+		for _, option := range g.options {
+			if option.LongName != "" {
+				longName := option.LongNameWithNamespace()
+
+				if otherOption, ok := longNames[longName]; ok {
+					duplicateError = newErrorf(ErrDuplicatedFlag, "option `%s' uses the same long name as option `%s'", option, otherOption)
+					return
+				}
+				longNames[longName] = option
+			}
+			if option.ShortName != 0 {
+				if otherOption, ok := shortNames[option.ShortName]; ok {
+					duplicateError = newErrorf(ErrDuplicatedFlag, "option `%s' uses the same short name as option `%s'", option, otherOption)
+					return
+				}
+				shortNames[option.ShortName] = option
+			}
+		}
+	})
+
+	return duplicateError
+}
+
+func (g *Group) scanSubGroupHandler(realval reflect.Value, sfield *reflect.StructField) (bool, error) {
+	mtag := newMultiTag(string(sfield.Tag))
+
+	if err := mtag.Parse(); err != nil {
+		return true, err
+	}
+
+	subgroup := mtag.Get("group")
+
+	if len(subgroup) != 0 {
+		var ptrval reflect.Value
+
+		if realval.Kind() == reflect.Ptr {
+			ptrval = realval
+
+			if ptrval.IsNil() {
+				ptrval.Set(reflect.New(ptrval.Type()))
+			}
+		} else {
+			ptrval = realval.Addr()
+		}
+
+		description := mtag.Get("description")
+
+		group, err := g.AddGroup(subgroup, description, ptrval.Interface())
+
+		if err != nil {
+			return true, err
+		}
+
+		group.Namespace = mtag.Get("namespace")
+		group.Hidden = mtag.Get("hidden") != ""
+
+		return true, nil
+	}
+
+	return false, nil
+}
+
+func (g *Group) scanType(handler scanHandler) error {
+	// Get all the public fields in the data struct
+	ptrval := reflect.ValueOf(g.data)
+
+	if ptrval.Type().Kind() != reflect.Ptr {
+		panic(ErrNotPointerToStruct)
+	}
+
+	stype := ptrval.Type().Elem()
+
+	if stype.Kind() != reflect.Struct {
+		panic(ErrNotPointerToStruct)
+	}
+
+	realval := reflect.Indirect(ptrval)
+
+	if err := g.scanStruct(realval, nil, handler); err != nil {
+		return err
+	}
+
+	if err := g.checkForDuplicateFlags(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (g *Group) scan() error {
+	return g.scanType(g.scanSubGroupHandler)
+}
+
+func (g *Group) groupByName(name string) *Group {
+	if len(name) == 0 {
+		return g
+	}
+
+	return g.Find(name)
+}

+ 491 - 0
vendor/github.com/jessevdk/go-flags/help.go

@@ -0,0 +1,491 @@
+// Copyright 2012 Jesse van den Kieboom. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package flags
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"io"
+	"runtime"
+	"strings"
+	"unicode/utf8"
+)
+
+type alignmentInfo struct {
+	maxLongLen      int
+	hasShort        bool
+	hasValueName    bool
+	terminalColumns int
+	indent          bool
+}
+
+const (
+	paddingBeforeOption                 = 2
+	distanceBetweenOptionAndDescription = 2
+)
+
+func (a *alignmentInfo) descriptionStart() int {
+	ret := a.maxLongLen + distanceBetweenOptionAndDescription
+
+	if a.hasShort {
+		ret += 2
+	}
+
+	if a.maxLongLen > 0 {
+		ret += 4
+	}
+
+	if a.hasValueName {
+		ret += 3
+	}
+
+	return ret
+}
+
+func (a *alignmentInfo) updateLen(name string, indent bool) {
+	l := utf8.RuneCountInString(name)
+
+	if indent {
+		l = l + 4
+	}
+
+	if l > a.maxLongLen {
+		a.maxLongLen = l
+	}
+}
+
+func (p *Parser) getAlignmentInfo() alignmentInfo {
+	ret := alignmentInfo{
+		maxLongLen:      0,
+		hasShort:        false,
+		hasValueName:    false,
+		terminalColumns: getTerminalColumns(),
+	}
+
+	if ret.terminalColumns <= 0 {
+		ret.terminalColumns = 80
+	}
+
+	var prevcmd *Command
+
+	p.eachActiveGroup(func(c *Command, grp *Group) {
+		if c != prevcmd {
+			for _, arg := range c.args {
+				ret.updateLen(arg.Name, c != p.Command)
+			}
+		}
+
+		for _, info := range grp.options {
+			if !info.canCli() {
+				continue
+			}
+
+			if info.ShortName != 0 {
+				ret.hasShort = true
+			}
+
+			if len(info.ValueName) > 0 {
+				ret.hasValueName = true
+			}
+
+			l := info.LongNameWithNamespace() + info.ValueName
+
+			if len(info.Choices) != 0 {
+				l += "[" + strings.Join(info.Choices, "|") + "]"
+			}
+
+			ret.updateLen(l, c != p.Command)
+		}
+	})
+
+	return ret
+}
+
+func wrapText(s string, l int, prefix string) string {
+	var ret string
+
+	if l < 10 {
+		l = 10
+	}
+
+	// Basic text wrapping of s at spaces to fit in l
+	lines := strings.Split(s, "\n")
+
+	for _, line := range lines {
+		var retline string
+
+		line = strings.TrimSpace(line)
+
+		for len(line) > l {
+			// Try to split on space
+			suffix := ""
+
+			pos := strings.LastIndex(line[:l], " ")
+
+			if pos < 0 {
+				pos = l - 1
+				suffix = "-\n"
+			}
+
+			if len(retline) != 0 {
+				retline += "\n" + prefix
+			}
+
+			retline += strings.TrimSpace(line[:pos]) + suffix
+			line = strings.TrimSpace(line[pos:])
+		}
+
+		if len(line) > 0 {
+			if len(retline) != 0 {
+				retline += "\n" + prefix
+			}
+
+			retline += line
+		}
+
+		if len(ret) > 0 {
+			ret += "\n"
+
+			if len(retline) > 0 {
+				ret += prefix
+			}
+		}
+
+		ret += retline
+	}
+
+	return ret
+}
+
+func (p *Parser) writeHelpOption(writer *bufio.Writer, option *Option, info alignmentInfo) {
+	line := &bytes.Buffer{}
+
+	prefix := paddingBeforeOption
+
+	if info.indent {
+		prefix += 4
+	}
+
+	if option.Hidden {
+		return
+	}
+
+	line.WriteString(strings.Repeat(" ", prefix))
+
+	if option.ShortName != 0 {
+		line.WriteRune(defaultShortOptDelimiter)
+		line.WriteRune(option.ShortName)
+	} else if info.hasShort {
+		line.WriteString("  ")
+	}
+
+	descstart := info.descriptionStart() + paddingBeforeOption
+
+	if len(option.LongName) > 0 {
+		if option.ShortName != 0 {
+			line.WriteString(", ")
+		} else if info.hasShort {
+			line.WriteString("  ")
+		}
+
+		line.WriteString(defaultLongOptDelimiter)
+		line.WriteString(option.LongNameWithNamespace())
+	}
+
+	if option.canArgument() {
+		line.WriteRune(defaultNameArgDelimiter)
+
+		if len(option.ValueName) > 0 {
+			line.WriteString(option.ValueName)
+		}
+
+		if len(option.Choices) > 0 {
+			line.WriteString("[" + strings.Join(option.Choices, "|") + "]")
+		}
+	}
+
+	written := line.Len()
+	line.WriteTo(writer)
+
+	if option.Description != "" {
+		dw := descstart - written
+		writer.WriteString(strings.Repeat(" ", dw))
+
+		var def string
+
+		if len(option.DefaultMask) != 0 {
+			if option.DefaultMask != "-" {
+				def = option.DefaultMask
+			}
+		} else {
+			def = option.defaultLiteral
+		}
+
+		var envDef string
+		if option.EnvDefaultKey != "" {
+			var envPrintable string
+			if runtime.GOOS == "windows" {
+				envPrintable = "%" + option.EnvDefaultKey + "%"
+			} else {
+				envPrintable = "$" + option.EnvDefaultKey
+			}
+			envDef = fmt.Sprintf(" [%s]", envPrintable)
+		}
+
+		var desc string
+
+		if def != "" {
+			desc = fmt.Sprintf("%s (default: %v)%s", option.Description, def, envDef)
+		} else {
+			desc = option.Description + envDef
+		}
+
+		writer.WriteString(wrapText(desc,
+			info.terminalColumns-descstart,
+			strings.Repeat(" ", descstart)))
+	}
+
+	writer.WriteString("\n")
+}
+
+func maxCommandLength(s []*Command) int {
+	if len(s) == 0 {
+		return 0
+	}
+
+	ret := len(s[0].Name)
+
+	for _, v := range s[1:] {
+		l := len(v.Name)
+
+		if l > ret {
+			ret = l
+		}
+	}
+
+	return ret
+}
+
+// WriteHelp writes a help message containing all the possible options and
+// their descriptions to the provided writer. Note that the HelpFlag parser
+// option provides a convenient way to add a -h/--help option group to the
+// command line parser which will automatically show the help messages using
+// this method.
+func (p *Parser) WriteHelp(writer io.Writer) {
+	if writer == nil {
+		return
+	}
+
+	wr := bufio.NewWriter(writer)
+	aligninfo := p.getAlignmentInfo()
+
+	cmd := p.Command
+
+	for cmd.Active != nil {
+		cmd = cmd.Active
+	}
+
+	if p.Name != "" {
+		wr.WriteString("Usage:\n")
+		wr.WriteString(" ")
+
+		allcmd := p.Command
+
+		for allcmd != nil {
+			var usage string
+
+			if allcmd == p.Command {
+				if len(p.Usage) != 0 {
+					usage = p.Usage
+				} else if p.Options&HelpFlag != 0 {
+					usage = "[OPTIONS]"
+				}
+			} else if us, ok := allcmd.data.(Usage); ok {
+				usage = us.Usage()
+			} else if allcmd.hasCliOptions() {
+				usage = fmt.Sprintf("[%s-OPTIONS]", allcmd.Name)
+			}
+
+			if len(usage) != 0 {
+				fmt.Fprintf(wr, " %s %s", allcmd.Name, usage)
+			} else {
+				fmt.Fprintf(wr, " %s", allcmd.Name)
+			}
+
+			if len(allcmd.args) > 0 {
+				fmt.Fprintf(wr, " ")
+			}
+
+			for i, arg := range allcmd.args {
+				if i != 0 {
+					fmt.Fprintf(wr, " ")
+				}
+
+				name := arg.Name
+
+				if arg.isRemaining() {
+					name = name + "..."
+				}
+
+				if !allcmd.ArgsRequired {
+					fmt.Fprintf(wr, "[%s]", name)
+				} else {
+					fmt.Fprintf(wr, "%s", name)
+				}
+			}
+
+			if allcmd.Active == nil && len(allcmd.commands) > 0 {
+				var co, cc string
+
+				if allcmd.SubcommandsOptional {
+					co, cc = "[", "]"
+				} else {
+					co, cc = "<", ">"
+				}
+
+				visibleCommands := allcmd.visibleCommands()
+
+				if len(visibleCommands) > 3 {
+					fmt.Fprintf(wr, " %scommand%s", co, cc)
+				} else {
+					subcommands := allcmd.sortedVisibleCommands()
+					names := make([]string, len(subcommands))
+
+					for i, subc := range subcommands {
+						names[i] = subc.Name
+					}
+
+					fmt.Fprintf(wr, " %s%s%s", co, strings.Join(names, " | "), cc)
+				}
+			}
+
+			allcmd = allcmd.Active
+		}
+
+		fmt.Fprintln(wr)
+
+		if len(cmd.LongDescription) != 0 {
+			fmt.Fprintln(wr)
+
+			t := wrapText(cmd.LongDescription,
+				aligninfo.terminalColumns,
+				"")
+
+			fmt.Fprintln(wr, t)
+		}
+	}
+
+	c := p.Command
+
+	for c != nil {
+		printcmd := c != p.Command
+
+		c.eachGroup(func(grp *Group) {
+			first := true
+
+			// Skip built-in help group for all commands except the top-level
+			// parser
+			if grp.Hidden || (grp.isBuiltinHelp && c != p.Command) {
+				return
+			}
+
+			for _, info := range grp.options {
+				if !info.canCli() || info.Hidden {
+					continue
+				}
+
+				if printcmd {
+					fmt.Fprintf(wr, "\n[%s command options]\n", c.Name)
+					aligninfo.indent = true
+					printcmd = false
+				}
+
+				if first && cmd.Group != grp {
+					fmt.Fprintln(wr)
+
+					if aligninfo.indent {
+						wr.WriteString("    ")
+					}
+
+					fmt.Fprintf(wr, "%s:\n", grp.ShortDescription)
+					first = false
+				}
+
+				p.writeHelpOption(wr, info, aligninfo)
+			}
+		})
+
+		var args []*Arg
+		for _, arg := range c.args {
+			if arg.Description != "" {
+				args = append(args, arg)
+			}
+		}
+
+		if len(args) > 0 {
+			if c == p.Command {
+				fmt.Fprintf(wr, "\nArguments:\n")
+			} else {
+				fmt.Fprintf(wr, "\n[%s command arguments]\n", c.Name)
+			}
+
+			descStart := aligninfo.descriptionStart() + paddingBeforeOption
+
+			for _, arg := range args {
+				argPrefix := strings.Repeat(" ", paddingBeforeOption)
+				argPrefix += arg.Name
+
+				if len(arg.Description) > 0 {
+					argPrefix += ":"
+					wr.WriteString(argPrefix)
+
+					// Space between "arg:" and the description start
+					descPadding := strings.Repeat(" ", descStart-len(argPrefix))
+					// How much space the description gets before wrapping
+					descWidth := aligninfo.terminalColumns - 1 - descStart
+					// Whitespace to which we can indent new description lines
+					descPrefix := strings.Repeat(" ", descStart)
+
+					wr.WriteString(descPadding)
+					wr.WriteString(wrapText(arg.Description, descWidth, descPrefix))
+				} else {
+					wr.WriteString(argPrefix)
+				}
+
+				fmt.Fprintln(wr)
+			}
+		}
+
+		c = c.Active
+	}
+
+	scommands := cmd.sortedVisibleCommands()
+
+	if len(scommands) > 0 {
+		maxnamelen := maxCommandLength(scommands)
+
+		fmt.Fprintln(wr)
+		fmt.Fprintln(wr, "Available commands:")
+
+		for _, c := range scommands {
+			fmt.Fprintf(wr, "  %s", c.Name)
+
+			if len(c.ShortDescription) > 0 {
+				pad := strings.Repeat(" ", maxnamelen-len(c.Name))
+				fmt.Fprintf(wr, "%s  %s", pad, c.ShortDescription)
+
+				if len(c.Aliases) > 0 {
+					fmt.Fprintf(wr, " (aliases: %s)", strings.Join(c.Aliases, ", "))
+				}
+
+			}
+
+			fmt.Fprintln(wr)
+		}
+	}
+
+	wr.Flush()
+}

+ 597 - 0
vendor/github.com/jessevdk/go-flags/ini.go

@@ -0,0 +1,597 @@
+package flags
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"os"
+	"reflect"
+	"sort"
+	"strconv"
+	"strings"
+)
+
+// IniError contains location information on where an error occurred.
+type IniError struct {
+	// The error message.
+	Message string
+
+	// The filename of the file in which the error occurred.
+	File string
+
+	// The line number at which the error occurred.
+	LineNumber uint
+}
+
+// Error provides a "file:line: message" formatted message of the ini error.
+func (x *IniError) Error() string {
+	return fmt.Sprintf(
+		"%s:%d: %s",
+		x.File,
+		x.LineNumber,
+		x.Message,
+	)
+}
+
+// IniOptions for writing
+type IniOptions uint
+
+const (
+	// IniNone indicates no options.
+	IniNone IniOptions = 0
+
+	// IniIncludeDefaults indicates that default values should be written.
+	IniIncludeDefaults = 1 << iota
+
+	// IniCommentDefaults indicates that if IniIncludeDefaults is used
+	// options with default values are written but commented out.
+	IniCommentDefaults
+
+	// IniIncludeComments indicates that comments containing the description
+	// of an option should be written.
+	IniIncludeComments
+
+	// IniDefault provides a default set of options.
+	IniDefault = IniIncludeComments
+)
+
+// IniParser is a utility to read and write flags options from and to ini
+// formatted strings.
+type IniParser struct {
+	ParseAsDefaults bool // override default flags
+
+	parser *Parser
+}
+
+type iniValue struct {
+	Name       string
+	Value      string
+	Quoted     bool
+	LineNumber uint
+}
+
+type iniSection []iniValue
+
+type ini struct {
+	File     string
+	Sections map[string]iniSection
+}
+
+// NewIniParser creates a new ini parser for a given Parser.
+func NewIniParser(p *Parser) *IniParser {
+	return &IniParser{
+		parser: p,
+	}
+}
+
+// IniParse is a convenience function to parse command line options with default
+// settings from an ini formatted file. The provided data is a pointer to a struct
+// representing the default option group (named "Application Options"). For
+// more control, use flags.NewParser.
+func IniParse(filename string, data interface{}) error {
+	p := NewParser(data, Default)
+
+	return NewIniParser(p).ParseFile(filename)
+}
+
+// ParseFile parses flags from an ini formatted file. See Parse for more
+// information on the ini file format. The returned errors can be of the type
+// flags.Error or flags.IniError.
+func (i *IniParser) ParseFile(filename string) error {
+	ini, err := readIniFromFile(filename)
+
+	if err != nil {
+		return err
+	}
+
+	return i.parse(ini)
+}
+
+// Parse parses flags from an ini format. You can use ParseFile as a
+// convenience function to parse from a filename instead of a general
+// io.Reader.
+//
+// The format of the ini file is as follows:
+//
+//     [Option group name]
+//     option = value
+//
+// Each section in the ini file represents an option group or command in the
+// flags parser. The default flags parser option group (i.e. when using
+// flags.Parse) is named 'Application Options'. The ini option name is matched
+// in the following order:
+//
+//     1. Compared to the ini-name tag on the option struct field (if present)
+//     2. Compared to the struct field name
+//     3. Compared to the option long name (if present)
+//     4. Compared to the option short name (if present)
+//
+// Sections for nested groups and commands can be addressed using a dot `.'
+// namespacing notation (i.e [subcommand.Options]). Group section names are
+// matched case insensitive.
+//
+// The returned errors can be of the type flags.Error or flags.IniError.
+func (i *IniParser) Parse(reader io.Reader) error {
+	ini, err := readIni(reader, "")
+
+	if err != nil {
+		return err
+	}
+
+	return i.parse(ini)
+}
+
+// WriteFile writes the flags as ini format into a file. See Write
+// for more information. The returned error occurs when the specified file
+// could not be opened for writing.
+func (i *IniParser) WriteFile(filename string, options IniOptions) error {
+	return writeIniToFile(i, filename, options)
+}
+
+// Write writes the current values of all the flags to an ini format.
+// See Parse for more information on the ini file format. You typically
+// call this only after settings have been parsed since the default values of each
+// option are stored just before parsing the flags (this is only relevant when
+// IniIncludeDefaults is _not_ set in options).
+func (i *IniParser) Write(writer io.Writer, options IniOptions) {
+	writeIni(i, writer, options)
+}
+
+func readFullLine(reader *bufio.Reader) (string, error) {
+	var line []byte
+
+	for {
+		l, more, err := reader.ReadLine()
+
+		if err != nil {
+			return "", err
+		}
+
+		if line == nil && !more {
+			return string(l), nil
+		}
+
+		line = append(line, l...)
+
+		if !more {
+			break
+		}
+	}
+
+	return string(line), nil
+}
+
+func optionIniName(option *Option) string {
+	name := option.tag.Get("_read-ini-name")
+
+	if len(name) != 0 {
+		return name
+	}
+
+	name = option.tag.Get("ini-name")
+
+	if len(name) != 0 {
+		return name
+	}
+
+	return option.field.Name
+}
+
+func writeGroupIni(cmd *Command, group *Group, namespace string, writer io.Writer, options IniOptions) {
+	var sname string
+
+	if len(namespace) != 0 {
+		sname = namespace
+	}
+
+	if cmd.Group != group && len(group.ShortDescription) != 0 {
+		if len(sname) != 0 {
+			sname += "."
+		}
+
+		sname += group.ShortDescription
+	}
+
+	sectionwritten := false
+	comments := (options & IniIncludeComments) != IniNone
+
+	for _, option := range group.options {
+		if option.isFunc() || option.Hidden {
+			continue
+		}
+
+		if len(option.tag.Get("no-ini")) != 0 {
+			continue
+		}
+
+		val := option.value
+
+		if (options&IniIncludeDefaults) == IniNone && option.valueIsDefault() {
+			continue
+		}
+
+		if !sectionwritten {
+			fmt.Fprintf(writer, "[%s]\n", sname)
+			sectionwritten = true
+		}
+
+		if comments && len(option.Description) != 0 {
+			fmt.Fprintf(writer, "; %s\n", option.Description)
+		}
+
+		oname := optionIniName(option)
+
+		commentOption := (options&(IniIncludeDefaults|IniCommentDefaults)) == IniIncludeDefaults|IniCommentDefaults && option.valueIsDefault()
+
+		kind := val.Type().Kind()
+		switch kind {
+		case reflect.Slice:
+			kind = val.Type().Elem().Kind()
+
+			if val.Len() == 0 {
+				writeOption(writer, oname, kind, "", "", true, option.iniQuote)
+			} else {
+				for idx := 0; idx < val.Len(); idx++ {
+					v, _ := convertToString(val.Index(idx), option.tag)
+
+					writeOption(writer, oname, kind, "", v, commentOption, option.iniQuote)
+				}
+			}
+		case reflect.Map:
+			kind = val.Type().Elem().Kind()
+
+			if val.Len() == 0 {
+				writeOption(writer, oname, kind, "", "", true, option.iniQuote)
+			} else {
+				mkeys := val.MapKeys()
+				keys := make([]string, len(val.MapKeys()))
+				kkmap := make(map[string]reflect.Value)
+
+				for i, k := range mkeys {
+					keys[i], _ = convertToString(k, option.tag)
+					kkmap[keys[i]] = k
+				}
+
+				sort.Strings(keys)
+
+				for _, k := range keys {
+					v, _ := convertToString(val.MapIndex(kkmap[k]), option.tag)
+
+					writeOption(writer, oname, kind, k, v, commentOption, option.iniQuote)
+				}
+			}
+		default:
+			v, _ := convertToString(val, option.tag)
+
+			writeOption(writer, oname, kind, "", v, commentOption, option.iniQuote)
+		}
+
+		if comments {
+			fmt.Fprintln(writer)
+		}
+	}
+
+	if sectionwritten && !comments {
+		fmt.Fprintln(writer)
+	}
+}
+
+func writeOption(writer io.Writer, optionName string, optionType reflect.Kind, optionKey string, optionValue string, commentOption bool, forceQuote bool) {
+	if forceQuote || (optionType == reflect.String && !isPrint(optionValue)) {
+		optionValue = strconv.Quote(optionValue)
+	}
+
+	comment := ""
+	if commentOption {
+		comment = "; "
+	}
+
+	fmt.Fprintf(writer, "%s%s =", comment, optionName)
+
+	if optionKey != "" {
+		fmt.Fprintf(writer, " %s:%s", optionKey, optionValue)
+	} else if optionValue != "" {
+		fmt.Fprintf(writer, " %s", optionValue)
+	}
+
+	fmt.Fprintln(writer)
+}
+
+func writeCommandIni(command *Command, namespace string, writer io.Writer, options IniOptions) {
+	command.eachGroup(func(group *Group) {
+		if !group.Hidden {
+			writeGroupIni(command, group, namespace, writer, options)
+		}
+	})
+
+	for _, c := range command.commands {
+		var nns string
+
+		if c.Hidden {
+			continue
+		}
+
+		if len(namespace) != 0 {
+			nns = c.Name + "." + nns
+		} else {
+			nns = c.Name
+		}
+
+		writeCommandIni(c, nns, writer, options)
+	}
+}
+
+func writeIni(parser *IniParser, writer io.Writer, options IniOptions) {
+	writeCommandIni(parser.parser.Command, "", writer, options)
+}
+
+func writeIniToFile(parser *IniParser, filename string, options IniOptions) error {
+	file, err := os.Create(filename)
+
+	if err != nil {
+		return err
+	}
+
+	defer file.Close()
+
+	writeIni(parser, file, options)
+
+	return nil
+}
+
+func readIniFromFile(filename string) (*ini, error) {
+	file, err := os.Open(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	defer file.Close()
+
+	return readIni(file, filename)
+}
+
+func readIni(contents io.Reader, filename string) (*ini, error) {
+	ret := &ini{
+		File:     filename,
+		Sections: make(map[string]iniSection),
+	}
+
+	reader := bufio.NewReader(contents)
+
+	// Empty global section
+	section := make(iniSection, 0, 10)
+	sectionname := ""
+
+	ret.Sections[sectionname] = section
+
+	var lineno uint
+
+	for {
+		line, err := readFullLine(reader)
+
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			return nil, err
+		}
+
+		lineno++
+		line = strings.TrimSpace(line)
+
+		// Skip empty lines and lines starting with ; (comments)
+		if len(line) == 0 || line[0] == ';' || line[0] == '#' {
+			continue
+		}
+
+		if line[0] == '[' {
+			if line[0] != '[' || line[len(line)-1] != ']' {
+				return nil, &IniError{
+					Message:    "malformed section header",
+					File:       filename,
+					LineNumber: lineno,
+				}
+			}
+
+			name := strings.TrimSpace(line[1 : len(line)-1])
+
+			if len(name) == 0 {
+				return nil, &IniError{
+					Message:    "empty section name",
+					File:       filename,
+					LineNumber: lineno,
+				}
+			}
+
+			sectionname = name
+			section = ret.Sections[name]
+
+			if section == nil {
+				section = make(iniSection, 0, 10)
+				ret.Sections[name] = section
+			}
+
+			continue
+		}
+
+		// Parse option here
+		keyval := strings.SplitN(line, "=", 2)
+
+		if len(keyval) != 2 {
+			return nil, &IniError{
+				Message:    fmt.Sprintf("malformed key=value (%s)", line),
+				File:       filename,
+				LineNumber: lineno,
+			}
+		}
+
+		name := strings.TrimSpace(keyval[0])
+		value := strings.TrimSpace(keyval[1])
+		quoted := false
+
+		if len(value) != 0 && value[0] == '"' {
+			if v, err := strconv.Unquote(value); err == nil {
+				value = v
+
+				quoted = true
+			} else {
+				return nil, &IniError{
+					Message:    err.Error(),
+					File:       filename,
+					LineNumber: lineno,
+				}
+			}
+		}
+
+		section = append(section, iniValue{
+			Name:       name,
+			Value:      value,
+			Quoted:     quoted,
+			LineNumber: lineno,
+		})
+
+		ret.Sections[sectionname] = section
+	}
+
+	return ret, nil
+}
+
+func (i *IniParser) matchingGroups(name string) []*Group {
+	if len(name) == 0 {
+		var ret []*Group
+
+		i.parser.eachGroup(func(g *Group) {
+			ret = append(ret, g)
+		})
+
+		return ret
+	}
+
+	g := i.parser.groupByName(name)
+
+	if g != nil {
+		return []*Group{g}
+	}
+
+	return nil
+}
+
+func (i *IniParser) parse(ini *ini) error {
+	p := i.parser
+
+	var quotesLookup = make(map[*Option]bool)
+
+	for name, section := range ini.Sections {
+		groups := i.matchingGroups(name)
+
+		if len(groups) == 0 {
+			return newErrorf(ErrUnknownGroup, "could not find option group `%s'", name)
+		}
+
+		for _, inival := range section {
+			var opt *Option
+
+			for _, group := range groups {
+				opt = group.optionByName(inival.Name, func(o *Option, n string) bool {
+					return strings.ToLower(o.tag.Get("ini-name")) == strings.ToLower(n)
+				})
+
+				if opt != nil && len(opt.tag.Get("no-ini")) != 0 {
+					opt = nil
+				}
+
+				if opt != nil {
+					break
+				}
+			}
+
+			if opt == nil {
+				if (p.Options & IgnoreUnknown) == None {
+					return &IniError{
+						Message:    fmt.Sprintf("unknown option: %s", inival.Name),
+						File:       ini.File,
+						LineNumber: inival.LineNumber,
+					}
+				}
+
+				continue
+			}
+
+			// ini value is ignored if override is set and
+			// value was previously set from non default
+			if i.ParseAsDefaults && !opt.isSetDefault {
+				continue
+			}
+
+			pval := &inival.Value
+
+			if !opt.canArgument() && len(inival.Value) == 0 {
+				pval = nil
+			} else {
+				if opt.value.Type().Kind() == reflect.Map {
+					parts := strings.SplitN(inival.Value, ":", 2)
+
+					// only handle unquoting
+					if len(parts) == 2 && parts[1][0] == '"' {
+						if v, err := strconv.Unquote(parts[1]); err == nil {
+							parts[1] = v
+
+							inival.Quoted = true
+						} else {
+							return &IniError{
+								Message:    err.Error(),
+								File:       ini.File,
+								LineNumber: inival.LineNumber,
+							}
+						}
+
+						s := parts[0] + ":" + parts[1]
+
+						pval = &s
+					}
+				}
+			}
+
+			if err := opt.set(pval); err != nil {
+				return &IniError{
+					Message:    err.Error(),
+					File:       ini.File,
+					LineNumber: inival.LineNumber,
+				}
+			}
+
+			// either all INI values are quoted or only values who need quoting
+			if _, ok := quotesLookup[opt]; !inival.Quoted || !ok {
+				quotesLookup[opt] = inival.Quoted
+			}
+
+			opt.tag.Set("_read-ini-name", inival.Name)
+		}
+	}
+
+	for opt, quoted := range quotesLookup {
+		opt.iniQuote = quoted
+	}
+
+	return nil
+}

+ 205 - 0
vendor/github.com/jessevdk/go-flags/man.go

@@ -0,0 +1,205 @@
+package flags
+
+import (
+	"fmt"
+	"io"
+	"runtime"
+	"strings"
+	"time"
+)
+
+func manQuote(s string) string {
+	return strings.Replace(s, "\\", "\\\\", -1)
+}
+
+func formatForMan(wr io.Writer, s string) {
+	for {
+		idx := strings.IndexRune(s, '`')
+
+		if idx < 0 {
+			fmt.Fprintf(wr, "%s", manQuote(s))
+			break
+		}
+
+		fmt.Fprintf(wr, "%s", manQuote(s[:idx]))
+
+		s = s[idx+1:]
+		idx = strings.IndexRune(s, '\'')
+
+		if idx < 0 {
+			fmt.Fprintf(wr, "%s", manQuote(s))
+			break
+		}
+
+		fmt.Fprintf(wr, "\\fB%s\\fP", manQuote(s[:idx]))
+		s = s[idx+1:]
+	}
+}
+
+func writeManPageOptions(wr io.Writer, grp *Group) {
+	grp.eachGroup(func(group *Group) {
+		if group.Hidden || len(group.options) == 0 {
+			return
+		}
+
+		// If the parent (grp) has any subgroups, display their descriptions as
+		// subsection headers similar to the output of --help.
+		if group.ShortDescription != "" && len(grp.groups) > 0 {
+			fmt.Fprintf(wr, ".SS %s\n", group.ShortDescription)
+
+			if group.LongDescription != "" {
+				formatForMan(wr, group.LongDescription)
+				fmt.Fprintln(wr, "")
+			}
+		}
+
+		for _, opt := range group.options {
+			if !opt.canCli() || opt.Hidden {
+				continue
+			}
+
+			fmt.Fprintln(wr, ".TP")
+			fmt.Fprintf(wr, "\\fB")
+
+			if opt.ShortName != 0 {
+				fmt.Fprintf(wr, "\\fB\\-%c\\fR", opt.ShortName)
+			}
+
+			if len(opt.LongName) != 0 {
+				if opt.ShortName != 0 {
+					fmt.Fprintf(wr, ", ")
+				}
+
+				fmt.Fprintf(wr, "\\fB\\-\\-%s\\fR", manQuote(opt.LongNameWithNamespace()))
+			}
+
+			if len(opt.ValueName) != 0 || opt.OptionalArgument {
+				if opt.OptionalArgument {
+					fmt.Fprintf(wr, " [\\fI%s=%s\\fR]", manQuote(opt.ValueName), manQuote(strings.Join(quoteV(opt.OptionalValue), ", ")))
+				} else {
+					fmt.Fprintf(wr, " \\fI%s\\fR", manQuote(opt.ValueName))
+				}
+			}
+
+			if len(opt.Default) != 0 {
+				fmt.Fprintf(wr, " <default: \\fI%s\\fR>", manQuote(strings.Join(quoteV(opt.Default), ", ")))
+			} else if len(opt.EnvDefaultKey) != 0 {
+				if runtime.GOOS == "windows" {
+					fmt.Fprintf(wr, " <default: \\fI%%%s%%\\fR>", manQuote(opt.EnvDefaultKey))
+				} else {
+					fmt.Fprintf(wr, " <default: \\fI$%s\\fR>", manQuote(opt.EnvDefaultKey))
+				}
+			}
+
+			if opt.Required {
+				fmt.Fprintf(wr, " (\\fIrequired\\fR)")
+			}
+
+			fmt.Fprintln(wr, "\\fP")
+
+			if len(opt.Description) != 0 {
+				formatForMan(wr, opt.Description)
+				fmt.Fprintln(wr, "")
+			}
+		}
+	})
+}
+
+func writeManPageSubcommands(wr io.Writer, name string, root *Command) {
+	commands := root.sortedVisibleCommands()
+
+	for _, c := range commands {
+		var nn string
+
+		if c.Hidden {
+			continue
+		}
+
+		if len(name) != 0 {
+			nn = name + " " + c.Name
+		} else {
+			nn = c.Name
+		}
+
+		writeManPageCommand(wr, nn, root, c)
+	}
+}
+
+func writeManPageCommand(wr io.Writer, name string, root *Command, command *Command) {
+	fmt.Fprintf(wr, ".SS %s\n", name)
+	fmt.Fprintln(wr, command.ShortDescription)
+
+	if len(command.LongDescription) > 0 {
+		fmt.Fprintln(wr, "")
+
+		cmdstart := fmt.Sprintf("The %s command", manQuote(command.Name))
+
+		if strings.HasPrefix(command.LongDescription, cmdstart) {
+			fmt.Fprintf(wr, "The \\fI%s\\fP command", manQuote(command.Name))
+
+			formatForMan(wr, command.LongDescription[len(cmdstart):])
+			fmt.Fprintln(wr, "")
+		} else {
+			formatForMan(wr, command.LongDescription)
+			fmt.Fprintln(wr, "")
+		}
+	}
+
+	var usage string
+	if us, ok := command.data.(Usage); ok {
+		usage = us.Usage()
+	} else if command.hasCliOptions() {
+		usage = fmt.Sprintf("[%s-OPTIONS]", command.Name)
+	}
+
+	var pre string
+	if root.hasCliOptions() {
+		pre = fmt.Sprintf("%s [OPTIONS] %s", root.Name, command.Name)
+	} else {
+		pre = fmt.Sprintf("%s %s", root.Name, command.Name)
+	}
+
+	if len(usage) > 0 {
+		fmt.Fprintf(wr, "\n\\fBUsage\\fP: %s %s\n.TP\n", manQuote(pre), manQuote(usage))
+	}
+
+	if len(command.Aliases) > 0 {
+		fmt.Fprintf(wr, "\n\\fBAliases\\fP: %s\n\n", manQuote(strings.Join(command.Aliases, ", ")))
+	}
+
+	writeManPageOptions(wr, command.Group)
+	writeManPageSubcommands(wr, name, command)
+}
+
+// WriteManPage writes a basic man page in groff format to the specified
+// writer.
+func (p *Parser) WriteManPage(wr io.Writer) {
+	t := time.Now()
+
+	fmt.Fprintf(wr, ".TH %s 1 \"%s\"\n", manQuote(p.Name), t.Format("2 January 2006"))
+	fmt.Fprintln(wr, ".SH NAME")
+	fmt.Fprintf(wr, "%s \\- %s\n", manQuote(p.Name), manQuote(p.ShortDescription))
+	fmt.Fprintln(wr, ".SH SYNOPSIS")
+
+	usage := p.Usage
+
+	if len(usage) == 0 {
+		usage = "[OPTIONS]"
+	}
+
+	fmt.Fprintf(wr, "\\fB%s\\fP %s\n", manQuote(p.Name), manQuote(usage))
+	fmt.Fprintln(wr, ".SH DESCRIPTION")
+
+	formatForMan(wr, p.LongDescription)
+	fmt.Fprintln(wr, "")
+
+	fmt.Fprintln(wr, ".SH OPTIONS")
+
+	writeManPageOptions(wr, p.Command.Group)
+
+	if len(p.visibleCommands()) > 0 {
+		fmt.Fprintln(wr, ".SH COMMANDS")
+
+		writeManPageSubcommands(wr, "", p.Command)
+	}
+}

+ 140 - 0
vendor/github.com/jessevdk/go-flags/multitag.go

@@ -0,0 +1,140 @@
+package flags
+
+import (
+	"strconv"
+)
+
+type multiTag struct {
+	value string
+	cache map[string][]string
+}
+
+func newMultiTag(v string) multiTag {
+	return multiTag{
+		value: v,
+	}
+}
+
+func (x *multiTag) scan() (map[string][]string, error) {
+	v := x.value
+
+	ret := make(map[string][]string)
+
+	// This is mostly copied from reflect.StructTag.Get
+	for v != "" {
+		i := 0
+
+		// Skip whitespace
+		for i < len(v) && v[i] == ' ' {
+			i++
+		}
+
+		v = v[i:]
+
+		if v == "" {
+			break
+		}
+
+		// Scan to colon to find key
+		i = 0
+
+		for i < len(v) && v[i] != ' ' && v[i] != ':' && v[i] != '"' {
+			i++
+		}
+
+		if i >= len(v) {
+			return nil, newErrorf(ErrTag, "expected `:' after key name, but got end of tag (in `%v`)", x.value)
+		}
+
+		if v[i] != ':' {
+			return nil, newErrorf(ErrTag, "expected `:' after key name, but got `%v' (in `%v`)", v[i], x.value)
+		}
+
+		if i+1 >= len(v) {
+			return nil, newErrorf(ErrTag, "expected `\"' to start tag value at end of tag (in `%v`)", x.value)
+		}
+
+		if v[i+1] != '"' {
+			return nil, newErrorf(ErrTag, "expected `\"' to start tag value, but got `%v' (in `%v`)", v[i+1], x.value)
+		}
+
+		name := v[:i]
+		v = v[i+1:]
+
+		// Scan quoted string to find value
+		i = 1
+
+		for i < len(v) && v[i] != '"' {
+			if v[i] == '\n' {
+				return nil, newErrorf(ErrTag, "unexpected newline in tag value `%v' (in `%v`)", name, x.value)
+			}
+
+			if v[i] == '\\' {
+				i++
+			}
+			i++
+		}
+
+		if i >= len(v) {
+			return nil, newErrorf(ErrTag, "expected end of tag value `\"' at end of tag (in `%v`)", x.value)
+		}
+
+		val, err := strconv.Unquote(v[:i+1])
+
+		if err != nil {
+			return nil, newErrorf(ErrTag, "Malformed value of tag `%v:%v` => %v (in `%v`)", name, v[:i+1], err, x.value)
+		}
+
+		v = v[i+1:]
+
+		ret[name] = append(ret[name], val)
+	}
+
+	return ret, nil
+}
+
+func (x *multiTag) Parse() error {
+	vals, err := x.scan()
+	x.cache = vals
+
+	return err
+}
+
+func (x *multiTag) cached() map[string][]string {
+	if x.cache == nil {
+		cache, _ := x.scan()
+
+		if cache == nil {
+			cache = make(map[string][]string)
+		}
+
+		x.cache = cache
+	}
+
+	return x.cache
+}
+
+func (x *multiTag) Get(key string) string {
+	c := x.cached()
+
+	if v, ok := c[key]; ok {
+		return v[len(v)-1]
+	}
+
+	return ""
+}
+
+func (x *multiTag) GetMany(key string) []string {
+	c := x.cached()
+	return c[key]
+}
+
+func (x *multiTag) Set(key string, value string) {
+	c := x.cached()
+	c[key] = []string{value}
+}
+
+func (x *multiTag) SetMany(key string, value []string) {
+	c := x.cached()
+	c[key] = value
+}

+ 459 - 0
vendor/github.com/jessevdk/go-flags/option.go

@@ -0,0 +1,459 @@
+package flags
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"reflect"
+	"strings"
+	"unicode/utf8"
+)
+
+// Option flag information. Contains a description of the option, short and
+// long name as well as a default value and whether an argument for this
+// flag is optional.
+type Option struct {
+	// The description of the option flag. This description is shown
+	// automatically in the built-in help.
+	Description string
+
+	// The short name of the option (a single character). If not 0, the
+	// option flag can be 'activated' using -<ShortName>. Either ShortName
+	// or LongName needs to be non-empty.
+	ShortName rune
+
+	// The long name of the option. If not "", the option flag can be
+	// activated using --<LongName>. Either ShortName or LongName needs
+	// to be non-empty.
+	LongName string
+
+	// The default value of the option.
+	Default []string
+
+	// The optional environment default value key name.
+	EnvDefaultKey string
+
+	// The optional delimiter string for EnvDefaultKey values.
+	EnvDefaultDelim string
+
+	// If true, specifies that the argument to an option flag is optional.
+	// When no argument to the flag is specified on the command line, the
+	// value of OptionalValue will be set in the field this option represents.
+	// This is only valid for non-boolean options.
+	OptionalArgument bool
+
+	// The optional value of the option. The optional value is used when
+	// the option flag is marked as having an OptionalArgument. This means
+	// that when the flag is specified, but no option argument is given,
+	// the value of the field this option represents will be set to
+	// OptionalValue. This is only valid for non-boolean options.
+	OptionalValue []string
+
+	// If true, the option _must_ be specified on the command line. If the
+	// option is not specified, the parser will generate an ErrRequired type
+	// error.
+	Required bool
+
+	// A name for the value of an option shown in the Help as --flag [ValueName]
+	ValueName string
+
+	// A mask value to show in the help instead of the default value. This
+	// is useful for hiding sensitive information in the help, such as
+	// passwords.
+	DefaultMask string
+
+	// If non empty, only a certain set of values is allowed for an option.
+	Choices []string
+
+	// If true, the option is not displayed in the help or man page
+	Hidden bool
+
+	// The group which the option belongs to
+	group *Group
+
+	// The struct field which the option represents.
+	field reflect.StructField
+
+	// The struct field value which the option represents.
+	value reflect.Value
+
+	// Determines if the option will be always quoted in the INI output
+	iniQuote bool
+
+	tag            multiTag
+	isSet          bool
+	isSetDefault   bool
+	preventDefault bool
+
+	defaultLiteral string
+}
+
+// LongNameWithNamespace returns the option's long name with the group namespaces
+// prepended by walking up the option's group tree. Namespaces and the long name
+// itself are separated by the parser's namespace delimiter. If the long name is
+// empty an empty string is returned.
+func (option *Option) LongNameWithNamespace() string {
+	if len(option.LongName) == 0 {
+		return ""
+	}
+
+	// fetch the namespace delimiter from the parser which is always at the
+	// end of the group hierarchy
+	namespaceDelimiter := ""
+	g := option.group
+
+	for {
+		if p, ok := g.parent.(*Parser); ok {
+			namespaceDelimiter = p.NamespaceDelimiter
+
+			break
+		}
+
+		switch i := g.parent.(type) {
+		case *Command:
+			g = i.Group
+		case *Group:
+			g = i
+		}
+	}
+
+	// concatenate long name with namespace
+	longName := option.LongName
+	g = option.group
+
+	for g != nil {
+		if g.Namespace != "" {
+			longName = g.Namespace + namespaceDelimiter + longName
+		}
+
+		switch i := g.parent.(type) {
+		case *Command:
+			g = i.Group
+		case *Group:
+			g = i
+		case *Parser:
+			g = nil
+		}
+	}
+
+	return longName
+}
+
+// String converts an option to a human friendly readable string describing the
+// option.
+func (option *Option) String() string {
+	var s string
+	var short string
+
+	if option.ShortName != 0 {
+		data := make([]byte, utf8.RuneLen(option.ShortName))
+		utf8.EncodeRune(data, option.ShortName)
+		short = string(data)
+
+		if len(option.LongName) != 0 {
+			s = fmt.Sprintf("%s%s, %s%s",
+				string(defaultShortOptDelimiter), short,
+				defaultLongOptDelimiter, option.LongNameWithNamespace())
+		} else {
+			s = fmt.Sprintf("%s%s", string(defaultShortOptDelimiter), short)
+		}
+	} else if len(option.LongName) != 0 {
+		s = fmt.Sprintf("%s%s", defaultLongOptDelimiter, option.LongNameWithNamespace())
+	}
+
+	return s
+}
+
+// Value returns the option value as an interface{}.
+func (option *Option) Value() interface{} {
+	return option.value.Interface()
+}
+
+// Field returns the reflect struct field of the option.
+func (option *Option) Field() reflect.StructField {
+	return option.field
+}
+
+// IsSet returns true if option has been set
+func (option *Option) IsSet() bool {
+	return option.isSet
+}
+
+// IsSetDefault returns true if option has been set via the default option tag
+func (option *Option) IsSetDefault() bool {
+	return option.isSetDefault
+}
+
+// Set the value of an option to the specified value. An error will be returned
+// if the specified value could not be converted to the corresponding option
+// value type.
+func (option *Option) set(value *string) error {
+	kind := option.value.Type().Kind()
+
+	if (kind == reflect.Map || kind == reflect.Slice) && !option.isSet {
+		option.empty()
+	}
+
+	option.isSet = true
+	option.preventDefault = true
+
+	if len(option.Choices) != 0 {
+		found := false
+
+		for _, choice := range option.Choices {
+			if choice == *value {
+				found = true
+				break
+			}
+		}
+
+		if !found {
+			allowed := strings.Join(option.Choices[0:len(option.Choices)-1], ", ")
+
+			if len(option.Choices) > 1 {
+				allowed += " or " + option.Choices[len(option.Choices)-1]
+			}
+
+			return newErrorf(ErrInvalidChoice,
+				"Invalid value `%s' for option `%s'. Allowed values are: %s",
+				*value, option, allowed)
+		}
+	}
+
+	if option.isFunc() {
+		return option.call(value)
+	} else if value != nil {
+		return convert(*value, option.value, option.tag)
+	}
+
+	return convert("", option.value, option.tag)
+}
+
+func (option *Option) canCli() bool {
+	return option.ShortName != 0 || len(option.LongName) != 0
+}
+
+func (option *Option) canArgument() bool {
+	if u := option.isUnmarshaler(); u != nil {
+		return true
+	}
+
+	return !option.isBool()
+}
+
+func (option *Option) emptyValue() reflect.Value {
+	tp := option.value.Type()
+
+	if tp.Kind() == reflect.Map {
+		return reflect.MakeMap(tp)
+	}
+
+	return reflect.Zero(tp)
+}
+
+func (option *Option) empty() {
+	if !option.isFunc() {
+		option.value.Set(option.emptyValue())
+	}
+}
+
+func (option *Option) clearDefault() {
+	usedDefault := option.Default
+
+	if envKey := option.EnvDefaultKey; envKey != "" {
+		if value, ok := os.LookupEnv(envKey); ok {
+			if option.EnvDefaultDelim != "" {
+				usedDefault = strings.Split(value,
+					option.EnvDefaultDelim)
+			} else {
+				usedDefault = []string{value}
+			}
+		}
+	}
+
+	option.isSetDefault = true
+
+	if len(usedDefault) > 0 {
+		option.empty()
+
+		for _, d := range usedDefault {
+			option.set(&d)
+			option.isSetDefault = true
+		}
+	} else {
+		tp := option.value.Type()
+
+		switch tp.Kind() {
+		case reflect.Map:
+			if option.value.IsNil() {
+				option.empty()
+			}
+		case reflect.Slice:
+			if option.value.IsNil() {
+				option.empty()
+			}
+		}
+	}
+}
+
+func (option *Option) valueIsDefault() bool {
+	// Check if the value of the option corresponds to its
+	// default value
+	emptyval := option.emptyValue()
+
+	checkvalptr := reflect.New(emptyval.Type())
+	checkval := reflect.Indirect(checkvalptr)
+
+	checkval.Set(emptyval)
+
+	if len(option.Default) != 0 {
+		for _, v := range option.Default {
+			convert(v, checkval, option.tag)
+		}
+	}
+
+	return reflect.DeepEqual(option.value.Interface(), checkval.Interface())
+}
+
+func (option *Option) isUnmarshaler() Unmarshaler {
+	v := option.value
+
+	for {
+		if !v.CanInterface() {
+			break
+		}
+
+		i := v.Interface()
+
+		if u, ok := i.(Unmarshaler); ok {
+			return u
+		}
+
+		if !v.CanAddr() {
+			break
+		}
+
+		v = v.Addr()
+	}
+
+	return nil
+}
+
+func (option *Option) isBool() bool {
+	tp := option.value.Type()
+
+	for {
+		switch tp.Kind() {
+		case reflect.Slice, reflect.Ptr:
+			tp = tp.Elem()
+		case reflect.Bool:
+			return true
+		case reflect.Func:
+			return tp.NumIn() == 0
+		default:
+			return false
+		}
+	}
+}
+
+func (option *Option) isSignedNumber() bool {
+	tp := option.value.Type()
+
+	for {
+		switch tp.Kind() {
+		case reflect.Slice, reflect.Ptr:
+			tp = tp.Elem()
+		case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64:
+			return true
+		default:
+			return false
+		}
+	}
+}
+
+func (option *Option) isFunc() bool {
+	return option.value.Type().Kind() == reflect.Func
+}
+
+func (option *Option) call(value *string) error {
+	var retval []reflect.Value
+
+	if value == nil {
+		retval = option.value.Call(nil)
+	} else {
+		tp := option.value.Type().In(0)
+
+		val := reflect.New(tp)
+		val = reflect.Indirect(val)
+
+		if err := convert(*value, val, option.tag); err != nil {
+			return err
+		}
+
+		retval = option.value.Call([]reflect.Value{val})
+	}
+
+	if len(retval) == 1 && retval[0].Type() == reflect.TypeOf((*error)(nil)).Elem() {
+		if retval[0].Interface() == nil {
+			return nil
+		}
+
+		return retval[0].Interface().(error)
+	}
+
+	return nil
+}
+
+func (option *Option) updateDefaultLiteral() {
+	defs := option.Default
+	def := ""
+
+	if len(defs) == 0 && option.canArgument() {
+		var showdef bool
+
+		switch option.field.Type.Kind() {
+		case reflect.Func, reflect.Ptr:
+			showdef = !option.value.IsNil()
+		case reflect.Slice, reflect.String, reflect.Array:
+			showdef = option.value.Len() > 0
+		case reflect.Map:
+			showdef = !option.value.IsNil() && option.value.Len() > 0
+		default:
+			zeroval := reflect.Zero(option.field.Type)
+			showdef = !reflect.DeepEqual(zeroval.Interface(), option.value.Interface())
+		}
+
+		if showdef {
+			def, _ = convertToString(option.value, option.tag)
+		}
+	} else if len(defs) != 0 {
+		l := len(defs) - 1
+
+		for i := 0; i < l; i++ {
+			def += quoteIfNeeded(defs[i]) + ", "
+		}
+
+		def += quoteIfNeeded(defs[l])
+	}
+
+	option.defaultLiteral = def
+}
+
+func (option *Option) shortAndLongName() string {
+	ret := &bytes.Buffer{}
+
+	if option.ShortName != 0 {
+		ret.WriteRune(defaultShortOptDelimiter)
+		ret.WriteRune(option.ShortName)
+	}
+
+	if len(option.LongName) != 0 {
+		if option.ShortName != 0 {
+			ret.WriteRune('/')
+		}
+
+		ret.WriteString(option.LongName)
+	}
+
+	return ret.String()
+}

+ 67 - 0
vendor/github.com/jessevdk/go-flags/optstyle_other.go

@@ -0,0 +1,67 @@
+// +build !windows forceposix
+
+package flags
+
+import (
+	"strings"
+)
+
+const (
+	defaultShortOptDelimiter = '-'
+	defaultLongOptDelimiter  = "--"
+	defaultNameArgDelimiter  = '='
+)
+
+func argumentStartsOption(arg string) bool {
+	return len(arg) > 0 && arg[0] == '-'
+}
+
+func argumentIsOption(arg string) bool {
+	if len(arg) > 1 && arg[0] == '-' && arg[1] != '-' {
+		return true
+	}
+
+	if len(arg) > 2 && arg[0] == '-' && arg[1] == '-' && arg[2] != '-' {
+		return true
+	}
+
+	return false
+}
+
+// stripOptionPrefix returns the option without the prefix and whether or
+// not the option is a long option or not.
+func stripOptionPrefix(optname string) (prefix string, name string, islong bool) {
+	if strings.HasPrefix(optname, "--") {
+		return "--", optname[2:], true
+	} else if strings.HasPrefix(optname, "-") {
+		return "-", optname[1:], false
+	}
+
+	return "", optname, false
+}
+
+// splitOption attempts to split the passed option into a name and an argument.
+// When there is no argument specified, nil will be returned for it.
+func splitOption(prefix string, option string, islong bool) (string, string, *string) {
+	pos := strings.Index(option, "=")
+
+	if (islong && pos >= 0) || (!islong && pos == 1) {
+		rest := option[pos+1:]
+		return option[:pos], "=", &rest
+	}
+
+	return option, "", nil
+}
+
+// addHelpGroup adds a new group that contains default help parameters.
+func (c *Command) addHelpGroup(showHelp func() error) *Group {
+	var help struct {
+		ShowHelp func() error `short:"h" long:"help" description:"Show this help message"`
+	}
+
+	help.ShowHelp = showHelp
+	ret, _ := c.AddGroup("Help Options", "", &help)
+	ret.isBuiltinHelp = true
+
+	return ret
+}

+ 108 - 0
vendor/github.com/jessevdk/go-flags/optstyle_windows.go

@@ -0,0 +1,108 @@
+// +build !forceposix
+
+package flags
+
+import (
+	"strings"
+)
+
+// Windows uses a front slash for both short and long options.  Also it uses
+// a colon for name/argument delimter.
+const (
+	defaultShortOptDelimiter = '/'
+	defaultLongOptDelimiter  = "/"
+	defaultNameArgDelimiter  = ':'
+)
+
+func argumentStartsOption(arg string) bool {
+	return len(arg) > 0 && (arg[0] == '-' || arg[0] == '/')
+}
+
+func argumentIsOption(arg string) bool {
+	// Windows-style options allow front slash for the option
+	// delimiter.
+	if len(arg) > 1 && arg[0] == '/' {
+		return true
+	}
+
+	if len(arg) > 1 && arg[0] == '-' && arg[1] != '-' {
+		return true
+	}
+
+	if len(arg) > 2 && arg[0] == '-' && arg[1] == '-' && arg[2] != '-' {
+		return true
+	}
+
+	return false
+}
+
+// stripOptionPrefix returns the option without the prefix and whether or
+// not the option is a long option or not.
+func stripOptionPrefix(optname string) (prefix string, name string, islong bool) {
+	// Determine if the argument is a long option or not.  Windows
+	// typically supports both long and short options with a single
+	// front slash as the option delimiter, so handle this situation
+	// nicely.
+	possplit := 0
+
+	if strings.HasPrefix(optname, "--") {
+		possplit = 2
+		islong = true
+	} else if strings.HasPrefix(optname, "-") {
+		possplit = 1
+		islong = false
+	} else if strings.HasPrefix(optname, "/") {
+		possplit = 1
+		islong = len(optname) > 2
+	}
+
+	return optname[:possplit], optname[possplit:], islong
+}
+
+// splitOption attempts to split the passed option into a name and an argument.
+// When there is no argument specified, nil will be returned for it.
+func splitOption(prefix string, option string, islong bool) (string, string, *string) {
+	if len(option) == 0 {
+		return option, "", nil
+	}
+
+	// Windows typically uses a colon for the option name and argument
+	// delimiter while POSIX typically uses an equals.  Support both styles,
+	// but don't allow the two to be mixed.  That is to say /foo:bar and
+	// --foo=bar are acceptable, but /foo=bar and --foo:bar are not.
+	var pos int
+	var sp string
+
+	if prefix == "/" {
+		sp = ":"
+		pos = strings.Index(option, sp)
+	} else if len(prefix) > 0 {
+		sp = "="
+		pos = strings.Index(option, sp)
+	}
+
+	if (islong && pos >= 0) || (!islong && pos == 1) {
+		rest := option[pos+1:]
+		return option[:pos], sp, &rest
+	}
+
+	return option, "", nil
+}
+
+// addHelpGroup adds a new group that contains default help parameters.
+func (c *Command) addHelpGroup(showHelp func() error) *Group {
+	// Windows CLI applications typically use /? for help, so make both
+	// that available as well as the POSIX style h and help.
+	var help struct {
+		ShowHelpWindows func() error `short:"?" description:"Show this help message"`
+		ShowHelpPosix   func() error `short:"h" long:"help" description:"Show this help message"`
+	}
+
+	help.ShowHelpWindows = showHelp
+	help.ShowHelpPosix = showHelp
+
+	ret, _ := c.AddGroup("Help Options", "", &help)
+	ret.isBuiltinHelp = true
+
+	return ret
+}

+ 700 - 0
vendor/github.com/jessevdk/go-flags/parser.go

@@ -0,0 +1,700 @@
+// Copyright 2012 Jesse van den Kieboom. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package flags
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"path"
+	"sort"
+	"strings"
+	"unicode/utf8"
+)
+
+// A Parser provides command line option parsing. It can contain several
+// option groups each with their own set of options.
+type Parser struct {
+	// Embedded, see Command for more information
+	*Command
+
+	// A usage string to be displayed in the help message.
+	Usage string
+
+	// Option flags changing the behavior of the parser.
+	Options Options
+
+	// NamespaceDelimiter separates group namespaces and option long names
+	NamespaceDelimiter string
+
+	// UnknownOptionsHandler is a function which gets called when the parser
+	// encounters an unknown option. The function receives the unknown option
+	// name, a SplitArgument which specifies its value if set with an argument
+	// separator, and the remaining command line arguments.
+	// It should return a new list of remaining arguments to continue parsing,
+	// or an error to indicate a parse failure.
+	UnknownOptionHandler func(option string, arg SplitArgument, args []string) ([]string, error)
+
+	// CompletionHandler is a function gets called to handle the completion of
+	// items. By default, the items are printed and the application is exited.
+	// You can override this default behavior by specifying a custom CompletionHandler.
+	CompletionHandler func(items []Completion)
+
+	// CommandHandler is a function that gets called to handle execution of a
+	// command. By default, the command will simply be executed. This can be
+	// overridden to perform certain actions (such as applying global flags)
+	// just before the command is executed. Note that if you override the
+	// handler it is your responsibility to call the command.Execute function.
+	//
+	// The command passed into CommandHandler may be nil in case there is no
+	// command to be executed when parsing has finished.
+	CommandHandler func(command Commander, args []string) error
+
+	internalError error
+}
+
+// SplitArgument represents the argument value of an option that was passed using
+// an argument separator.
+type SplitArgument interface {
+	// String returns the option's value as a string, and a boolean indicating
+	// if the option was present.
+	Value() (string, bool)
+}
+
+type strArgument struct {
+	value *string
+}
+
+func (s strArgument) Value() (string, bool) {
+	if s.value == nil {
+		return "", false
+	}
+
+	return *s.value, true
+}
+
+// Options provides parser options that change the behavior of the option
+// parser.
+type Options uint
+
+const (
+	// None indicates no options.
+	None Options = 0
+
+	// HelpFlag adds a default Help Options group to the parser containing
+	// -h and --help options. When either -h or --help is specified on the
+	// command line, the parser will return the special error of type
+	// ErrHelp. When PrintErrors is also specified, then the help message
+	// will also be automatically printed to os.Stdout.
+	HelpFlag = 1 << iota
+
+	// PassDoubleDash passes all arguments after a double dash, --, as
+	// remaining command line arguments (i.e. they will not be parsed for
+	// flags).
+	PassDoubleDash
+
+	// IgnoreUnknown ignores any unknown options and passes them as
+	// remaining command line arguments instead of generating an error.
+	IgnoreUnknown
+
+	// PrintErrors prints any errors which occurred during parsing to
+	// os.Stderr. In the special case of ErrHelp, the message will be printed
+	// to os.Stdout.
+	PrintErrors
+
+	// PassAfterNonOption passes all arguments after the first non option
+	// as remaining command line arguments. This is equivalent to strict
+	// POSIX processing.
+	PassAfterNonOption
+
+	// Default is a convenient default set of options which should cover
+	// most of the uses of the flags package.
+	Default = HelpFlag | PrintErrors | PassDoubleDash
+)
+
+type parseState struct {
+	arg        string
+	args       []string
+	retargs    []string
+	positional []*Arg
+	err        error
+
+	command *Command
+	lookup  lookup
+}
+
+// Parse is a convenience function to parse command line options with default
+// settings. The provided data is a pointer to a struct representing the
+// default option group (named "Application Options"). For more control, use
+// flags.NewParser.
+func Parse(data interface{}) ([]string, error) {
+	return NewParser(data, Default).Parse()
+}
+
+// ParseArgs is a convenience function to parse command line options with default
+// settings. The provided data is a pointer to a struct representing the
+// default option group (named "Application Options"). The args argument is
+// the list of command line arguments to parse. If you just want to parse the
+// default program command line arguments (i.e. os.Args), then use flags.Parse
+// instead. For more control, use flags.NewParser.
+func ParseArgs(data interface{}, args []string) ([]string, error) {
+	return NewParser(data, Default).ParseArgs(args)
+}
+
+// NewParser creates a new parser. It uses os.Args[0] as the application
+// name and then calls Parser.NewNamedParser (see Parser.NewNamedParser for
+// more details). The provided data is a pointer to a struct representing the
+// default option group (named "Application Options"), or nil if the default
+// group should not be added. The options parameter specifies a set of options
+// for the parser.
+func NewParser(data interface{}, options Options) *Parser {
+	p := NewNamedParser(path.Base(os.Args[0]), options)
+
+	if data != nil {
+		g, err := p.AddGroup("Application Options", "", data)
+
+		if err == nil {
+			g.parent = p
+		}
+
+		p.internalError = err
+	}
+
+	return p
+}
+
+// NewNamedParser creates a new parser. The appname is used to display the
+// executable name in the built-in help message. Option groups and commands can
+// be added to this parser by using AddGroup and AddCommand.
+func NewNamedParser(appname string, options Options) *Parser {
+	p := &Parser{
+		Command:            newCommand(appname, "", "", nil),
+		Options:            options,
+		NamespaceDelimiter: ".",
+	}
+
+	p.Command.parent = p
+
+	return p
+}
+
+// Parse parses the command line arguments from os.Args using Parser.ParseArgs.
+// For more detailed information see ParseArgs.
+func (p *Parser) Parse() ([]string, error) {
+	return p.ParseArgs(os.Args[1:])
+}
+
+// ParseArgs parses the command line arguments according to the option groups that
+// were added to the parser. On successful parsing of the arguments, the
+// remaining, non-option, arguments (if any) are returned. The returned error
+// indicates a parsing error and can be used with PrintError to display
+// contextual information on where the error occurred exactly.
+//
+// When the common help group has been added (AddHelp) and either -h or --help
+// was specified in the command line arguments, a help message will be
+// automatically printed if the PrintErrors option is enabled.
+// Furthermore, the special error type ErrHelp is returned.
+// It is up to the caller to exit the program if so desired.
+func (p *Parser) ParseArgs(args []string) ([]string, error) {
+	if p.internalError != nil {
+		return nil, p.internalError
+	}
+
+	p.eachOption(func(c *Command, g *Group, option *Option) {
+		option.isSet = false
+		option.isSetDefault = false
+		option.updateDefaultLiteral()
+	})
+
+	// Add built-in help group to all commands if necessary
+	if (p.Options & HelpFlag) != None {
+		p.addHelpGroups(p.showBuiltinHelp)
+	}
+
+	compval := os.Getenv("GO_FLAGS_COMPLETION")
+
+	if len(compval) != 0 {
+		comp := &completion{parser: p}
+		items := comp.complete(args)
+
+		if p.CompletionHandler != nil {
+			p.CompletionHandler(items)
+		} else {
+			comp.print(items, compval == "verbose")
+			os.Exit(0)
+		}
+
+		return nil, nil
+	}
+
+	s := &parseState{
+		args:    args,
+		retargs: make([]string, 0, len(args)),
+	}
+
+	p.fillParseState(s)
+
+	for !s.eof() {
+		arg := s.pop()
+
+		// When PassDoubleDash is set and we encounter a --, then
+		// simply append all the rest as arguments and break out
+		if (p.Options&PassDoubleDash) != None && arg == "--" {
+			s.addArgs(s.args...)
+			break
+		}
+
+		if !argumentIsOption(arg) {
+			// Note: this also sets s.err, so we can just check for
+			// nil here and use s.err later
+			if p.parseNonOption(s) != nil {
+				break
+			}
+
+			continue
+		}
+
+		var err error
+
+		prefix, optname, islong := stripOptionPrefix(arg)
+		optname, _, argument := splitOption(prefix, optname, islong)
+
+		if islong {
+			err = p.parseLong(s, optname, argument)
+		} else {
+			err = p.parseShort(s, optname, argument)
+		}
+
+		if err != nil {
+			ignoreUnknown := (p.Options & IgnoreUnknown) != None
+			parseErr := wrapError(err)
+
+			if parseErr.Type != ErrUnknownFlag || (!ignoreUnknown && p.UnknownOptionHandler == nil) {
+				s.err = parseErr
+				break
+			}
+
+			if ignoreUnknown {
+				s.addArgs(arg)
+			} else if p.UnknownOptionHandler != nil {
+				modifiedArgs, err := p.UnknownOptionHandler(optname, strArgument{argument}, s.args)
+
+				if err != nil {
+					s.err = err
+					break
+				}
+
+				s.args = modifiedArgs
+			}
+		}
+	}
+
+	if s.err == nil {
+		p.eachOption(func(c *Command, g *Group, option *Option) {
+			if option.preventDefault {
+				return
+			}
+
+			option.clearDefault()
+		})
+
+		s.checkRequired(p)
+	}
+
+	var reterr error
+
+	if s.err != nil {
+		reterr = s.err
+	} else if len(s.command.commands) != 0 && !s.command.SubcommandsOptional {
+		reterr = s.estimateCommand()
+	} else if cmd, ok := s.command.data.(Commander); ok {
+		if p.CommandHandler != nil {
+			reterr = p.CommandHandler(cmd, s.retargs)
+		} else {
+			reterr = cmd.Execute(s.retargs)
+		}
+	} else if p.CommandHandler != nil {
+		reterr = p.CommandHandler(nil, s.retargs)
+	}
+
+	if reterr != nil {
+		var retargs []string
+
+		if ourErr, ok := reterr.(*Error); !ok || ourErr.Type != ErrHelp {
+			retargs = append([]string{s.arg}, s.args...)
+		} else {
+			retargs = s.args
+		}
+
+		return retargs, p.printError(reterr)
+	}
+
+	return s.retargs, nil
+}
+
+func (p *parseState) eof() bool {
+	return len(p.args) == 0
+}
+
+func (p *parseState) pop() string {
+	if p.eof() {
+		return ""
+	}
+
+	p.arg = p.args[0]
+	p.args = p.args[1:]
+
+	return p.arg
+}
+
+func (p *parseState) peek() string {
+	if p.eof() {
+		return ""
+	}
+
+	return p.args[0]
+}
+
+func (p *parseState) checkRequired(parser *Parser) error {
+	c := parser.Command
+
+	var required []*Option
+
+	for c != nil {
+		c.eachGroup(func(g *Group) {
+			for _, option := range g.options {
+				if !option.isSet && option.Required {
+					required = append(required, option)
+				}
+			}
+		})
+
+		c = c.Active
+	}
+
+	if len(required) == 0 {
+		if len(p.positional) > 0 {
+			var reqnames []string
+
+			for _, arg := range p.positional {
+				argRequired := (!arg.isRemaining() && p.command.ArgsRequired) || arg.Required != -1 || arg.RequiredMaximum != -1
+
+				if !argRequired {
+					continue
+				}
+
+				if arg.isRemaining() {
+					if arg.value.Len() < arg.Required {
+						var arguments string
+
+						if arg.Required > 1 {
+							arguments = "arguments, but got only " + fmt.Sprintf("%d", arg.value.Len())
+						} else {
+							arguments = "argument"
+						}
+
+						reqnames = append(reqnames, "`"+arg.Name+" (at least "+fmt.Sprintf("%d", arg.Required)+" "+arguments+")`")
+					} else if arg.RequiredMaximum != -1 && arg.value.Len() > arg.RequiredMaximum {
+						if arg.RequiredMaximum == 0 {
+							reqnames = append(reqnames, "`"+arg.Name+" (zero arguments)`")
+						} else {
+							var arguments string
+
+							if arg.RequiredMaximum > 1 {
+								arguments = "arguments, but got " + fmt.Sprintf("%d", arg.value.Len())
+							} else {
+								arguments = "argument"
+							}
+
+							reqnames = append(reqnames, "`"+arg.Name+" (at most "+fmt.Sprintf("%d", arg.RequiredMaximum)+" "+arguments+")`")
+						}
+					}
+				} else {
+					reqnames = append(reqnames, "`"+arg.Name+"`")
+				}
+			}
+
+			if len(reqnames) == 0 {
+				return nil
+			}
+
+			var msg string
+
+			if len(reqnames) == 1 {
+				msg = fmt.Sprintf("the required argument %s was not provided", reqnames[0])
+			} else {
+				msg = fmt.Sprintf("the required arguments %s and %s were not provided",
+					strings.Join(reqnames[:len(reqnames)-1], ", "), reqnames[len(reqnames)-1])
+			}
+
+			p.err = newError(ErrRequired, msg)
+			return p.err
+		}
+
+		return nil
+	}
+
+	names := make([]string, 0, len(required))
+
+	for _, k := range required {
+		names = append(names, "`"+k.String()+"'")
+	}
+
+	sort.Strings(names)
+
+	var msg string
+
+	if len(names) == 1 {
+		msg = fmt.Sprintf("the required flag %s was not specified", names[0])
+	} else {
+		msg = fmt.Sprintf("the required flags %s and %s were not specified",
+			strings.Join(names[:len(names)-1], ", "), names[len(names)-1])
+	}
+
+	p.err = newError(ErrRequired, msg)
+	return p.err
+}
+
+func (p *parseState) estimateCommand() error {
+	commands := p.command.sortedVisibleCommands()
+	cmdnames := make([]string, len(commands))
+
+	for i, v := range commands {
+		cmdnames[i] = v.Name
+	}
+
+	var msg string
+	var errtype ErrorType
+
+	if len(p.retargs) != 0 {
+		c, l := closestChoice(p.retargs[0], cmdnames)
+		msg = fmt.Sprintf("Unknown command `%s'", p.retargs[0])
+		errtype = ErrUnknownCommand
+
+		if float32(l)/float32(len(c)) < 0.5 {
+			msg = fmt.Sprintf("%s, did you mean `%s'?", msg, c)
+		} else if len(cmdnames) == 1 {
+			msg = fmt.Sprintf("%s. You should use the %s command",
+				msg,
+				cmdnames[0])
+		} else if len(cmdnames) > 1 {
+			msg = fmt.Sprintf("%s. Please specify one command of: %s or %s",
+				msg,
+				strings.Join(cmdnames[:len(cmdnames)-1], ", "),
+				cmdnames[len(cmdnames)-1])
+		}
+	} else {
+		errtype = ErrCommandRequired
+
+		if len(cmdnames) == 1 {
+			msg = fmt.Sprintf("Please specify the %s command", cmdnames[0])
+		} else if len(cmdnames) > 1 {
+			msg = fmt.Sprintf("Please specify one command of: %s or %s",
+				strings.Join(cmdnames[:len(cmdnames)-1], ", "),
+				cmdnames[len(cmdnames)-1])
+		}
+	}
+
+	return newError(errtype, msg)
+}
+
+func (p *Parser) parseOption(s *parseState, name string, option *Option, canarg bool, argument *string) (err error) {
+	if !option.canArgument() {
+		if argument != nil {
+			return newErrorf(ErrNoArgumentForBool, "bool flag `%s' cannot have an argument", option)
+		}
+
+		err = option.set(nil)
+	} else if argument != nil || (canarg && !s.eof()) {
+		var arg string
+
+		if argument != nil {
+			arg = *argument
+		} else {
+			arg = s.pop()
+
+			if argumentIsOption(arg) && !(option.isSignedNumber() && len(arg) > 1 && arg[0] == '-' && arg[1] >= '0' && arg[1] <= '9') {
+				return newErrorf(ErrExpectedArgument, "expected argument for flag `%s', but got option `%s'", option, arg)
+			} else if p.Options&PassDoubleDash != 0 && arg == "--" {
+				return newErrorf(ErrExpectedArgument, "expected argument for flag `%s', but got double dash `--'", option)
+			}
+		}
+
+		if option.tag.Get("unquote") != "false" {
+			arg, err = unquoteIfPossible(arg)
+		}
+
+		if err == nil {
+			err = option.set(&arg)
+		}
+	} else if option.OptionalArgument {
+		option.empty()
+
+		for _, v := range option.OptionalValue {
+			err = option.set(&v)
+
+			if err != nil {
+				break
+			}
+		}
+	} else {
+		err = newErrorf(ErrExpectedArgument, "expected argument for flag `%s'", option)
+	}
+
+	if err != nil {
+		if _, ok := err.(*Error); !ok {
+			err = newErrorf(ErrMarshal, "invalid argument for flag `%s' (expected %s): %s",
+				option,
+				option.value.Type(),
+				err.Error())
+		}
+	}
+
+	return err
+}
+
+func (p *Parser) parseLong(s *parseState, name string, argument *string) error {
+	if option := s.lookup.longNames[name]; option != nil {
+		// Only long options that are required can consume an argument
+		// from the argument list
+		canarg := !option.OptionalArgument
+
+		return p.parseOption(s, name, option, canarg, argument)
+	}
+
+	return newErrorf(ErrUnknownFlag, "unknown flag `%s'", name)
+}
+
+func (p *Parser) splitShortConcatArg(s *parseState, optname string) (string, *string) {
+	c, n := utf8.DecodeRuneInString(optname)
+
+	if n == len(optname) {
+		return optname, nil
+	}
+
+	first := string(c)
+
+	if option := s.lookup.shortNames[first]; option != nil && option.canArgument() {
+		arg := optname[n:]
+		return first, &arg
+	}
+
+	return optname, nil
+}
+
+func (p *Parser) parseShort(s *parseState, optname string, argument *string) error {
+	if argument == nil {
+		optname, argument = p.splitShortConcatArg(s, optname)
+	}
+
+	for i, c := range optname {
+		shortname := string(c)
+
+		if option := s.lookup.shortNames[shortname]; option != nil {
+			// Only the last short argument can consume an argument from
+			// the arguments list, and only if it's non optional
+			canarg := (i+utf8.RuneLen(c) == len(optname)) && !option.OptionalArgument
+
+			if err := p.parseOption(s, shortname, option, canarg, argument); err != nil {
+				return err
+			}
+		} else {
+			return newErrorf(ErrUnknownFlag, "unknown flag `%s'", shortname)
+		}
+
+		// Only the first option can have a concatted argument, so just
+		// clear argument here
+		argument = nil
+	}
+
+	return nil
+}
+
+func (p *parseState) addArgs(args ...string) error {
+	for len(p.positional) > 0 && len(args) > 0 {
+		arg := p.positional[0]
+
+		if err := convert(args[0], arg.value, arg.tag); err != nil {
+			p.err = err
+			return err
+		}
+
+		if !arg.isRemaining() {
+			p.positional = p.positional[1:]
+		}
+
+		args = args[1:]
+	}
+
+	p.retargs = append(p.retargs, args...)
+	return nil
+}
+
+func (p *Parser) parseNonOption(s *parseState) error {
+	if len(s.positional) > 0 {
+		return s.addArgs(s.arg)
+	}
+
+	if len(s.command.commands) > 0 && len(s.retargs) == 0 {
+		if cmd := s.lookup.commands[s.arg]; cmd != nil {
+			s.command.Active = cmd
+			cmd.fillParseState(s)
+
+			return nil
+		} else if !s.command.SubcommandsOptional {
+			s.addArgs(s.arg)
+			return newErrorf(ErrUnknownCommand, "Unknown command `%s'", s.arg)
+		}
+	}
+
+	if (p.Options & PassAfterNonOption) != None {
+		// If PassAfterNonOption is set then all remaining arguments
+		// are considered positional
+		if err := s.addArgs(s.arg); err != nil {
+			return err
+		}
+
+		if err := s.addArgs(s.args...); err != nil {
+			return err
+		}
+
+		s.args = []string{}
+	} else {
+		return s.addArgs(s.arg)
+	}
+
+	return nil
+}
+
+func (p *Parser) showBuiltinHelp() error {
+	var b bytes.Buffer
+
+	p.WriteHelp(&b)
+	return newError(ErrHelp, b.String())
+}
+
+func (p *Parser) printError(err error) error {
+	if err != nil && (p.Options&PrintErrors) != None {
+		flagsErr, ok := err.(*Error)
+
+		if ok && flagsErr.Type == ErrHelp {
+			fmt.Fprintln(os.Stdout, err)
+		} else {
+			fmt.Fprintln(os.Stderr, err)
+		}
+	}
+
+	return err
+}
+
+func (p *Parser) clearIsSet() {
+	p.eachCommand(func(c *Command) {
+		c.eachGroup(func(g *Group) {
+			for _, option := range g.options {
+				option.isSet = false
+			}
+		})
+	}, true)
+}

+ 28 - 0
vendor/github.com/jessevdk/go-flags/termsize.go

@@ -0,0 +1,28 @@
+// +build !windows,!plan9,!solaris,!appengine
+
+package flags
+
+import (
+	"syscall"
+	"unsafe"
+)
+
+type winsize struct {
+	row, col       uint16
+	xpixel, ypixel uint16
+}
+
+func getTerminalColumns() int {
+	ws := winsize{}
+
+	if tIOCGWINSZ != 0 {
+		syscall.Syscall(syscall.SYS_IOCTL,
+			uintptr(0),
+			uintptr(tIOCGWINSZ),
+			uintptr(unsafe.Pointer(&ws)))
+
+		return int(ws.col)
+	}
+
+	return 80
+}

+ 7 - 0
vendor/github.com/jessevdk/go-flags/termsize_nosysioctl.go

@@ -0,0 +1,7 @@
+// +build windows plan9 solaris appengine
+
+package flags
+
+func getTerminalColumns() int {
+	return 80
+}

+ 7 - 0
vendor/github.com/jessevdk/go-flags/tiocgwinsz_bsdish.go

@@ -0,0 +1,7 @@
+// +build darwin freebsd netbsd openbsd
+
+package flags
+
+const (
+	tIOCGWINSZ = 0x40087468
+)

+ 7 - 0
vendor/github.com/jessevdk/go-flags/tiocgwinsz_linux.go

@@ -0,0 +1,7 @@
+// +build linux
+
+package flags
+
+const (
+	tIOCGWINSZ = 0x5413
+)

+ 7 - 0
vendor/github.com/jessevdk/go-flags/tiocgwinsz_other.go

@@ -0,0 +1,7 @@
+// +build !darwin,!freebsd,!netbsd,!openbsd,!linux
+
+package flags
+
+const (
+	tIOCGWINSZ = 0
+)

+ 26 - 0
vendor/github.com/mmcdole/gofeed/.gitignore

@@ -0,0 +1,26 @@
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe
+*.test
+*.prof
+
+.DS_STORE

+ 16 - 0
vendor/github.com/mmcdole/gofeed/.travis.yml

@@ -0,0 +1,16 @@
+language: go
+go:
+- tip
+- 1.6
+- 1.5
+- 1.4
+install:
+- go get -t -v ./...
+- go get github.com/go-playground/overalls
+- go get github.com/mattn/goveralls
+- go get golang.org/x/tools/cmd/cover
+script:
+- go test -v ./...
+- $GOPATH/bin/overalls -project=github.com/mmcdole/gofeed -covermode=count -ignore=.git,vendor -debug
+after_success:
+    - $GOPATH/bin/goveralls -coverprofile=overalls.coverprofile -service=travis-ci

+ 21 - 0
vendor/github.com/mmcdole/gofeed/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 mmcdole
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 254 - 0
vendor/github.com/mmcdole/gofeed/README.md

@@ -0,0 +1,254 @@
+# gofeed 
+
+[![Build Status](https://travis-ci.org/mmcdole/gofeed.svg?branch=master)](https://travis-ci.org/mmcdole/gofeed) [![Coverage Status](https://coveralls.io/repos/github/mmcdole/gofeed/badge.svg?branch=master)](https://coveralls.io/github/mmcdole/gofeed?branch=master) [![Go Report Card](https://goreportcard.com/badge/github.com/mmcdole/gofeed)](https://goreportcard.com/report/github.com/mmcdole/gofeed) [![](https://godoc.org/github.com/mmcdole/gofeed?status.svg)](http://godoc.org/github.com/mmcdole/gofeed) [![License](http://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org)
+
+The `gofeed` library is a robust feed parser that supports parsing both [RSS](https://en.wikipedia.org/wiki/RSS) and [Atom](https://en.wikipedia.org/wiki/Atom_(standard)) feeds.  The universal `gofeed.Parser` will parse and convert all feed types into a hybrid `gofeed.Feed` model.  You also have the option of parsing them into their respective `atom.Feed` and `rss.Feed` models using the feed specific `atom.Parser` or `rss.Parser`.  
+
+##### Supported feed types:
+* RSS 0.90
+* Netscape RSS 0.91
+* Userland RSS 0.91
+* RSS 0.92
+* RSS 0.93
+* RSS 0.94
+* RSS 1.0
+* RSS 2.0
+* Atom 0.3
+* Atom 1.0
+
+It also provides support for parsing several popular predefined extension modules, including [Dublin Core](http://dublincore.org/documents/dces/) and [Apple’s iTunes](https://help.apple.com/itc/podcasts_connect/#/itcb54353390), as well as arbitrary extensions.  See the [Extensions](#extensions) section for more details.
+
+## Table of Contents
+- [Overview](#overview)
+- [Basic Usage](#basic-usage)
+- [Advanced Usage](#advanced-usage)
+- [Extensions](#extensions)
+- [Invalid Feeds](#invalid-feeds)
+- [Default Mappings](#default-mappings)
+- [Dependencies](#dependencies)
+- [License](#license)
+- [Donate](#donate)
+- [Credits](#credits)
+
+## Overview
+
+#### Universal Feed Parser
+
+The universal `gofeed.Parser` works in 3 stages: detection, parsing and translation.  It first detects the feed type that it is currently parsing.  Then it uses a feed specific parser to parse the feed into its true representation which will be either a `rss.Feed` or `atom.Feed`.  These models cover every field possible for their respective feed types.  Finally, they are *translated* into a `gofeed.Feed` model that is a hybrid of both feed types.  Performing the universal feed parsing in these 3 stages allows for more flexibility and keeps the code base more maintainable by separating RSS and Atom parsing into seperate packages.
+
+![Diagram](docs/sequence.png)
+
+The translation step is done by anything which adheres to the `gofeed.Translator` interface.  The `DefaultRSSTranslator` and `DefaultAtomTranslator` are used behind the scenes when you use the `gofeed.Parser` with its default settings.  You can see how they translate fields from ```atom.Feed``` or ```rss.Feed``` to the universal ```gofeed.Feed``` struct in the [Default Mappings](#default-mappings) section.  However, should you disagree with the way certain fields are translated you can easily supply your own `gofeed.Translator` and override this behavior.  See the [Advanced Usage](#advanced-usage) section for an example how to do this.
+
+#### Feed Specific Parsers
+
+The `gofeed` library provides two feed specific parsers: `atom.Parser` and `rss.Parser`.  If the hybrid `gofeed.Feed` model that the universal `gofeed.Parser` produces does not contain a field from the `atom.Feed` or `rss.Feed` model that you require, it might be beneficial to use the feed specific parsers.  When using the `atom.Parser` or `rss.Parser` directly, you can access all of fields found in the `atom.Feed` and `rss.Feed` models.  It is also marginally faster because you are able to skip the translation step.
+
+However, for the *vast* majority of users, the universal `gofeed.Parser` is the best way to parse feeds.  This allows the user of `gofeed` library to not care about the differences between RSS or Atom feeds.
+
+## Basic Usage
+
+#### Universal Feed Parser
+
+The most common usage scenario will be to use ```gofeed.Parser``` to parse an arbitrary RSS or Atom feed into the hybrid ```gofeed.Feed``` model.  This hybrid model allows you to treat RSS and Atom feeds the same.
+
+##### Parse a feed from an URL:
+
+```go
+fp := gofeed.NewParser()
+feed, _ := fp.ParseURL("http://feeds.twit.tv/twit.xml")
+fmt.Println(feed.Title)
+```
+
+##### Parse a feed from a string:
+
+```go
+feedData := `<rss version="2.0">
+<channel>
+<title>Sample Feed</title>
+</channel>
+</rss>`
+fp := gofeed.NewParser()
+feed, _ := fp.ParseString(feedData)
+fmt.Println(feed.Title)
+```
+
+##### Parse a feed from an io.Reader:
+
+```go
+file, _ := os.Open("/path/to/a/file.xml")
+defer file.Close()
+fp := gofeed.NewParser()
+feed, _ := fp.Parse(file)
+fmt.Println(feed.Title)
+```
+
+#### Feed Specific Parsers
+
+You can easily use the `rss.Parser` and `atom.Parser` directly if you have a usage scenario that requires it:
+
+##### Parse a RSS feed into a `rss.Feed`
+
+```go
+feedData := `<rss version="2.0">
+<channel>
+<webMaster>example@site.com (Example Name)</webMaster>
+</channel>
+</rss>`
+fp := rss.Parser{}
+rssFeed, _ := fp.Parse(strings.NewReader(feedData))
+fmt.Println(rssFeed.WebMaster)
+```
+
+##### Parse an Atom feed into a `atom.Feed`
+
+```go
+feedData := `<feed xmlns="http://www.w3.org/2005/Atom">
+<subtitle>Example Atom</subtitle>
+</feed>`
+fp := atom.Parser{}
+atomFeed, _ := fp.Parse(strings.NewReader(feedData))
+fmt.Println(atomFeed.Subtitle)
+```
+
+## Advanced Usage
+
+##### Parse a feed while using a custom translator
+
+The mappings and precedence order that are outlined in the [Default Mappings](#default-mappings) section are provided by the following two structs: `DefaultRSSTranslator` and `DefaultAtomTranslator`.  If you have fields that you think should have a different precedence, or if you want to make a translator that is aware of an unsupported extension you can do this by specifying your own RSS or Atom translator when using the `gofeed.Parser`.
+
+Here is a simple example of creating a custom `Translator` that makes the `/rss/channel/itunes:author` field have a higher precedence than the `/rss/channel/managingEditor` field in RSS feeds.  We will wrap the existing `DefaultRSSTranslator` since we only want to change the behavior for a single field.
+
+First we must define a custom translator:
+
+```go
+
+import (
+    "fmt"
+
+    "github.com/mmcdole/gofeed"
+    "github.com/mmcdole/gofeed/rss"
+)
+
+type MyCustomTranslator struct {
+    defaultTranslator *gofeed.DefaultRSSTranslator
+}
+
+func NewMyCustomTranslator() *MyCustomTranslator {
+  t := &MyCustomTranslator{}
+  
+  // We create a DefaultRSSTranslator internally so we can wrap its Translate
+  // call since we only want to modify the precedence for a single field.
+  t.defaultTranslator = &gofeed.DefaultRSSTranslator{}
+  return t
+}
+
+func (ct* MyCustomTranslator) Translate(feed interface{}) (*gofeed.Feed, error) {
+	rss, found := feed.(*rss.Feed)
+	if !found {
+		return nil, fmt.Errorf("Feed did not match expected type of *rss.Feed")
+	}
+
+  f, err := ct.defaultTranslator.Translate(rss)
+  if err != nil {
+    return nil, err
+  }
+  
+  if rss.ITunesExt != nil && rss.ITunesExt.Author != "" {
+      f.Author = rss.ITunesExt.Author
+  } else {
+      f.Author = rss.ManagingEditor
+  }
+  return f
+}
+```
+
+Next you must configure your `gofeed.Parser` to utilize the new `gofeed.Translator`:
+
+```go
+feedData := `<rss version="2.0">
+<channel>
+<managingEditor>Ender Wiggin</managingEditor>
+<itunes:author>Valentine Wiggin</itunes:author>
+</channel>
+</rss>`
+    
+fp := gofeed.NewParser()
+fp.RSSTranslator = NewMyCustomTranslator()
+feed, _ := fp.ParseString(feedData)
+fmt.Println(feed.Author) // Valentine Wiggin
+```
+
+## Extensions 
+
+Every element which does not belong to the feed's default namespace is considered an extension by `gofeed`.  These are parsed and stored in a tree-like structure located at `Feed.Extensions` and `Item.Extensions`.  These fields should allow you to access and read any custom extension elements.
+
+In addition to the generic handling of extensions, `gofeed` also has built in support for parsing certain popular extensions into their own structs for convenience.  It currently supports the [Dublin Core](http://dublincore.org/documents/dces/) and [Apple iTunes](https://help.apple.com/itc/podcasts_connect/#/itcb54353390) extensions which you can access at `Feed.ItunesExt`, `feed.DublinCoreExt` and `Item.ITunesExt` and `Item.DublinCoreExt`
+
+## Invalid Feeds
+
+A best-effort attempt is made at parsing broken and invalid XML feeds.  Currently, `gofeed` can succesfully parse feeds with the following issues:
+- Unescaped/Naked Markup in feed elements
+- Undeclared namespace prefixes
+- Missing closing tags on certain elements
+- Illegal tags within feed elements without namespace prefixes
+- Missing "required" elements as specified by the respective feed specs.
+- Incorrect date formats 
+
+## Default Mappings
+
+The ```DefaultRSSTranslator``` and the ```DefaultAtomTranslator``` map the following ```rss.Feed``` and ```atom.Feed``` fields to their respective ```gofeed.Feed``` fields.  They are listed in order of precedence (highest to lowest):
+
+
+`gofeed.Feed` | RSS | Atom
+--- | --- | ---
+Title | /rss/channel/title<br>/rdf:RDF/channel/title<br>/rss/channel/dc:title<br>/rdf:RDF/channel/dc:title | /feed/title
+Description | /rss/channel/description<br>/rdf:RDF/channel/description<br>/rss/channel/itunes:subtitle | /feed/subtitle<br>/feed/tagline
+Link | /rss/channel/link<br>/rdf:RDF/channel/link | /feed/link[@rel=”alternate”]/@href<br>/feed/link[not(@rel)]/@href
+FeedLink | /rss/channel/atom:link[@rel="self"]/@href<br>/rdf:RDF/channel/atom:link[@rel="self"]/@href | /feed/link[@rel="self"]/@href
+Updated | /rss/channel/lastBuildDate<br>/rss/channel/dc:date<br>/rdf:RDF/channel/dc:date | /feed/updated<br>/feed/modified
+Published | /rss/channel/pubDate |
+Author | /rss/channel/managingEditor<br>/rss/channel/webMaster<br>/rss/channel/dc:author<br>/rdf:RDF/channel/dc:author<br>/rss/channel/dc:creator<br>/rdf:RDF/channel/dc:creator<br>/rss/channel/itunes:author | /feed/author
+Language | /rss/channel/language<br>/rss/channel/dc:language<br>/rdf:RDF/channel/dc:language | /feed/@xml:lang
+Image | /rss/channel/image<br>/rdf:RDF/image<br>/rss/channel/itunes:image | /feed/logo
+Copyright | /rss/channel/copyright<br>/rss/channel/dc:rights<br>/rdf:RDF/channel/dc:rights | /feed/rights<br>/feed/copyright
+Generator | /rss/channel/generator | /feed/generator
+Categories | /rss/channel/category<br>/rss/channel/itunes:category<br>/rss/channel/itunes:keywords<br>/rss/channel/dc:subject<br>/rdf:RDF/channel/dc:subject | /feed/category
+
+
+`gofeed.Item` | RSS | Atom
+--- | --- | ---
+Title | /rss/channel/item/title<br>/rdf:RDF/item/title<br>/rdf:RDF/item/dc:title<br>/rss/channel/item/dc:title | /feed/entry/title
+Description | /rss/channel/item/description<br>/rdf:RDF/item/description<br>/rss/channel/item/dc:description<br>/rdf:RDF/item/dc:description | /feed/entry/summary
+Content | | /feed/entry/content
+Link | /rss/channel/item/link<br>/rdf:RDF/item/link | /feed/entry/link[@rel=”alternate”]/@href<br>/feed/entry/link[not(@rel)]/@href
+Updated | /rss/channel/item/dc:date<br>/rdf:RDF/rdf:item/dc:date | /feed/entry/modified<br>/feed/entry/updated
+Published | /rss/channel/item/pubDate<br>/rss/channel/item/dc:date | /feed/entry/published<br>/feed/entry/issued
+Author | /rss/channel/item/author<br>/rss/channel/item/dc:author<br>/rdf:RDF/item/dc:author<br>/rss/channel/item/dc:creator<br>/rdf:RDF/item/dc:creator<br>/rss/channel/item/itunes:author | /feed/entry/author
+Guid |  /rss/channel/item/guid | /feed/entry/id
+Image | /rss/channel/item/itunes:image<br>/rss/channel/item/media:image |
+Categories | /rss/channel/item/category<br>/rss/channel/item/dc:subject<br>/rss/channel/item/itunes:keywords<br>/rdf:RDF/channel/item/dc:subject | /feed/entry/category
+Enclosures | /rss/channel/item/enclosure | /feed/entry/link[@rel=”enclosure”]
+
+## Dependencies
+
+* [goxpp](https://github.com/mmcdole/goxpp) - XML Pull Parser
+* [goquery](https://github.com/PuerkitoBio/goquery) - Go jQuery-like interface
+* [testify](https://github.com/stretchr/testify) - Unit test enhancements
+
+## License
+
+This project is licensed under the [MIT License](https://raw.githubusercontent.com/mmcdole/gofeed/master/LICENSE)
+
+## Donate
+
+I write open source software for fun. However, if you want to buy me a beer because you found something I wrote useful, feel free!
+
+Bitcoin: 1CXrjBBkxgVNgKXRAq5MnsR7zzZbHvUHkJ
+
+## Credits
+
+* [Mark Pilgrim](https://en.wikipedia.org/wiki/Mark_Pilgrim) and [Kurt McKee](http://kurtmckee.org) for their work on the excellent [Universal Feed Parser](https://github.com/kurtmckee/feedparser) Python library.  This library was the inspiration for the `gofeed` library.
+* [Dan MacTough](http://blog.mact.me) for his work on [node-feedparser](https://github.com/danmactough/node-feedparser).  It provided inspiration for the set of fields that should be covered in the hybrid `gofeed.Feed` model.
+* [Matt Jibson](https://mattjibson.com/) for his date parsing function in the [goread](https://github.com/mjibson/goread) project.
+* [Jim Teeuwen](https://github.com/jteeuwen) for his method of representing arbitrary feed extensions in the [go-pkg-rss](https://github.com/jteeuwen/go-pkg-rss) library.

+ 114 - 0
vendor/github.com/mmcdole/gofeed/atom/feed.go

@@ -0,0 +1,114 @@
+package atom
+
+import (
+	"encoding/json"
+	"time"
+
+	"github.com/mmcdole/gofeed/extensions"
+)
+
+// Feed is an Atom Feed
+type Feed struct {
+	Title         string         `json:"title,omitempty"`
+	ID            string         `json:"id,omitempty"`
+	Updated       string         `json:"updated,omitempty"`
+	UpdatedParsed *time.Time     `json:"updatedParsed,omitempty"`
+	Subtitle      string         `json:"subtitle,omitempty"`
+	Links         []*Link        `json:"links,omitempty"`
+	Language      string         `json:"language,omitempty"`
+	Generator     *Generator     `json:"generator,omitempty"`
+	Icon          string         `json:"icon,omitempty"`
+	Logo          string         `json:"logo,omitempty"`
+	Rights        string         `json:"rights,omitempty"`
+	Contributors  []*Person      `json:"contributors,omitempty"`
+	Authors       []*Person      `json:"authors,omitempty"`
+	Categories    []*Category    `json:"categories,omitempty"`
+	Entries       []*Entry       `json:"entries"`
+	Extensions    ext.Extensions `json:"extensions,omitempty"`
+	Version       string         `json:"version"`
+}
+
+func (f Feed) String() string {
+	json, _ := json.MarshalIndent(f, "", "    ")
+	return string(json)
+}
+
+// Entry is an Atom Entry
+type Entry struct {
+	Title           string         `json:"title,omitempty"`
+	ID              string         `json:"id,omitempty"`
+	Updated         string         `json:"updated,omitempty"`
+	UpdatedParsed   *time.Time     `json:"updatedParsed,omitempty"`
+	Summary         string         `json:"summary,omitempty"`
+	Authors         []*Person      `json:"authors,omitempty"`
+	Contributors    []*Person      `json:"contributors,omitempty"`
+	Categories      []*Category    `json:"categories,omitempty"`
+	Links           []*Link        `json:"links,omitempty"`
+	Rights          string         `json:"rights,omitempty"`
+	Published       string         `json:"published,omitempty"`
+	PublishedParsed *time.Time     `json:"publishedParsed,omitempty"`
+	Source          *Source        `json:"source,omitempty"`
+	Content         *Content       `json:"content,omitempty"`
+	Extensions      ext.Extensions `json:"extensions,omitempty"`
+}
+
+// Category is category metadata for Feeds and Entries
+type Category struct {
+	Term   string `json:"term,omitempty"`
+	Scheme string `json:"scheme,omitempty"`
+	Label  string `json:"label,omitempty"`
+}
+
+// Person represents a person in an Atom feed
+// for things like Authors, Contributors, etc
+type Person struct {
+	Name  string `json:"name,omitempty"`
+	Email string `json:"email,omitempty"`
+	URI   string `json:"uri,omitempty"`
+}
+
+// Link is an Atom link that defines a reference
+// from an entry or feed to a Web resource
+type Link struct {
+	Href     string `json:"href,omitempty"`
+	Hreflang string `json:"hreflang,omitempty"`
+	Rel      string `json:"rel,omitempty"`
+	Type     string `json:"type,omitempty"`
+	Title    string `json:"title,omitempty"`
+	Length   string `json:"length,omitempty"`
+}
+
+// Content either contains or links to the content of
+// the entry
+type Content struct {
+	Src   string `json:"src,omitempty"`
+	Type  string `json:"type,omitempty"`
+	Value string `json:"value,omitempty"`
+}
+
+// Generator identifies the agent used to generate a
+// feed, for debugging and other purposes.
+type Generator struct {
+	Value   string `json:"value,omitempty"`
+	URI     string `json:"uri,omitempty"`
+	Version string `json:"version,omitempty"`
+}
+
+// Source contains the feed information for another
+// feed if a given entry came from that feed.
+type Source struct {
+	Title         string         `json:"title,omitempty"`
+	ID            string         `json:"id,omitempty"`
+	Updated       string         `json:"updated,omitempty"`
+	UpdatedParsed *time.Time     `json:"updatedParsed,omitempty"`
+	Subtitle      string         `json:"subtitle,omitempty"`
+	Links         []*Link        `json:"links,omitempty"`
+	Generator     *Generator     `json:"generator,omitempty"`
+	Icon          string         `json:"icon,omitempty"`
+	Logo          string         `json:"logo,omitempty"`
+	Rights        string         `json:"rights,omitempty"`
+	Contributors  []*Person      `json:"contributors,omitempty"`
+	Authors       []*Person      `json:"authors,omitempty"`
+	Categories    []*Category    `json:"categories,omitempty"`
+	Extensions    ext.Extensions `json:"extensions,omitempty"`
+}

+ 722 - 0
vendor/github.com/mmcdole/gofeed/atom/parser.go

@@ -0,0 +1,722 @@
+package atom
+
+import (
+	"encoding/base64"
+	"io"
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/mmcdole/gofeed/extensions"
+	"github.com/mmcdole/gofeed/internal/shared"
+	"github.com/mmcdole/goxpp"
+)
+
+// Parser is an Atom Parser
+type Parser struct{}
+
+// Parse parses an xml feed into an atom.Feed
+func (ap *Parser) Parse(feed io.Reader) (*Feed, error) {
+	p := xpp.NewXMLPullParser(feed, false, shared.NewReaderLabel)
+
+	_, err := shared.FindRoot(p)
+	if err != nil {
+		return nil, err
+	}
+
+	return ap.parseRoot(p)
+}
+
+func (ap *Parser) parseRoot(p *xpp.XMLPullParser) (*Feed, error) {
+	if err := p.Expect(xpp.StartTag, "feed"); err != nil {
+		return nil, err
+	}
+
+	atom := &Feed{}
+	atom.Entries = []*Entry{}
+	atom.Version = ap.parseVersion(p)
+	atom.Language = ap.parseLanguage(p)
+
+	contributors := []*Person{}
+	authors := []*Person{}
+	categories := []*Category{}
+	links := []*Link{}
+	extensions := ext.Extensions{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+
+			name := strings.ToLower(p.Name)
+
+			if shared.IsExtension(p) {
+				e, err := shared.ParseExtension(extensions, p)
+				if err != nil {
+					return nil, err
+				}
+				extensions = e
+			} else if name == "title" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.Title = result
+			} else if name == "id" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.ID = result
+			} else if name == "updated" ||
+				name == "modified" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.Updated = result
+				date, err := shared.ParseDate(result)
+				if err == nil {
+					utcDate := date.UTC()
+					atom.UpdatedParsed = &utcDate
+				}
+			} else if name == "subtitle" ||
+				name == "tagline" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.Subtitle = result
+			} else if name == "link" {
+				result, err := ap.parseLink(p)
+				if err != nil {
+					return nil, err
+				}
+				links = append(links, result)
+			} else if name == "generator" {
+				result, err := ap.parseGenerator(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.Generator = result
+			} else if name == "icon" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.Icon = result
+			} else if name == "logo" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.Logo = result
+			} else if name == "rights" ||
+				name == "copyright" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.Rights = result
+			} else if name == "contributor" {
+				result, err := ap.parsePerson("contributor", p)
+				if err != nil {
+					return nil, err
+				}
+				contributors = append(contributors, result)
+			} else if name == "author" {
+				result, err := ap.parsePerson("author", p)
+				if err != nil {
+					return nil, err
+				}
+				authors = append(authors, result)
+			} else if name == "category" {
+				result, err := ap.parseCategory(p)
+				if err != nil {
+					return nil, err
+				}
+				categories = append(categories, result)
+			} else if name == "entry" {
+				result, err := ap.parseEntry(p)
+				if err != nil {
+					return nil, err
+				}
+				atom.Entries = append(atom.Entries, result)
+			} else {
+				err := p.Skip()
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
+
+	if len(categories) > 0 {
+		atom.Categories = categories
+	}
+
+	if len(authors) > 0 {
+		atom.Authors = authors
+	}
+
+	if len(contributors) > 0 {
+		atom.Contributors = contributors
+	}
+
+	if len(links) > 0 {
+		atom.Links = links
+	}
+
+	if len(extensions) > 0 {
+		atom.Extensions = extensions
+	}
+
+	if err := p.Expect(xpp.EndTag, "feed"); err != nil {
+		return nil, err
+	}
+
+	return atom, nil
+}
+
+func (ap *Parser) parseEntry(p *xpp.XMLPullParser) (*Entry, error) {
+	if err := p.Expect(xpp.StartTag, "entry"); err != nil {
+		return nil, err
+	}
+	entry := &Entry{}
+
+	contributors := []*Person{}
+	authors := []*Person{}
+	categories := []*Category{}
+	links := []*Link{}
+	extensions := ext.Extensions{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+
+			name := strings.ToLower(p.Name)
+
+			if shared.IsExtension(p) {
+				e, err := shared.ParseExtension(extensions, p)
+				if err != nil {
+					return nil, err
+				}
+				extensions = e
+			} else if name == "title" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				entry.Title = result
+			} else if name == "id" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				entry.ID = result
+			} else if name == "rights" ||
+				name == "copyright" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				entry.Rights = result
+			} else if name == "summary" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				entry.Summary = result
+			} else if name == "source" {
+				result, err := ap.parseSource(p)
+				if err != nil {
+					return nil, err
+				}
+				entry.Source = result
+			} else if name == "updated" ||
+				name == "modified" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				entry.Updated = result
+				date, err := shared.ParseDate(result)
+				if err == nil {
+					utcDate := date.UTC()
+					entry.UpdatedParsed = &utcDate
+				}
+			} else if name == "contributor" {
+				result, err := ap.parsePerson("contributor", p)
+				if err != nil {
+					return nil, err
+				}
+				contributors = append(contributors, result)
+			} else if name == "author" {
+				result, err := ap.parsePerson("author", p)
+				if err != nil {
+					return nil, err
+				}
+				authors = append(authors, result)
+			} else if name == "category" {
+				result, err := ap.parseCategory(p)
+				if err != nil {
+					return nil, err
+				}
+				categories = append(categories, result)
+			} else if name == "link" {
+				result, err := ap.parseLink(p)
+				if err != nil {
+					return nil, err
+				}
+				links = append(links, result)
+			} else if name == "published" ||
+				name == "issued" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				entry.Published = result
+				date, err := shared.ParseDate(result)
+				if err == nil {
+					utcDate := date.UTC()
+					entry.PublishedParsed = &utcDate
+				}
+			} else if name == "content" {
+				result, err := ap.parseContent(p)
+				if err != nil {
+					return nil, err
+				}
+				entry.Content = result
+			} else {
+				err := p.Skip()
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
+
+	if len(categories) > 0 {
+		entry.Categories = categories
+	}
+
+	if len(authors) > 0 {
+		entry.Authors = authors
+	}
+
+	if len(links) > 0 {
+		entry.Links = links
+	}
+
+	if len(contributors) > 0 {
+		entry.Contributors = contributors
+	}
+
+	if len(extensions) > 0 {
+		entry.Extensions = extensions
+	}
+
+	if err := p.Expect(xpp.EndTag, "entry"); err != nil {
+		return nil, err
+	}
+
+	return entry, nil
+}
+
+func (ap *Parser) parseSource(p *xpp.XMLPullParser) (*Source, error) {
+
+	if err := p.Expect(xpp.StartTag, "source"); err != nil {
+		return nil, err
+	}
+
+	source := &Source{}
+
+	contributors := []*Person{}
+	authors := []*Person{}
+	categories := []*Category{}
+	links := []*Link{}
+	extensions := ext.Extensions{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+
+			name := strings.ToLower(p.Name)
+
+			if shared.IsExtension(p) {
+				e, err := shared.ParseExtension(extensions, p)
+				if err != nil {
+					return nil, err
+				}
+				extensions = e
+			} else if name == "title" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				source.Title = result
+			} else if name == "id" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				source.ID = result
+			} else if name == "updated" ||
+				name == "modified" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				source.Updated = result
+				date, err := shared.ParseDate(result)
+				if err == nil {
+					utcDate := date.UTC()
+					source.UpdatedParsed = &utcDate
+				}
+			} else if name == "subtitle" ||
+				name == "tagline" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				source.Subtitle = result
+			} else if name == "link" {
+				result, err := ap.parseLink(p)
+				if err != nil {
+					return nil, err
+				}
+				links = append(links, result)
+			} else if name == "generator" {
+				result, err := ap.parseGenerator(p)
+				if err != nil {
+					return nil, err
+				}
+				source.Generator = result
+			} else if name == "icon" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				source.Icon = result
+			} else if name == "logo" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				source.Logo = result
+			} else if name == "rights" ||
+				name == "copyright" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				source.Rights = result
+			} else if name == "contributor" {
+				result, err := ap.parsePerson("contributor", p)
+				if err != nil {
+					return nil, err
+				}
+				contributors = append(contributors, result)
+			} else if name == "author" {
+				result, err := ap.parsePerson("author", p)
+				if err != nil {
+					return nil, err
+				}
+				authors = append(authors, result)
+			} else if name == "category" {
+				result, err := ap.parseCategory(p)
+				if err != nil {
+					return nil, err
+				}
+				categories = append(categories, result)
+			} else {
+				err := p.Skip()
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
+
+	if len(categories) > 0 {
+		source.Categories = categories
+	}
+
+	if len(authors) > 0 {
+		source.Authors = authors
+	}
+
+	if len(contributors) > 0 {
+		source.Contributors = contributors
+	}
+
+	if len(links) > 0 {
+		source.Links = links
+	}
+
+	if len(extensions) > 0 {
+		source.Extensions = extensions
+	}
+
+	if err := p.Expect(xpp.EndTag, "source"); err != nil {
+		return nil, err
+	}
+
+	return source, nil
+}
+
+func (ap *Parser) parseContent(p *xpp.XMLPullParser) (*Content, error) {
+	c := &Content{}
+	c.Type = p.Attribute("type")
+	c.Src = p.Attribute("src")
+
+	text, err := ap.parseAtomText(p)
+	if err != nil {
+		return nil, err
+	}
+	c.Value = text
+
+	return c, nil
+}
+
+func (ap *Parser) parsePerson(name string, p *xpp.XMLPullParser) (*Person, error) {
+
+	if err := p.Expect(xpp.StartTag, name); err != nil {
+		return nil, err
+	}
+
+	person := &Person{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+
+			name := strings.ToLower(p.Name)
+
+			if name == "name" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				person.Name = result
+			} else if name == "email" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				person.Email = result
+			} else if name == "uri" ||
+				name == "url" ||
+				name == "homepage" {
+				result, err := ap.parseAtomText(p)
+				if err != nil {
+					return nil, err
+				}
+				person.URI = result
+			} else {
+				err := p.Skip()
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
+
+	if err := p.Expect(xpp.EndTag, name); err != nil {
+		return nil, err
+	}
+
+	return person, nil
+}
+
+func (ap *Parser) parseLink(p *xpp.XMLPullParser) (*Link, error) {
+	if err := p.Expect(xpp.StartTag, "link"); err != nil {
+		return nil, err
+	}
+
+	l := &Link{}
+	l.Href = p.Attribute("href")
+	l.Hreflang = p.Attribute("hreflang")
+	l.Type = p.Attribute("type")
+	l.Length = p.Attribute("length")
+	l.Title = p.Attribute("title")
+	l.Rel = p.Attribute("rel")
+	if l.Rel == "" {
+		l.Rel = "alternate"
+	}
+
+	if err := p.Skip(); err != nil {
+		return nil, err
+	}
+
+	if err := p.Expect(xpp.EndTag, "link"); err != nil {
+		return nil, err
+	}
+	return l, nil
+}
+
+func (ap *Parser) parseCategory(p *xpp.XMLPullParser) (*Category, error) {
+	if err := p.Expect(xpp.StartTag, "category"); err != nil {
+		return nil, err
+	}
+
+	c := &Category{}
+	c.Term = p.Attribute("term")
+	c.Scheme = p.Attribute("scheme")
+	c.Label = p.Attribute("label")
+
+	if err := p.Skip(); err != nil {
+		return nil, err
+	}
+
+	if err := p.Expect(xpp.EndTag, "category"); err != nil {
+		return nil, err
+	}
+	return c, nil
+}
+
+func (ap *Parser) parseGenerator(p *xpp.XMLPullParser) (*Generator, error) {
+
+	if err := p.Expect(xpp.StartTag, "generator"); err != nil {
+		return nil, err
+	}
+
+	g := &Generator{}
+
+	uri := p.Attribute("uri") // Atom 1.0
+	url := p.Attribute("url") // Atom 0.3
+
+	if uri != "" {
+		g.URI = uri
+	} else if url != "" {
+		g.URI = url
+	}
+
+	g.Version = p.Attribute("version")
+
+	result, err := ap.parseAtomText(p)
+	if err != nil {
+		return nil, err
+	}
+
+	g.Value = result
+
+	if err := p.Expect(xpp.EndTag, "generator"); err != nil {
+		return nil, err
+	}
+
+	return g, nil
+}
+
+func (ap *Parser) parseAtomText(p *xpp.XMLPullParser) (string, error) {
+
+	var text struct {
+		Type     string `xml:"type,attr"`
+		Mode     string `xml:"mode,attr"`
+		InnerXML string `xml:",innerxml"`
+	}
+
+	err := p.DecodeElement(&text)
+	if err != nil {
+		return "", err
+	}
+
+	result := text.InnerXML
+	result = strings.TrimSpace(result)
+
+	if strings.HasPrefix(result, "<![CDATA[") &&
+		strings.HasSuffix(result, "]]>") {
+		result = strings.TrimPrefix(result, "<![CDATA[")
+		result = strings.TrimSuffix(result, "]]>")
+		return result, nil
+	}
+
+	lowerType := strings.ToLower(text.Type)
+	lowerMode := strings.ToLower(text.Mode)
+
+	if lowerType == "text" ||
+		strings.HasPrefix(lowerType, "text/") ||
+		(lowerType == "" && lowerMode == "") {
+		result, err = shared.DecodeEntities(result)
+	} else if strings.Contains(lowerType, "xhtml") {
+		result = ap.stripWrappingDiv(result)
+	} else if lowerType == "html" {
+		result = ap.stripWrappingDiv(result)
+		result, err = shared.DecodeEntities(result)
+	} else {
+		decodedStr, err := base64.StdEncoding.DecodeString(result)
+		if err == nil {
+			result = string(decodedStr)
+		}
+	}
+
+	return result, err
+}
+
+func (ap *Parser) parseLanguage(p *xpp.XMLPullParser) string {
+	return p.Attribute("lang")
+}
+
+func (ap *Parser) parseVersion(p *xpp.XMLPullParser) string {
+	ver := p.Attribute("version")
+	if ver != "" {
+		return ver
+	}
+
+	ns := p.Attribute("xmlns")
+	if ns == "http://purl.org/atom/ns#" {
+		return "0.3"
+	}
+
+	if ns == "http://www.w3.org/2005/Atom" {
+		return "1.0"
+	}
+
+	return ""
+}
+
+func (ap *Parser) stripWrappingDiv(content string) (result string) {
+	result = content
+	r := strings.NewReader(result)
+	doc, err := goquery.NewDocumentFromReader(r)
+	if err == nil {
+		root := doc.Find("body").Children()
+		if root.Is("div") && root.Siblings().Size() == 0 {
+			html, err := root.Unwrap().Html()
+			if err == nil {
+				result = html
+			}
+		}
+	}
+	return
+}

+ 47 - 0
vendor/github.com/mmcdole/gofeed/detector.go

@@ -0,0 +1,47 @@
+package gofeed
+
+import (
+	"io"
+	"strings"
+
+	"github.com/mmcdole/gofeed/internal/shared"
+	"github.com/mmcdole/goxpp"
+)
+
+// FeedType represents one of the possible feed
+// types that we can detect.
+type FeedType int
+
+const (
+	// FeedTypeUnknown represents a feed that could not have its
+	// type determiend.
+	FeedTypeUnknown FeedType = iota
+	// FeedTypeAtom repesents an Atom feed
+	FeedTypeAtom
+	// FeedTypeRSS represents an RSS feed
+	FeedTypeRSS
+)
+
+// DetectFeedType attempts to determine the type of feed
+// by looking for specific xml elements unique to the
+// various feed types.
+func DetectFeedType(feed io.Reader) FeedType {
+	p := xpp.NewXMLPullParser(feed, false, shared.NewReaderLabel)
+
+	_, err := shared.FindRoot(p)
+	if err != nil {
+		return FeedTypeUnknown
+	}
+
+	name := strings.ToLower(p.Name)
+	switch name {
+	case "rdf":
+		return FeedTypeRSS
+	case "rss":
+		return FeedTypeRSS
+	case "feed":
+		return FeedTypeAtom
+	default:
+		return FeedTypeUnknown
+	}
+}

+ 45 - 0
vendor/github.com/mmcdole/gofeed/extensions/dublincore.go

@@ -0,0 +1,45 @@
+package ext
+
+// DublinCoreExtension represents a feed extension
+// for the Dublin Core specification.
+type DublinCoreExtension struct {
+	Title       []string `json:"title,omitempty"`
+	Creator     []string `json:"creator,omitempty"`
+	Author      []string `json:"author,omitempty"`
+	Subject     []string `json:"subject,omitempty"`
+	Description []string `json:"description,omitempty"`
+	Publisher   []string `json:"publisher,omitempty"`
+	Contributor []string `json:"contributor,omitempty"`
+	Date        []string `json:"date,omitempty"`
+	Type        []string `json:"type,omitempty"`
+	Format      []string `json:"format,omitempty"`
+	Identifier  []string `json:"identifier,omitempty"`
+	Source      []string `json:"source,omitempty"`
+	Language    []string `json:"language,omitempty"`
+	Relation    []string `json:"relation,omitempty"`
+	Coverage    []string `json:"coverage,omitempty"`
+	Rights      []string `json:"rights,omitempty"`
+}
+
+// NewDublinCoreExtension creates a new DublinCoreExtension
+// given the generic extension map for the "dc" prefix.
+func NewDublinCoreExtension(extensions map[string][]Extension) *DublinCoreExtension {
+	dc := &DublinCoreExtension{}
+	dc.Title = parseTextArrayExtension("title", extensions)
+	dc.Creator = parseTextArrayExtension("creator", extensions)
+	dc.Author = parseTextArrayExtension("author", extensions)
+	dc.Subject = parseTextArrayExtension("subject", extensions)
+	dc.Description = parseTextArrayExtension("description", extensions)
+	dc.Publisher = parseTextArrayExtension("publisher", extensions)
+	dc.Contributor = parseTextArrayExtension("contributor", extensions)
+	dc.Date = parseTextArrayExtension("date", extensions)
+	dc.Type = parseTextArrayExtension("type", extensions)
+	dc.Format = parseTextArrayExtension("format", extensions)
+	dc.Identifier = parseTextArrayExtension("identifier", extensions)
+	dc.Source = parseTextArrayExtension("source", extensions)
+	dc.Language = parseTextArrayExtension("language", extensions)
+	dc.Relation = parseTextArrayExtension("relation", extensions)
+	dc.Coverage = parseTextArrayExtension("coverage", extensions)
+	dc.Rights = parseTextArrayExtension("rights", extensions)
+	return dc
+}

+ 46 - 0
vendor/github.com/mmcdole/gofeed/extensions/extensions.go

@@ -0,0 +1,46 @@
+package ext
+
+// Extensions is the generic extension map for Feeds and Items.
+// The first map is for the element namespace prefix (e.g., itunes).
+// The second map is for the element name (e.g., author).
+type Extensions map[string]map[string][]Extension
+
+// Extension represents a single XML element that was in a non
+// default namespace in a Feed or Item/Entry.
+type Extension struct {
+	Name     string                 `json:"name"`
+	Value    string                 `json:"value"`
+	Attrs    map[string]string      `json:"attrs"`
+	Children map[string][]Extension `json:"children"`
+}
+
+func parseTextExtension(name string, extensions map[string][]Extension) (value string) {
+	if extensions == nil {
+		return
+	}
+
+	matches, ok := extensions[name]
+	if !ok || len(matches) == 0 {
+		return
+	}
+
+	match := matches[0]
+	return match.Value
+}
+
+func parseTextArrayExtension(name string, extensions map[string][]Extension) (values []string) {
+	if extensions == nil {
+		return
+	}
+
+	matches, ok := extensions[name]
+	if !ok || len(matches) == 0 {
+		return
+	}
+
+	values = []string{}
+	for _, m := range matches {
+		values = append(values, m.Value)
+	}
+	return
+}

+ 141 - 0
vendor/github.com/mmcdole/gofeed/extensions/itunes.go

@@ -0,0 +1,141 @@
+package ext
+
+// ITunesFeedExtension is a set of extension
+// fields for RSS feeds.
+type ITunesFeedExtension struct {
+	Author     string            `json:"author,omitempty"`
+	Block      string            `json:"block,omitempty"`
+	Categories []*ITunesCategory `json:"categories,omitempty"`
+	Explicit   string            `json:"explicit,omitempty"`
+	Keywords   string            `json:"keywords,omitempty"`
+	Owner      *ITunesOwner      `json:"owner,omitempty"`
+	Subtitle   string            `json:"subtitle,omitempty"`
+	Summary    string            `json:"summary,omitempty"`
+	Image      string            `json:"image,omitempty"`
+	Complete   string            `json:"complete,omitempty"`
+	NewFeedURL string            `json:"newFeedUrl,omitempty"`
+}
+
+// ITunesItemExtension is a set of extension
+// fields for RSS items.
+type ITunesItemExtension struct {
+	Author            string `json:"author,omitempty"`
+	Block             string `json:"block,omitempty"`
+	Duration          string `json:"duration,omitempty"`
+	Explicit          string `json:"explicit,omitempty"`
+	Keywords          string `json:"keywords,omitempty"`
+	Subtitle          string `json:"subtitle,omitempty"`
+	Summary           string `json:"summary,omitempty"`
+	Image             string `json:"image,omitempty"`
+	IsClosedCaptioned string `json:"isClosedCaptioned,omitempty"`
+	Order             string `json:"order,omitempty"`
+}
+
+// ITunesCategory is a category element for itunes feeds.
+type ITunesCategory struct {
+	Text        string          `json:"text,omitempty"`
+	Subcategory *ITunesCategory `json:"subcategory,omitempty"`
+}
+
+// ITunesOwner is the owner of a particular itunes feed.
+type ITunesOwner struct {
+	Email string `json:"email,omitempty"`
+	Name  string `json:"name,omitempty"`
+}
+
+// NewITunesFeedExtension creates an ITunesFeedExtension given an
+// extension map for the "itunes" key.
+func NewITunesFeedExtension(extensions map[string][]Extension) *ITunesFeedExtension {
+	feed := &ITunesFeedExtension{}
+	feed.Author = parseTextExtension("author", extensions)
+	feed.Block = parseTextExtension("block", extensions)
+	feed.Explicit = parseTextExtension("explicit", extensions)
+	feed.Keywords = parseTextExtension("keywords", extensions)
+	feed.Subtitle = parseTextExtension("subtitle", extensions)
+	feed.Summary = parseTextExtension("summary", extensions)
+	feed.Image = parseImage(extensions)
+	feed.Complete = parseTextExtension("complete", extensions)
+	feed.NewFeedURL = parseTextExtension("new-feed-url", extensions)
+	feed.Categories = parseCategories(extensions)
+	feed.Owner = parseOwner(extensions)
+	return feed
+}
+
+// NewITunesItemExtension creates an ITunesItemExtension given an
+// extension map for the "itunes" key.
+func NewITunesItemExtension(extensions map[string][]Extension) *ITunesItemExtension {
+	entry := &ITunesItemExtension{}
+	entry.Author = parseTextExtension("author", extensions)
+	entry.Block = parseTextExtension("block", extensions)
+	entry.Duration = parseTextExtension("duration", extensions)
+	entry.Explicit = parseTextExtension("explicit", extensions)
+	entry.Subtitle = parseTextExtension("subtitle", extensions)
+	entry.Summary = parseTextExtension("summary", extensions)
+	entry.Image = parseImage(extensions)
+	entry.IsClosedCaptioned = parseTextExtension("isClosedCaptioned", extensions)
+	entry.Order = parseTextExtension("order", extensions)
+	return entry
+}
+
+func parseImage(extensions map[string][]Extension) (image string) {
+	if extensions == nil {
+		return
+	}
+
+	matches, ok := extensions["image"]
+	if !ok || len(matches) == 0 {
+		return
+	}
+
+	image = matches[0].Attrs["href"]
+	return
+}
+
+func parseOwner(extensions map[string][]Extension) (owner *ITunesOwner) {
+	if extensions == nil {
+		return
+	}
+
+	matches, ok := extensions["owner"]
+	if !ok || len(matches) == 0 {
+		return
+	}
+
+	owner = &ITunesOwner{}
+	if name, ok := matches[0].Children["name"]; ok {
+		owner.Name = name[0].Value
+	}
+	if email, ok := matches[0].Children["email"]; ok {
+		owner.Email = email[0].Value
+	}
+	return
+}
+
+func parseCategories(extensions map[string][]Extension) (categories []*ITunesCategory) {
+	if extensions == nil {
+		return
+	}
+
+	matches, ok := extensions["category"]
+	if !ok || len(matches) == 0 {
+		return
+	}
+
+	categories = []*ITunesCategory{}
+	for _, cat := range matches {
+		c := &ITunesCategory{}
+		if text, ok := cat.Attrs["text"]; ok {
+			c.Text = text
+		}
+
+		if subs, ok := cat.Children["category"]; ok {
+			s := &ITunesCategory{}
+			if text, ok := subs[0].Attrs["text"]; ok {
+				s.Text = text
+			}
+			c.Subcategory = s
+		}
+		categories = append(categories, c)
+	}
+	return
+}

+ 84 - 0
vendor/github.com/mmcdole/gofeed/feed.go

@@ -0,0 +1,84 @@
+package gofeed
+
+import (
+	"encoding/json"
+	"time"
+
+	"github.com/mmcdole/gofeed/extensions"
+)
+
+// Feed is the universal Feed type that atom.Feed
+// and rss.Feed gets translated to. It represents
+// a web feed.
+type Feed struct {
+	Title           string                   `json:"title,omitempty"`
+	Description     string                   `json:"description,omitempty"`
+	Link            string                   `json:"link,omitempty"`
+	FeedLink        string                   `json:"feedLink,omitempty"`
+	Updated         string                   `json:"updated,omitempty"`
+	UpdatedParsed   *time.Time               `json:"updatedParsed,omitempty"`
+	Published       string                   `json:"published,omitempty"`
+	PublishedParsed *time.Time               `json:"publishedParsed,omitempty"`
+	Author          *Person                  `json:"author,omitempty"`
+	Language        string                   `json:"language,omitempty"`
+	Image           *Image                   `json:"image,omitempty"`
+	Copyright       string                   `json:"copyright,omitempty"`
+	Generator       string                   `json:"generator,omitempty"`
+	Categories      []string                 `json:"categories,omitempty"`
+	DublinCoreExt   *ext.DublinCoreExtension `json:"dcExt,omitempty"`
+	ITunesExt       *ext.ITunesFeedExtension `json:"itunesExt,omitempty"`
+	Extensions      ext.Extensions           `json:"extensions,omitempty"`
+	Custom          map[string]string        `json:"custom,omitempty"`
+	Items           []*Item                  `json:"items"`
+	FeedType        string                   `json:"feedType"`
+	FeedVersion     string                   `json:"feedVersion"`
+}
+
+func (f Feed) String() string {
+	json, _ := json.MarshalIndent(f, "", "    ")
+	return string(json)
+}
+
+// Item is the universal Item type that atom.Entry
+// and rss.Item gets translated to.  It represents
+// a single entry in a given feed.
+type Item struct {
+	Title           string                   `json:"title,omitempty"`
+	Description     string                   `json:"description,omitempty"`
+	Content         string                   `json:"content,omitempty"`
+	Link            string                   `json:"link,omitempty"`
+	Updated         string                   `json:"updated,omitempty"`
+	UpdatedParsed   *time.Time               `json:"updatedParsed,omitempty"`
+	Published       string                   `json:"published,omitempty"`
+	PublishedParsed *time.Time               `json:"publishedParsed,omitempty"`
+	Author          *Person                  `json:"author,omitempty"`
+	GUID            string                   `json:"guid,omitempty"`
+	Image           *Image                   `json:"image,omitempty"`
+	Categories      []string                 `json:"categories,omitempty"`
+	Enclosures      []*Enclosure             `json:"enclosures,omitempty"`
+	DublinCoreExt   *ext.DublinCoreExtension `json:"dcExt,omitempty"`
+	ITunesExt       *ext.ITunesItemExtension `json:"itunesExt,omitempty"`
+	Extensions      ext.Extensions           `json:"extensions,omitempty"`
+	Custom          map[string]string        `json:"custom,omitempty"`
+}
+
+// Person is an individual specified in a feed
+// (e.g. an author)
+type Person struct {
+	Name  string `json:"name,omitempty"`
+	Email string `json:"email,omitempty"`
+}
+
+// Image is an image that is the artwork for a given
+// feed or item.
+type Image struct {
+	URL   string `json:"url,omitempty"`
+	Title string `json:"title,omitempty"`
+}
+
+// Enclosure is a file associated with a given Item.
+type Enclosure struct {
+	URL    string `json:"url,omitempty"`
+	Length string `json:"length,omitempty"`
+	Type   string `json:"type,omitempty"`
+}

+ 19 - 0
vendor/github.com/mmcdole/gofeed/internal/shared/charsetconv.go

@@ -0,0 +1,19 @@
+package shared
+
+import (
+	"io"
+
+	"golang.org/x/net/html/charset"
+)
+
+func NewReaderLabel(label string, input io.Reader) (io.Reader, error) {
+	conv, err := charset.NewReaderLabel(label, input)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Wrap the charset decoder reader with a XML sanitizer
+	//clean := NewXMLSanitizerReader(conv)
+	return conv, nil
+}

+ 196 - 0
vendor/github.com/mmcdole/gofeed/internal/shared/dateparser.go

@@ -0,0 +1,196 @@
+package shared
+
+import (
+	"fmt"
+	"strings"
+	"time"
+)
+
+// DateFormats taken from github.com/mjibson/goread
+var dateFormats = []string{
+	time.RFC822,  // RSS
+	time.RFC822Z, // RSS
+	time.RFC3339, // Atom
+	time.UnixDate,
+	time.RubyDate,
+	time.RFC850,
+	time.RFC1123Z,
+	time.RFC1123,
+	time.ANSIC,
+	"Mon, January 2 2006 15:04:05 -0700",
+	"Mon, January 02, 2006, 15:04:05 MST",
+	"Mon, January 02, 2006 15:04:05 MST",
+	"Mon, Jan 2, 2006 15:04 MST",
+	"Mon, Jan 2 2006 15:04 MST",
+	"Mon, Jan 2, 2006 15:04:05 MST",
+	"Mon, Jan 2 2006 15:04:05 -700",
+	"Mon, Jan 2 2006 15:04:05 -0700",
+	"Mon Jan 2 15:04 2006",
+	"Mon Jan 2 15:04:05 2006 MST",
+	"Mon Jan 02, 2006 3:04 pm",
+	"Mon, Jan 02,2006 15:04:05 MST",
+	"Mon Jan 02 2006 15:04:05 -0700",
+	"Monday, January 2, 2006 15:04:05 MST",
+	"Monday, January 2, 2006 03:04 PM",
+	"Monday, January 2, 2006",
+	"Monday, January 02, 2006",
+	"Monday, 2 January 2006 15:04:05 MST",
+	"Monday, 2 January 2006 15:04:05 -0700",
+	"Monday, 2 Jan 2006 15:04:05 MST",
+	"Monday, 2 Jan 2006 15:04:05 -0700",
+	"Monday, 02 January 2006 15:04:05 MST",
+	"Monday, 02 January 2006 15:04:05 -0700",
+	"Monday, 02 January 2006 15:04:05",
+	"Mon, 2 January 2006 15:04 MST",
+	"Mon, 2 January 2006, 15:04 -0700",
+	"Mon, 2 January 2006, 15:04:05 MST",
+	"Mon, 2 January 2006 15:04:05 MST",
+	"Mon, 2 January 2006 15:04:05 -0700",
+	"Mon, 2 January 2006",
+	"Mon, 2 Jan 2006 3:04:05 PM -0700",
+	"Mon, 2 Jan 2006 15:4:5 MST",
+	"Mon, 2 Jan 2006 15:4:5 -0700 GMT",
+	"Mon, 2, Jan 2006 15:4",
+	"Mon, 2 Jan 2006 15:04 MST",
+	"Mon, 2 Jan 2006, 15:04 -0700",
+	"Mon, 2 Jan 2006 15:04 -0700",
+	"Mon, 2 Jan 2006 15:04:05 UT",
+	"Mon, 2 Jan 2006 15:04:05MST",
+	"Mon, 2 Jan 2006 15:04:05 MST",
+	"Mon 2 Jan 2006 15:04:05 MST",
+	"mon,2 Jan 2006 15:04:05 MST",
+	"Mon, 2 Jan 2006 15:04:05 -0700 MST",
+	"Mon, 2 Jan 2006 15:04:05-0700",
+	"Mon, 2 Jan 2006 15:04:05 -0700",
+	"Mon, 2 Jan 2006 15:04:05",
+	"Mon, 2 Jan 2006 15:04",
+	"Mon,2 Jan 2006",
+	"Mon, 2 Jan 2006",
+	"Mon, 2 Jan 15:04:05 MST",
+	"Mon, 2 Jan 06 15:04:05 MST",
+	"Mon, 2 Jan 06 15:04:05 -0700",
+	"Mon, 2006-01-02 15:04",
+	"Mon,02 January 2006 14:04:05 MST",
+	"Mon, 02 January 2006",
+	"Mon, 02 Jan 2006 3:04:05 PM MST",
+	"Mon, 02 Jan 2006 15 -0700",
+	"Mon,02 Jan 2006 15:04 MST",
+	"Mon, 02 Jan 2006 15:04 MST",
+	"Mon, 02 Jan 2006 15:04 -0700",
+	"Mon, 02 Jan 2006 15:04:05 Z",
+	"Mon, 02 Jan 2006 15:04:05 UT",
+	"Mon, 02 Jan 2006 15:04:05 MST-07:00",
+	"Mon, 02 Jan 2006 15:04:05 MST -0700",
+	"Mon, 02 Jan 2006, 15:04:05 MST",
+	"Mon, 02 Jan 2006 15:04:05MST",
+	"Mon, 02 Jan 2006 15:04:05 MST",
+	"Mon , 02 Jan 2006 15:04:05 MST",
+	"Mon, 02 Jan 2006 15:04:05 GMT-0700",
+	"Mon,02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -07:00",
+	"Mon, 02 Jan 2006 15:04:05 --0700",
+	"Mon 02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -07",
+	"Mon, 02 Jan 2006 15:04:05 00",
+	"Mon, 02 Jan 2006 15:04:05",
+	"Mon, 02 Jan 2006",
+	"Mon, 02 Jan 06 15:04:05 MST",
+	"January 2, 2006 3:04 PM",
+	"January 2, 2006, 3:04 p.m.",
+	"January 2, 2006 15:04:05 MST",
+	"January 2, 2006 15:04:05",
+	"January 2, 2006 03:04 PM",
+	"January 2, 2006",
+	"January 02, 2006 15:04:05 MST",
+	"January 02, 2006 15:04",
+	"January 02, 2006 03:04 PM",
+	"January 02, 2006",
+	"Jan 2, 2006 3:04:05 PM MST",
+	"Jan 2, 2006 3:04:05 PM",
+	"Jan 2, 2006 15:04:05 MST",
+	"Jan 2, 2006",
+	"Jan 02 2006 03:04:05PM",
+	"Jan 02, 2006",
+	"6/1/2 15:04",
+	"6-1-2 15:04",
+	"2 January 2006 15:04:05 MST",
+	"2 January 2006 15:04:05 -0700",
+	"2 January 2006",
+	"2 Jan 2006 15:04:05 Z",
+	"2 Jan 2006 15:04:05 MST",
+	"2 Jan 2006 15:04:05 -0700",
+	"2 Jan 2006",
+	"2.1.2006 15:04:05",
+	"2/1/2006",
+	"2-1-2006",
+	"2006 January 02",
+	"2006-1-2T15:04:05Z",
+	"2006-1-2 15:04:05",
+	"2006-1-2",
+	"2006-1-02T15:04:05Z",
+	"2006-01-02T15:04Z",
+	"2006-01-02T15:04-07:00",
+	"2006-01-02T15:04:05Z",
+	"2006-01-02T15:04:05-07:00:00",
+	"2006-01-02T15:04:05:-0700",
+	"2006-01-02T15:04:05-0700",
+	"2006-01-02T15:04:05-07:00",
+	"2006-01-02T15:04:05 -0700",
+	"2006-01-02T15:04:05:00",
+	"2006-01-02T15:04:05",
+	"2006-01-02 at 15:04:05",
+	"2006-01-02 15:04:05Z",
+	"2006-01-02 15:04:05 MST",
+	"2006-01-02 15:04:05-0700",
+	"2006-01-02 15:04:05-07:00",
+	"2006-01-02 15:04:05 -0700",
+	"2006-01-02 15:04",
+	"2006-01-02 00:00:00.0 15:04:05.0 -0700",
+	"2006/01/02",
+	"2006-01-02",
+	"15:04 02.01.2006 -0700",
+	"1/2/2006 3:04:05 PM MST",
+	"1/2/2006 3:04:05 PM",
+	"1/2/2006 15:04:05 MST",
+	"1/2/2006",
+	"06/1/2 15:04",
+	"06-1-2 15:04",
+	"02 Monday, Jan 2006 15:04",
+	"02 Jan 2006 15:04 MST",
+	"02 Jan 2006 15:04:05 UT",
+	"02 Jan 2006 15:04:05 MST",
+	"02 Jan 2006 15:04:05 -0700",
+	"02 Jan 2006 15:04:05",
+	"02 Jan 2006",
+	"02/01/2006 15:04 MST",
+	"02-01-2006 15:04:05 MST",
+	"02.01.2006 15:04:05",
+	"02/01/2006 15:04:05",
+	"02.01.2006 15:04",
+	"02/01/2006 - 15:04",
+	"02.01.2006 -0700",
+	"02/01/2006",
+	"02-01-2006",
+	"01/02/2006 3:04 PM",
+	"01/02/2006 15:04:05 MST",
+	"01/02/2006 - 15:04",
+	"01/02/2006",
+	"01-02-2006",
+}
+
+// ParseDate parses a given date string using a large
+// list of commonly found feed date formats.
+func ParseDate(ds string) (t time.Time, err error) {
+	d := strings.TrimSpace(ds)
+	if d == "" {
+		return t, fmt.Errorf("Date string is empty")
+	}
+	for _, f := range dateFormats {
+		if t, err = time.Parse(f, d); err == nil {
+			return
+		}
+	}
+	err = fmt.Errorf("Failed to parse date: %s", ds)
+	return
+}

+ 176 - 0
vendor/github.com/mmcdole/gofeed/internal/shared/extparser.go

@@ -0,0 +1,176 @@
+package shared
+
+import (
+	"strings"
+
+	"github.com/mmcdole/gofeed/extensions"
+	"github.com/mmcdole/goxpp"
+)
+
+// IsExtension returns whether or not the current
+// XML element is an extension element (if it has a
+// non empty prefix)
+func IsExtension(p *xpp.XMLPullParser) bool {
+	space := strings.TrimSpace(p.Space)
+	if prefix, ok := p.Spaces[space]; ok {
+		return !(prefix == "" || prefix == "rss" || prefix == "rdf" || prefix == "content")
+	}
+
+	return p.Space != ""
+}
+
+// ParseExtension parses the current element of the
+// XMLPullParser as an extension element and updates
+// the extension map
+func ParseExtension(fe ext.Extensions, p *xpp.XMLPullParser) (ext.Extensions, error) {
+	prefix := prefixForNamespace(p.Space, p)
+
+	result, err := parseExtensionElement(p)
+	if err != nil {
+		return nil, err
+	}
+
+	// Ensure the extension prefix map exists
+	if _, ok := fe[prefix]; !ok {
+		fe[prefix] = map[string][]ext.Extension{}
+	}
+	// Ensure the extension element slice exists
+	if _, ok := fe[prefix][p.Name]; !ok {
+		fe[prefix][p.Name] = []ext.Extension{}
+	}
+
+	fe[prefix][p.Name] = append(fe[prefix][p.Name], result)
+	return fe, nil
+}
+
+func parseExtensionElement(p *xpp.XMLPullParser) (e ext.Extension, err error) {
+	if err = p.Expect(xpp.StartTag, "*"); err != nil {
+		return e, err
+	}
+
+	e.Name = p.Name
+	e.Children = map[string][]ext.Extension{}
+	e.Attrs = map[string]string{}
+
+	for _, attr := range p.Attrs {
+		// TODO: Alright that we are stripping
+		// namespace information from attributes ?
+		e.Attrs[attr.Name.Local] = attr.Value
+	}
+
+	for {
+		tok, err := p.Next()
+		if err != nil {
+			return e, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+			child, err := parseExtensionElement(p)
+			if err != nil {
+				return e, err
+			}
+
+			if _, ok := e.Children[child.Name]; !ok {
+				e.Children[child.Name] = []ext.Extension{}
+			}
+
+			e.Children[child.Name] = append(e.Children[child.Name], child)
+		} else if tok == xpp.Text {
+			e.Value += p.Text
+		}
+	}
+
+	e.Value = strings.TrimSpace(e.Value)
+
+	if err = p.Expect(xpp.EndTag, e.Name); err != nil {
+		return e, err
+	}
+
+	return e, nil
+}
+
+func prefixForNamespace(space string, p *xpp.XMLPullParser) string {
+	// First we check if the global namespace map
+	// contains an entry for this namespace/prefix.
+	// This way we can use the canonical prefix for this
+	// ns instead of the one defined in the feed.
+	if prefix, ok := canonicalNamespaces[space]; ok {
+		return prefix
+	}
+
+	// Next we check if the feed itself defined this
+	// this namespace and return it if we have a result.
+	if prefix, ok := p.Spaces[space]; ok {
+		return prefix
+	}
+
+	// Lastly, any namespace which is not defined in the
+	// the feed will be the prefix itself when using Go's
+	// xml.Decoder.Token() method.
+	return space
+}
+
+// Namespaces taken from github.com/kurtmckee/feedparser
+// These are used for determining canonical name space prefixes
+// for many of the popular RSS/Atom extensions.
+//
+// These canonical prefixes override any prefixes used in the feed itself.
+var canonicalNamespaces = map[string]string{
+	"http://webns.net/mvcb/":                                         "admin",
+	"http://purl.org/rss/1.0/modules/aggregation/":                   "ag",
+	"http://purl.org/rss/1.0/modules/annotate/":                      "annotate",
+	"http://media.tangent.org/rss/1.0/":                              "audio",
+	"http://backend.userland.com/blogChannelModule":                  "blogChannel",
+	"http://creativecommons.org/ns#license":                          "cc",
+	"http://web.resource.org/cc/":                                    "cc",
+	"http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html": "creativeCommons",
+	"http://backend.userland.com/creativeCommonsRssModule":           "creativeCommons",
+	"http://purl.org/rss/1.0/modules/company":                        "co",
+	"http://purl.org/rss/1.0/modules/content/":                       "content",
+	"http://my.theinfo.org/changed/1.0/rss/":                         "cp",
+	"http://purl.org/dc/elements/1.1/":                               "dc",
+	"http://purl.org/dc/terms/":                                      "dcterms",
+	"http://purl.org/rss/1.0/modules/email/":                         "email",
+	"http://purl.org/rss/1.0/modules/event/":                         "ev",
+	"http://rssnamespace.org/feedburner/ext/1.0":                     "feedburner",
+	"http://freshmeat.net/rss/fm/":                                   "fm",
+	"http://xmlns.com/foaf/0.1/":                                     "foaf",
+	"http://www.w3.org/2003/01/geo/wgs84_pos#":                       "geo",
+	"http://www.georss.org/georss":                                   "georss",
+	"http://www.opengis.net/gml":                                     "gml",
+	"http://postneo.com/icbm/":                                       "icbm",
+	"http://purl.org/rss/1.0/modules/image/":                         "image",
+	"http://www.itunes.com/DTDs/PodCast-1.0.dtd":                     "itunes",
+	"http://example.com/DTDs/PodCast-1.0.dtd":                        "itunes",
+	"http://purl.org/rss/1.0/modules/link/":                          "l",
+	"http://search.yahoo.com/mrss":                                   "media",
+	"http://search.yahoo.com/mrss/":                                  "media",
+	"http://madskills.com/public/xml/rss/module/pingback/":           "pingback",
+	"http://prismstandard.org/namespaces/1.2/basic/":                 "prism",
+	"http://www.w3.org/1999/02/22-rdf-syntax-ns#":                    "rdf",
+	"http://www.w3.org/2000/01/rdf-schema#":                          "rdfs",
+	"http://purl.org/rss/1.0/modules/reference/":                     "ref",
+	"http://purl.org/rss/1.0/modules/richequiv/":                     "reqv",
+	"http://purl.org/rss/1.0/modules/search/":                        "search",
+	"http://purl.org/rss/1.0/modules/slash/":                         "slash",
+	"http://schemas.xmlsoap.org/soap/envelope/":                      "soap",
+	"http://purl.org/rss/1.0/modules/servicestatus/":                 "ss",
+	"http://hacks.benhammersley.com/rss/streaming/":                  "str",
+	"http://purl.org/rss/1.0/modules/subscription/":                  "sub",
+	"http://purl.org/rss/1.0/modules/syndication/":                   "sy",
+	"http://schemas.pocketsoap.com/rss/myDescModule/":                "szf",
+	"http://purl.org/rss/1.0/modules/taxonomy/":                      "taxo",
+	"http://purl.org/rss/1.0/modules/threading/":                     "thr",
+	"http://purl.org/rss/1.0/modules/textinput/":                     "ti",
+	"http://madskills.com/public/xml/rss/module/trackback/":          "trackback",
+	"http://wellformedweb.org/commentAPI/":                           "wfw",
+	"http://purl.org/rss/1.0/modules/wiki/":                          "wiki",
+	"http://www.w3.org/1999/xhtml":                                   "xhtml",
+	"http://www.w3.org/1999/xlink":                                   "xlink",
+	"http://www.w3.org/XML/1998/namespace":                           "xml",
+	"http://podlove.org/simple-chapters":                             "psc",
+}

+ 196 - 0
vendor/github.com/mmcdole/gofeed/internal/shared/parseutils.go

@@ -0,0 +1,196 @@
+package shared
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/mmcdole/goxpp"
+)
+
+var (
+	emailNameRgx = regexp.MustCompile(`^([^@]+@[^\s]+)\s+\(([^@]+)\)$`)
+	nameEmailRgx = regexp.MustCompile(`^([^@]+)\s+\(([^@]+@[^)]+)\)$`)
+	nameOnlyRgx  = regexp.MustCompile(`^([^@()]+)$`)
+	emailOnlyRgx = regexp.MustCompile(`^([^@()]+@[^@()]+)$`)
+
+	TruncatedEntity         = errors.New("truncated entity")
+	InvalidNumericReference = errors.New("invalid numeric reference")
+)
+
+// FindRoot iterates through the tokens of an xml document until
+// it encounters its first StartTag event.  It returns an error
+// if it reaches EndDocument before finding a tag.
+func FindRoot(p *xpp.XMLPullParser) (event xpp.XMLEventType, err error) {
+	for {
+		event, err = p.Next()
+		if err != nil {
+			return event, err
+		}
+		if event == xpp.StartTag {
+			break
+		}
+
+		if event == xpp.EndDocument {
+			return event, fmt.Errorf("Failed to find root node before document end.")
+		}
+	}
+	return
+}
+
+// NextTag iterates through the tokens until it reaches a StartTag or EndTag
+// It is similar to goxpp's NextTag method except it wont throw an error if
+// the next immediate token isnt a Start/EndTag.  Instead, it will continue to
+// consume tokens until it hits a Start/EndTag or EndDocument.
+func NextTag(p *xpp.XMLPullParser) (event xpp.XMLEventType, err error) {
+	for {
+		event, err = p.Next()
+		if err != nil {
+			return event, err
+		}
+
+		if event == xpp.StartTag || event == xpp.EndTag {
+			break
+		}
+
+		if event == xpp.EndDocument {
+			return event, fmt.Errorf("Failed to find NextTag before reaching the end of the document.")
+		}
+
+	}
+	return
+}
+
+// ParseText is a helper function for parsing the text
+// from the current element of the XMLPullParser.
+// This function can handle parsing naked XML text from
+// an element.
+func ParseText(p *xpp.XMLPullParser) (string, error) {
+	var text struct {
+		Type     string `xml:"type,attr"`
+		InnerXML string `xml:",innerxml"`
+	}
+
+	err := p.DecodeElement(&text)
+	if err != nil {
+		return "", err
+	}
+
+	result := text.InnerXML
+	result = strings.TrimSpace(result)
+
+	if strings.HasPrefix(result, "<![CDATA[") &&
+		strings.HasSuffix(result, "]]>") {
+		result = strings.TrimPrefix(result, "<![CDATA[")
+		result = strings.TrimSuffix(result, "]]>")
+		return result, nil
+	}
+
+	return DecodeEntities(result)
+}
+
+// DecodeEntities decodes escaped XML entities
+// in a string and returns the unescaped string
+func DecodeEntities(str string) (string, error) {
+	data := []byte(str)
+	buf := bytes.NewBuffer([]byte{})
+
+	for len(data) > 0 {
+		// Find the next entity
+		idx := bytes.IndexByte(data, '&')
+		if idx == -1 {
+			buf.Write(data)
+			break
+		}
+
+		// Write and skip everything before it
+		buf.Write(data[:idx])
+		data = data[idx+1:]
+
+		if len(data) == 0 {
+			return "", TruncatedEntity
+		}
+
+		// Find the end of the entity
+		end := bytes.IndexByte(data, ';')
+		if end == -1 {
+			return "", TruncatedEntity
+		}
+
+		if data[0] == '#' {
+			// Numerical character reference
+			var str string
+			base := 10
+
+			if len(data) > 1 && data[1] == 'x' {
+				str = string(data[2:end])
+				base = 16
+			} else {
+				str = string(data[1:end])
+			}
+
+			i, err := strconv.ParseUint(str, base, 32)
+			if err != nil {
+				return "", InvalidNumericReference
+			}
+
+			buf.WriteRune(rune(i))
+		} else {
+			// Predefined entity
+			name := string(data[:end])
+
+			var c byte
+			switch name {
+			case "lt":
+				c = '<'
+			case "gt":
+				c = '>'
+			case "quot":
+				c = '"'
+			case "apos":
+				c = '\''
+			case "amp":
+				c = '&'
+			default:
+				return "", fmt.Errorf("unknown predefined "+
+					"entity &%s;", name)
+			}
+
+			buf.WriteByte(c)
+		}
+
+		// Skip the entity
+		data = data[end+1:]
+	}
+
+	return buf.String(), nil
+}
+
+// ParseNameAddress parses name/email strings commonly
+// found in RSS feeds of the format "Example Name (example@site.com)"
+// and other variations of this format.
+func ParseNameAddress(nameAddressText string) (name string, address string) {
+	if nameAddressText == "" {
+		return
+	}
+
+	if emailNameRgx.MatchString(nameAddressText) {
+		result := emailNameRgx.FindStringSubmatch(nameAddressText)
+		address = result[1]
+		name = result[2]
+	} else if nameEmailRgx.MatchString(nameAddressText) {
+		result := nameEmailRgx.FindStringSubmatch(nameAddressText)
+		name = result[1]
+		address = result[2]
+	} else if nameOnlyRgx.MatchString(nameAddressText) {
+		result := nameOnlyRgx.FindStringSubmatch(nameAddressText)
+		name = result[1]
+	} else if emailOnlyRgx.MatchString(nameAddressText) {
+		result := emailOnlyRgx.FindStringSubmatch(nameAddressText)
+		address = result[1]
+	}
+	return
+}

+ 23 - 0
vendor/github.com/mmcdole/gofeed/internal/shared/xmlsanitizer.go

@@ -0,0 +1,23 @@
+package shared
+
+import (
+	"io"
+
+	"golang.org/x/text/transform"
+)
+
+// NewXMLSanitizerReader creates an io.Reader that
+// wraps another io.Reader and removes illegal xml
+// characters from the io stream.
+func NewXMLSanitizerReader(xml io.Reader) io.Reader {
+	isIllegal := func(r rune) bool {
+		return !(r == 0x09 ||
+			r == 0x0A ||
+			r == 0x0D ||
+			r >= 0x20 && r <= 0xDF77 ||
+			r >= 0xE000 && r <= 0xFFFD ||
+			r >= 0x10000 && r <= 0x10FFFF)
+	}
+	t := transform.Chain(transform.RemoveFunc(isIllegal))
+	return transform.NewReader(xml, t)
+}

+ 145 - 0
vendor/github.com/mmcdole/gofeed/parser.go

@@ -0,0 +1,145 @@
+package gofeed
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+
+	"github.com/mmcdole/gofeed/atom"
+	"github.com/mmcdole/gofeed/rss"
+)
+
+// HTTPError represents an HTTP error returned by a server.
+type HTTPError struct {
+	StatusCode int
+	Status     string
+}
+
+func (err HTTPError) Error() string {
+	return fmt.Sprintf("http error: %s", err.Status)
+}
+
+// Parser is a universal feed parser that detects
+// a given feed type, parsers it, and translates it
+// to the universal feed type.
+type Parser struct {
+	AtomTranslator Translator
+	RSSTranslator  Translator
+	Client         *http.Client
+	rp             *rss.Parser
+	ap             *atom.Parser
+}
+
+// NewParser creates a universal feed parser.
+func NewParser() *Parser {
+	fp := Parser{
+		rp: &rss.Parser{},
+		ap: &atom.Parser{},
+	}
+	return &fp
+}
+
+// Parse parses a RSS or Atom feed into
+// the universal gofeed.Feed.  It takes an
+// io.Reader which should return the xml content.
+func (f *Parser) Parse(feed io.Reader) (*Feed, error) {
+	// Wrap the feed io.Reader in a io.TeeReader
+	// so we can capture all the bytes read by the
+	// DetectFeedType function and construct a new
+	// reader with those bytes intact for when we
+	// attempt to parse the feeds.
+	var buf bytes.Buffer
+	tee := io.TeeReader(feed, &buf)
+	feedType := DetectFeedType(tee)
+
+	// Glue the read bytes from the detect function
+	// back into a new reader
+	r := io.MultiReader(&buf, feed)
+
+	switch feedType {
+	case FeedTypeAtom:
+		return f.parseAtomFeed(r)
+	case FeedTypeRSS:
+		return f.parseRSSFeed(r)
+	}
+	return nil, errors.New("Failed to detect feed type")
+}
+
+// ParseURL fetches the contents of a given url and
+// attempts to parse the response into the universal feed type.
+func (f *Parser) ParseURL(feedURL string) (feed *Feed, err error) {
+	client := f.httpClient()
+	resp, err := client.Get(feedURL)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if resp != nil {
+		defer func() {
+			ce := resp.Body.Close()
+			if ce != nil {
+				err = ce
+			}
+		}()
+	}
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, HTTPError{
+			StatusCode: resp.StatusCode,
+			Status:     resp.Status,
+		}
+	}
+
+	return f.Parse(resp.Body)
+}
+
+// ParseString parses a feed XML string and into the
+// universal feed type.
+func (f *Parser) ParseString(feed string) (*Feed, error) {
+	return f.Parse(strings.NewReader(feed))
+}
+
+func (f *Parser) parseAtomFeed(feed io.Reader) (*Feed, error) {
+	af, err := f.ap.Parse(feed)
+	if err != nil {
+		return nil, err
+	}
+	return f.atomTrans().Translate(af)
+}
+
+func (f *Parser) parseRSSFeed(feed io.Reader) (*Feed, error) {
+	rf, err := f.rp.Parse(feed)
+	if err != nil {
+		return nil, err
+	}
+
+	return f.rssTrans().Translate(rf)
+}
+
+func (f *Parser) atomTrans() Translator {
+	if f.AtomTranslator != nil {
+		return f.AtomTranslator
+	}
+	f.AtomTranslator = &DefaultAtomTranslator{}
+	return f.AtomTranslator
+}
+
+func (f *Parser) rssTrans() Translator {
+	if f.RSSTranslator != nil {
+		return f.RSSTranslator
+	}
+	f.RSSTranslator = &DefaultRSSTranslator{}
+	return f.RSSTranslator
+}
+
+func (f *Parser) httpClient() *http.Client {
+	if f.Client != nil {
+		return f.Client
+	}
+	f.Client = &http.Client{}
+	return f.Client
+}

+ 120 - 0
vendor/github.com/mmcdole/gofeed/rss/feed.go

@@ -0,0 +1,120 @@
+package rss
+
+import (
+	"encoding/json"
+	"time"
+
+	"github.com/mmcdole/gofeed/extensions"
+)
+
+// Feed is an RSS Feed
+type Feed struct {
+	Title               string                   `json:"title,omitempty"`
+	Link                string                   `json:"link,omitempty"`
+	Description         string                   `json:"description,omitempty"`
+	Language            string                   `json:"language,omitempty"`
+	Copyright           string                   `json:"copyright,omitempty"`
+	ManagingEditor      string                   `json:"managingEditor,omitempty"`
+	WebMaster           string                   `json:"webMaster,omitempty"`
+	PubDate             string                   `json:"pubDate,omitempty"`
+	PubDateParsed       *time.Time               `json:"pubDateParsed,omitempty"`
+	LastBuildDate       string                   `json:"lastBuildDate,omitempty"`
+	LastBuildDateParsed *time.Time               `json:"lastBuildDateParsed,omitempty"`
+	Categories          []*Category              `json:"categories,omitempty"`
+	Generator           string                   `json:"generator,omitempty"`
+	Docs                string                   `json:"docs,omitempty"`
+	TTL                 string                   `json:"ttl,omitempty"`
+	Image               *Image                   `json:"image,omitempty"`
+	Rating              string                   `json:"rating,omitempty"`
+	SkipHours           []string                 `json:"skipHours,omitempty"`
+	SkipDays            []string                 `json:"skipDays,omitempty"`
+	Cloud               *Cloud                   `json:"cloud,omitempty"`
+	TextInput           *TextInput               `json:"textInput,omitempty"`
+	DublinCoreExt       *ext.DublinCoreExtension `json:"dcExt,omitempty"`
+	ITunesExt           *ext.ITunesFeedExtension `json:"itunesExt,omitempty"`
+	Extensions          ext.Extensions           `json:"extensions,omitempty"`
+	Items               []*Item                  `json:"items"`
+	Version             string                   `json:"version"`
+}
+
+func (f Feed) String() string {
+	json, _ := json.MarshalIndent(f, "", "    ")
+	return string(json)
+}
+
+// Item is an RSS Item
+type Item struct {
+	Title         string                   `json:"title,omitempty"`
+	Link          string                   `json:"link,omitempty"`
+	Description   string                   `json:"description,omitempty"`
+	Content       string                   `json:"content,omitempty"`
+	Author        string                   `json:"author,omitempty"`
+	Categories    []*Category              `json:"categories,omitempty"`
+	Comments      string                   `json:"comments,omitempty"`
+	Enclosure     *Enclosure               `json:"enclosure,omitempty"`
+	GUID          *GUID                    `json:"guid,omitempty"`
+	PubDate       string                   `json:"pubDate,omitempty"`
+	PubDateParsed *time.Time               `json:"pubDateParsed,omitempty"`
+	Source        *Source                  `json:"source,omitempty"`
+	DublinCoreExt *ext.DublinCoreExtension `json:"dcExt,omitempty"`
+	ITunesExt     *ext.ITunesItemExtension `json:"itunesExt,omitempty"`
+	Extensions    ext.Extensions           `json:"extensions,omitempty"`
+}
+
+// Image is an image that represents the feed
+type Image struct {
+	URL         string `json:"url,omitempty"`
+	Link        string `json:"link,omitempty"`
+	Title       string `json:"title,omitempty"`
+	Width       string `json:"width,omitempty"`
+	Height      string `json:"height,omitempty"`
+	Description string `json:"description,omitempty"`
+}
+
+// Enclosure is a media object that is attached to
+// the item
+type Enclosure struct {
+	URL    string `json:"url,omitempty"`
+	Length string `json:"length,omitempty"`
+	Type   string `json:"type,omitempty"`
+}
+
+// GUID is a unique identifier for an item
+type GUID struct {
+	Value       string `json:"value,omitempty"`
+	IsPermalink string `json:"isPermalink,omitempty"`
+}
+
+// Source contains feed information for another
+// feed if a given item came from that feed
+type Source struct {
+	Title string `json:"title,omitempty"`
+	URL   string `json:"url,omitempty"`
+}
+
+// Category is category metadata for Feeds and Entries
+type Category struct {
+	Domain string `json:"domain,omitempty"`
+	Value  string `json:"value,omitempty"`
+}
+
+// TextInput specifies a text input box that
+// can be displayed with the channel
+type TextInput struct {
+	Title       string `json:"title,omitempty"`
+	Description string `json:"description,omitempty"`
+	Name        string `json:"name,omitempty"`
+	Link        string `json:"link,omitempty"`
+}
+
+// Cloud allows processes to register with a
+// cloud to be notified of updates to the channel,
+// implementing a lightweight publish-subscribe protocol
+// for RSS feeds
+type Cloud struct {
+	Domain            string `json:"domain,omitempty"`
+	Port              string `json:"port,omitempty"`
+	Path              string `json:"path,omitempty"`
+	RegisterProcedure string `json:"registerProcedure,omitempty"`
+	Protocol          string `json:"protocol,omitempty"`
+}

+ 767 - 0
vendor/github.com/mmcdole/gofeed/rss/parser.go

@@ -0,0 +1,767 @@
+package rss
+
+import (
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/mmcdole/gofeed/extensions"
+	"github.com/mmcdole/gofeed/internal/shared"
+	"github.com/mmcdole/goxpp"
+)
+
+// Parser is a RSS Parser
+type Parser struct{}
+
+// Parse parses an xml feed into an rss.Feed
+func (rp *Parser) Parse(feed io.Reader) (*Feed, error) {
+	p := xpp.NewXMLPullParser(feed, false, shared.NewReaderLabel)
+
+	_, err := shared.FindRoot(p)
+	if err != nil {
+		return nil, err
+	}
+
+	return rp.parseRoot(p)
+}
+
+func (rp *Parser) parseRoot(p *xpp.XMLPullParser) (*Feed, error) {
+	rssErr := p.Expect(xpp.StartTag, "rss")
+	rdfErr := p.Expect(xpp.StartTag, "rdf")
+	if rssErr != nil && rdfErr != nil {
+		return nil, fmt.Errorf("%s or %s", rssErr.Error(), rdfErr.Error())
+	}
+
+	// Items found in feed root
+	var channel *Feed
+	var textinput *TextInput
+	var image *Image
+	items := []*Item{}
+
+	ver := rp.parseVersion(p)
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+
+			// Skip any extensions found in the feed root.
+			if shared.IsExtension(p) {
+				p.Skip()
+				continue
+			}
+
+			name := strings.ToLower(p.Name)
+
+			if name == "channel" {
+				channel, err = rp.parseChannel(p)
+				if err != nil {
+					return nil, err
+				}
+			} else if name == "item" {
+				item, err := rp.parseItem(p)
+				if err != nil {
+					return nil, err
+				}
+				items = append(items, item)
+			} else if name == "textinput" {
+				textinput, err = rp.parseTextInput(p)
+				if err != nil {
+					return nil, err
+				}
+			} else if name == "image" {
+				image, err = rp.parseImage(p)
+				if err != nil {
+					return nil, err
+				}
+			} else {
+				p.Skip()
+			}
+		}
+	}
+
+	rssErr = p.Expect(xpp.EndTag, "rss")
+	rdfErr = p.Expect(xpp.EndTag, "rdf")
+	if rssErr != nil && rdfErr != nil {
+		return nil, fmt.Errorf("%s or %s", rssErr.Error(), rdfErr.Error())
+	}
+
+	if channel == nil {
+		channel = &Feed{}
+		channel.Items = []*Item{}
+	}
+
+	if len(items) > 0 {
+		channel.Items = append(channel.Items, items...)
+	}
+
+	if textinput != nil {
+		channel.TextInput = textinput
+	}
+
+	if image != nil {
+		channel.Image = image
+	}
+
+	channel.Version = ver
+	return channel, nil
+}
+
+func (rp *Parser) parseChannel(p *xpp.XMLPullParser) (rss *Feed, err error) {
+
+	if err = p.Expect(xpp.StartTag, "channel"); err != nil {
+		return nil, err
+	}
+
+	rss = &Feed{}
+	rss.Items = []*Item{}
+
+	extensions := ext.Extensions{}
+	categories := []*Category{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+
+			name := strings.ToLower(p.Name)
+
+			if shared.IsExtension(p) {
+				ext, err := shared.ParseExtension(extensions, p)
+				if err != nil {
+					return nil, err
+				}
+				extensions = ext
+			} else if name == "title" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Title = result
+			} else if name == "description" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Description = result
+			} else if name == "link" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Link = result
+			} else if name == "language" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Language = result
+			} else if name == "copyright" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Copyright = result
+			} else if name == "managingeditor" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.ManagingEditor = result
+			} else if name == "webmaster" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.WebMaster = result
+			} else if name == "pubdate" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.PubDate = result
+				date, err := shared.ParseDate(result)
+				if err == nil {
+					utcDate := date.UTC()
+					rss.PubDateParsed = &utcDate
+				}
+			} else if name == "lastbuilddate" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.LastBuildDate = result
+				date, err := shared.ParseDate(result)
+				if err == nil {
+					utcDate := date.UTC()
+					rss.LastBuildDateParsed = &utcDate
+				}
+			} else if name == "generator" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Generator = result
+			} else if name == "docs" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Docs = result
+			} else if name == "ttl" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.TTL = result
+			} else if name == "rating" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Rating = result
+			} else if name == "skiphours" {
+				result, err := rp.parseSkipHours(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.SkipHours = result
+			} else if name == "skipdays" {
+				result, err := rp.parseSkipDays(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.SkipDays = result
+			} else if name == "item" {
+				result, err := rp.parseItem(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Items = append(rss.Items, result)
+			} else if name == "cloud" {
+				result, err := rp.parseCloud(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Cloud = result
+			} else if name == "category" {
+				result, err := rp.parseCategory(p)
+				if err != nil {
+					return nil, err
+				}
+				categories = append(categories, result)
+			} else if name == "image" {
+				result, err := rp.parseImage(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.Image = result
+			} else if name == "textinput" {
+				result, err := rp.parseTextInput(p)
+				if err != nil {
+					return nil, err
+				}
+				rss.TextInput = result
+			} else {
+				// Skip element as it isn't an extension and not
+				// part of the spec
+				p.Skip()
+			}
+		}
+	}
+
+	if err = p.Expect(xpp.EndTag, "channel"); err != nil {
+		return nil, err
+	}
+
+	if len(categories) > 0 {
+		rss.Categories = categories
+	}
+
+	if len(extensions) > 0 {
+		rss.Extensions = extensions
+
+		if itunes, ok := rss.Extensions["itunes"]; ok {
+			rss.ITunesExt = ext.NewITunesFeedExtension(itunes)
+		}
+
+		if dc, ok := rss.Extensions["dc"]; ok {
+			rss.DublinCoreExt = ext.NewDublinCoreExtension(dc)
+		}
+	}
+
+	return rss, nil
+}
+
+func (rp *Parser) parseItem(p *xpp.XMLPullParser) (item *Item, err error) {
+
+	if err = p.Expect(xpp.StartTag, "item"); err != nil {
+		return nil, err
+	}
+
+	item = &Item{}
+	extensions := ext.Extensions{}
+	categories := []*Category{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+
+			name := strings.ToLower(p.Name)
+
+			if shared.IsExtension(p) {
+				ext, err := shared.ParseExtension(extensions, p)
+				if err != nil {
+					return nil, err
+				}
+				item.Extensions = ext
+			} else if name == "title" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				item.Title = result
+			} else if name == "description" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				item.Description = result
+			} else if name == "encoded" {
+				space := strings.TrimSpace(p.Space)
+				if prefix, ok := p.Spaces[space]; ok && prefix == "content" {
+					result, err := shared.ParseText(p)
+					if err != nil {
+						return nil, err
+					}
+					item.Content = result
+				}
+			} else if name == "link" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				item.Link = result
+			} else if name == "author" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				item.Author = result
+			} else if name == "comments" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				item.Comments = result
+			} else if name == "pubdate" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				item.PubDate = result
+				date, err := shared.ParseDate(result)
+				if err == nil {
+					utcDate := date.UTC()
+					item.PubDateParsed = &utcDate
+				}
+			} else if name == "source" {
+				result, err := rp.parseSource(p)
+				if err != nil {
+					return nil, err
+				}
+				item.Source = result
+			} else if name == "enclosure" {
+				result, err := rp.parseEnclosure(p)
+				if err != nil {
+					return nil, err
+				}
+				item.Enclosure = result
+			} else if name == "guid" {
+				result, err := rp.parseGUID(p)
+				if err != nil {
+					return nil, err
+				}
+				item.GUID = result
+			} else if name == "category" {
+				result, err := rp.parseCategory(p)
+				if err != nil {
+					return nil, err
+				}
+				categories = append(categories, result)
+			} else {
+				// Skip any elements not part of the item spec
+				p.Skip()
+			}
+		}
+	}
+
+	if len(categories) > 0 {
+		item.Categories = categories
+	}
+
+	if len(extensions) > 0 {
+		item.Extensions = extensions
+
+		if itunes, ok := item.Extensions["itunes"]; ok {
+			item.ITunesExt = ext.NewITunesItemExtension(itunes)
+		}
+
+		if dc, ok := item.Extensions["dc"]; ok {
+			item.DublinCoreExt = ext.NewDublinCoreExtension(dc)
+		}
+	}
+
+	if err = p.Expect(xpp.EndTag, "item"); err != nil {
+		return nil, err
+	}
+
+	return item, nil
+}
+
+func (rp *Parser) parseSource(p *xpp.XMLPullParser) (source *Source, err error) {
+	if err = p.Expect(xpp.StartTag, "source"); err != nil {
+		return nil, err
+	}
+
+	source = &Source{}
+	source.URL = p.Attribute("url")
+
+	result, err := shared.ParseText(p)
+	if err != nil {
+		return source, err
+	}
+	source.Title = result
+
+	if err = p.Expect(xpp.EndTag, "source"); err != nil {
+		return nil, err
+	}
+	return source, nil
+}
+
+func (rp *Parser) parseEnclosure(p *xpp.XMLPullParser) (enclosure *Enclosure, err error) {
+	if err = p.Expect(xpp.StartTag, "enclosure"); err != nil {
+		return nil, err
+	}
+
+	enclosure = &Enclosure{}
+	enclosure.URL = p.Attribute("url")
+	enclosure.Length = p.Attribute("length")
+	enclosure.Type = p.Attribute("type")
+
+	// Ignore any enclosure text
+	_, err = p.NextText()
+	if err != nil {
+		return enclosure, err
+	}
+
+	if err = p.Expect(xpp.EndTag, "enclosure"); err != nil {
+		return nil, err
+	}
+
+	return enclosure, nil
+}
+
+func (rp *Parser) parseImage(p *xpp.XMLPullParser) (image *Image, err error) {
+	if err = p.Expect(xpp.StartTag, "image"); err != nil {
+		return nil, err
+	}
+
+	image = &Image{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return image, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+			name := strings.ToLower(p.Name)
+
+			if name == "url" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				image.URL = result
+			} else if name == "title" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				image.Title = result
+			} else if name == "link" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				image.Link = result
+			} else if name == "width" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				image.Width = result
+			} else if name == "height" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				image.Height = result
+			} else if name == "description" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				image.Description = result
+			} else {
+				p.Skip()
+			}
+		}
+	}
+
+	if err = p.Expect(xpp.EndTag, "image"); err != nil {
+		return nil, err
+	}
+
+	return image, nil
+}
+
+func (rp *Parser) parseGUID(p *xpp.XMLPullParser) (guid *GUID, err error) {
+	if err = p.Expect(xpp.StartTag, "guid"); err != nil {
+		return nil, err
+	}
+
+	guid = &GUID{}
+	guid.IsPermalink = p.Attribute("isPermalink")
+
+	result, err := shared.ParseText(p)
+	if err != nil {
+		return
+	}
+	guid.Value = result
+
+	if err = p.Expect(xpp.EndTag, "guid"); err != nil {
+		return nil, err
+	}
+
+	return guid, nil
+}
+
+func (rp *Parser) parseCategory(p *xpp.XMLPullParser) (cat *Category, err error) {
+
+	if err = p.Expect(xpp.StartTag, "category"); err != nil {
+		return nil, err
+	}
+
+	cat = &Category{}
+	cat.Domain = p.Attribute("domain")
+
+	result, err := shared.ParseText(p)
+	if err != nil {
+		return nil, err
+	}
+
+	cat.Value = result
+
+	if err = p.Expect(xpp.EndTag, "category"); err != nil {
+		return nil, err
+	}
+	return cat, nil
+}
+
+func (rp *Parser) parseTextInput(p *xpp.XMLPullParser) (*TextInput, error) {
+	if err := p.Expect(xpp.StartTag, "textinput"); err != nil {
+		return nil, err
+	}
+
+	ti := &TextInput{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+			name := strings.ToLower(p.Name)
+
+			if name == "title" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				ti.Title = result
+			} else if name == "description" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				ti.Description = result
+			} else if name == "name" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				ti.Name = result
+			} else if name == "link" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				ti.Link = result
+			} else {
+				p.Skip()
+			}
+		}
+	}
+
+	if err := p.Expect(xpp.EndTag, "textinput"); err != nil {
+		return nil, err
+	}
+
+	return ti, nil
+}
+
+func (rp *Parser) parseSkipHours(p *xpp.XMLPullParser) ([]string, error) {
+	if err := p.Expect(xpp.StartTag, "skiphours"); err != nil {
+		return nil, err
+	}
+
+	hours := []string{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+			name := strings.ToLower(p.Name)
+			if name == "hour" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				hours = append(hours, result)
+			} else {
+				p.Skip()
+			}
+		}
+	}
+
+	if err := p.Expect(xpp.EndTag, "skiphours"); err != nil {
+		return nil, err
+	}
+
+	return hours, nil
+}
+
+func (rp *Parser) parseSkipDays(p *xpp.XMLPullParser) ([]string, error) {
+	if err := p.Expect(xpp.StartTag, "skipdays"); err != nil {
+		return nil, err
+	}
+
+	days := []string{}
+
+	for {
+		tok, err := shared.NextTag(p)
+		if err != nil {
+			return nil, err
+		}
+
+		if tok == xpp.EndTag {
+			break
+		}
+
+		if tok == xpp.StartTag {
+			name := strings.ToLower(p.Name)
+			if name == "day" {
+				result, err := shared.ParseText(p)
+				if err != nil {
+					return nil, err
+				}
+				days = append(days, result)
+			} else {
+				p.Skip()
+			}
+		}
+	}
+
+	if err := p.Expect(xpp.EndTag, "skipdays"); err != nil {
+		return nil, err
+	}
+
+	return days, nil
+}
+
+func (rp *Parser) parseCloud(p *xpp.XMLPullParser) (*Cloud, error) {
+	if err := p.Expect(xpp.StartTag, "cloud"); err != nil {
+		return nil, err
+	}
+
+	cloud := &Cloud{}
+	cloud.Domain = p.Attribute("domain")
+	cloud.Port = p.Attribute("port")
+	cloud.Path = p.Attribute("path")
+	cloud.RegisterProcedure = p.Attribute("registerProcedure")
+	cloud.Protocol = p.Attribute("protocol")
+
+	shared.NextTag(p)
+
+	if err := p.Expect(xpp.EndTag, "cloud"); err != nil {
+		return nil, err
+	}
+
+	return cloud, nil
+}
+
+func (rp *Parser) parseVersion(p *xpp.XMLPullParser) (ver string) {
+	name := strings.ToLower(p.Name)
+	if name == "rss" {
+		ver = p.Attribute("version")
+	} else if name == "rdf" {
+		ns := p.Attribute("xmlns")
+		if ns == "http://channel.netscape.com/rdf/simple/0.9/" ||
+			ns == "http://my.netscape.com/rdf/simple/0.9/" {
+			ver = "0.9"
+		} else if ns == "http://purl.org/rss/1.0/" {
+			ver = "1.0"
+		}
+	}
+	return
+}

+ 21 - 0
vendor/github.com/mmcdole/gofeed/testdata/parser/atom/atom10_feed_entry_source_authors_multiple.json

@@ -0,0 +1,21 @@
+{
+    "entries": [
+        {
+            "source": {
+                "authors": [
+                    {
+                        "name":"Author Name 1",
+                        "uri":"http://example.org/1",
+                        "email": "email@example.org"
+                    },
+                    {
+                        "name":"Author Name 2",
+                        "uri":"http://example.org/2",
+                        "email": "email2@example.org"
+                    }
+                ]
+            }
+        }
+    ],
+    "version": "1.0"
+}

+ 20 - 0
vendor/github.com/mmcdole/gofeed/testdata/parser/atom/atom10_feed_entry_source_authors_multiple.xml

@@ -0,0 +1,20 @@
+<!--
+Description: entry source author - multiple
+-->
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <entry>
+    <source>
+      <author>
+      	<name>Author Name 1</name>
+      	<email>email@example.org</email>
+        <uri>http://example.org/1</uri>
+      </author>
+      <author>
+      	<name>Author Name 2</name>
+      	<email>email2@example.org</email>
+        <uri>http://example.org/2</uri>
+      </author>
+    </source>
+  </entry>
+</feed>
+

+ 686 - 0
vendor/github.com/mmcdole/gofeed/translator.go

@@ -0,0 +1,686 @@
+package gofeed
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/mmcdole/gofeed/atom"
+	"github.com/mmcdole/gofeed/extensions"
+	"github.com/mmcdole/gofeed/internal/shared"
+	"github.com/mmcdole/gofeed/rss"
+)
+
+// Translator converts a particular feed (atom.Feed or rss.Feed)
+// into the generic Feed struct
+type Translator interface {
+	Translate(feed interface{}) (*Feed, error)
+}
+
+// DefaultRSSTranslator converts an rss.Feed struct
+// into the generic Feed struct.
+//
+// This default implementation defines a set of
+// mapping rules between rss.Feed -> Feed
+// for each of the fields in Feed.
+type DefaultRSSTranslator struct{}
+
+// Translate converts an RSS feed into the universal
+// feed type.
+func (t *DefaultRSSTranslator) Translate(feed interface{}) (*Feed, error) {
+	rss, found := feed.(*rss.Feed)
+	if !found {
+		return nil, fmt.Errorf("Feed did not match expected type of *rss.Feed")
+	}
+
+	result := &Feed{}
+	result.Title = t.translateFeedTitle(rss)
+	result.Description = t.translateFeedDescription(rss)
+	result.Link = t.translateFeedLink(rss)
+	result.FeedLink = t.translateFeedFeedLink(rss)
+	result.Updated = t.translateFeedUpdated(rss)
+	result.UpdatedParsed = t.translateFeedUpdatedParsed(rss)
+	result.Published = t.translateFeedPublished(rss)
+	result.PublishedParsed = t.translateFeedPublishedParsed(rss)
+	result.Author = t.translateFeedAuthor(rss)
+	result.Language = t.translateFeedLanguage(rss)
+	result.Image = t.translateFeedImage(rss)
+	result.Copyright = t.translateFeedCopyright(rss)
+	result.Generator = t.translateFeedGenerator(rss)
+	result.Categories = t.translateFeedCategories(rss)
+	result.Items = t.translateFeedItems(rss)
+	result.ITunesExt = rss.ITunesExt
+	result.DublinCoreExt = rss.DublinCoreExt
+	result.Extensions = rss.Extensions
+	result.FeedVersion = rss.Version
+	result.FeedType = "rss"
+	return result, nil
+}
+
+func (t *DefaultRSSTranslator) translateFeedItem(rssItem *rss.Item) (item *Item) {
+	item = &Item{}
+	item.Title = t.translateItemTitle(rssItem)
+	item.Description = t.translateItemDescription(rssItem)
+	item.Content = t.translateItemContent(rssItem)
+	item.Link = t.translateItemLink(rssItem)
+	item.Published = t.translateItemPublished(rssItem)
+	item.PublishedParsed = t.translateItemPublishedParsed(rssItem)
+	item.Author = t.translateItemAuthor(rssItem)
+	item.GUID = t.translateItemGUID(rssItem)
+	item.Image = t.translateItemImage(rssItem)
+	item.Categories = t.translateItemCategories(rssItem)
+	item.Enclosures = t.translateItemEnclosures(rssItem)
+	item.DublinCoreExt = rssItem.DublinCoreExt
+	item.ITunesExt = rssItem.ITunesExt
+	item.Extensions = rssItem.Extensions
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedTitle(rss *rss.Feed) (title string) {
+	if rss.Title != "" {
+		title = rss.Title
+	} else if rss.DublinCoreExt != nil && rss.DublinCoreExt.Title != nil {
+		title = t.firstEntry(rss.DublinCoreExt.Title)
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedDescription(rss *rss.Feed) (desc string) {
+	return rss.Description
+}
+
+func (t *DefaultRSSTranslator) translateFeedLink(rss *rss.Feed) (link string) {
+	if rss.Link != "" {
+		link = rss.Link
+	} else if rss.ITunesExt != nil && rss.ITunesExt.Subtitle != "" {
+		link = rss.ITunesExt.Subtitle
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedFeedLink(rss *rss.Feed) (link string) {
+	atomExtensions := t.extensionsForKeys([]string{"atom", "atom10", "atom03"}, rss.Extensions)
+	for _, ex := range atomExtensions {
+		if links, ok := ex["link"]; ok {
+			for _, l := range links {
+				if l.Attrs["Rel"] == "self" {
+					link = l.Value
+				}
+			}
+		}
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedUpdated(rss *rss.Feed) (updated string) {
+	if rss.LastBuildDate != "" {
+		updated = rss.LastBuildDate
+	} else if rss.DublinCoreExt != nil && rss.DublinCoreExt.Date != nil {
+		updated = t.firstEntry(rss.DublinCoreExt.Date)
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedUpdatedParsed(rss *rss.Feed) (updated *time.Time) {
+	if rss.LastBuildDateParsed != nil {
+		updated = rss.LastBuildDateParsed
+	} else if rss.DublinCoreExt != nil && rss.DublinCoreExt.Date != nil {
+		dateText := t.firstEntry(rss.DublinCoreExt.Date)
+		date, err := shared.ParseDate(dateText)
+		if err == nil {
+			updated = &date
+		}
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedPublished(rss *rss.Feed) (published string) {
+	return rss.PubDate
+}
+
+func (t *DefaultRSSTranslator) translateFeedPublishedParsed(rss *rss.Feed) (published *time.Time) {
+	return rss.PubDateParsed
+}
+
+func (t *DefaultRSSTranslator) translateFeedAuthor(rss *rss.Feed) (author *Person) {
+	if rss.ManagingEditor != "" {
+		name, address := shared.ParseNameAddress(rss.ManagingEditor)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	} else if rss.WebMaster != "" {
+		name, address := shared.ParseNameAddress(rss.WebMaster)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	} else if rss.DublinCoreExt != nil && rss.DublinCoreExt.Author != nil {
+		dcAuthor := t.firstEntry(rss.DublinCoreExt.Author)
+		name, address := shared.ParseNameAddress(dcAuthor)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	} else if rss.DublinCoreExt != nil && rss.DublinCoreExt.Creator != nil {
+		dcCreator := t.firstEntry(rss.DublinCoreExt.Creator)
+		name, address := shared.ParseNameAddress(dcCreator)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	} else if rss.ITunesExt != nil && rss.ITunesExt.Author != "" {
+		name, address := shared.ParseNameAddress(rss.ITunesExt.Author)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedLanguage(rss *rss.Feed) (language string) {
+	if rss.Language != "" {
+		language = rss.Language
+	} else if rss.DublinCoreExt != nil && rss.DublinCoreExt.Language != nil {
+		language = t.firstEntry(rss.DublinCoreExt.Language)
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedImage(rss *rss.Feed) (image *Image) {
+	if rss.Image != nil {
+		image = &Image{}
+		image.Title = rss.Image.Title
+		image.URL = rss.Image.URL
+	} else if rss.ITunesExt != nil && rss.ITunesExt.Image != "" {
+		image = &Image{}
+		image.URL = rss.ITunesExt.Image
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedCopyright(rss *rss.Feed) (rights string) {
+	if rss.Copyright != "" {
+		rights = rss.Copyright
+	} else if rss.DublinCoreExt != nil && rss.DublinCoreExt.Rights != nil {
+		rights = t.firstEntry(rss.DublinCoreExt.Rights)
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedGenerator(rss *rss.Feed) (generator string) {
+	return rss.Generator
+}
+
+func (t *DefaultRSSTranslator) translateFeedCategories(rss *rss.Feed) (categories []string) {
+	cats := []string{}
+	if rss.Categories != nil {
+		for _, c := range rss.Categories {
+			cats = append(cats, c.Value)
+		}
+	}
+
+	if rss.ITunesExt != nil && rss.ITunesExt.Keywords != "" {
+		keywords := strings.Split(rss.ITunesExt.Keywords, ",")
+		for _, k := range keywords {
+			cats = append(cats, k)
+		}
+	}
+
+	if rss.ITunesExt != nil && rss.ITunesExt.Categories != nil {
+		for _, c := range rss.ITunesExt.Categories {
+			cats = append(cats, c.Text)
+			if c.Subcategory != nil {
+				cats = append(cats, c.Subcategory.Text)
+			}
+		}
+	}
+
+	if rss.DublinCoreExt != nil && rss.DublinCoreExt.Subject != nil {
+		for _, c := range rss.DublinCoreExt.Subject {
+			cats = append(cats, c)
+		}
+	}
+
+	if len(cats) > 0 {
+		categories = cats
+	}
+
+	return
+}
+
+func (t *DefaultRSSTranslator) translateFeedItems(rss *rss.Feed) (items []*Item) {
+	items = []*Item{}
+	for _, i := range rss.Items {
+		items = append(items, t.translateFeedItem(i))
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemTitle(rssItem *rss.Item) (title string) {
+	if rssItem.Title != "" {
+		title = rssItem.Title
+	} else if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Title != nil {
+		title = t.firstEntry(rssItem.DublinCoreExt.Title)
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemDescription(rssItem *rss.Item) (desc string) {
+	if rssItem.Description != "" {
+		desc = rssItem.Description
+	} else if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Description != nil {
+		desc = t.firstEntry(rssItem.DublinCoreExt.Description)
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemContent(rssItem *rss.Item) (content string) {
+	return rssItem.Content
+}
+
+func (t *DefaultRSSTranslator) translateItemLink(rssItem *rss.Item) (link string) {
+	return rssItem.Link
+}
+
+func (t *DefaultRSSTranslator) translateItemUpdated(rssItem *rss.Item) (updated string) {
+	if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Date != nil {
+		updated = t.firstEntry(rssItem.DublinCoreExt.Date)
+	}
+	return updated
+}
+
+func (t *DefaultRSSTranslator) translateItemUpdatedParsed(rssItem *rss.Item) (updated *time.Time) {
+	if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Date != nil {
+		updatedText := t.firstEntry(rssItem.DublinCoreExt.Date)
+		updatedDate, err := shared.ParseDate(updatedText)
+		if err == nil {
+			updated = &updatedDate
+		}
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemPublished(rssItem *rss.Item) (pubDate string) {
+	if rssItem.PubDate != "" {
+		return rssItem.PubDate
+	} else if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Date != nil {
+		return t.firstEntry(rssItem.DublinCoreExt.Date)
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemPublishedParsed(rssItem *rss.Item) (pubDate *time.Time) {
+	if rssItem.PubDateParsed != nil {
+		return rssItem.PubDateParsed
+	} else if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Date != nil {
+		pubDateText := t.firstEntry(rssItem.DublinCoreExt.Date)
+		pubDateParsed, err := shared.ParseDate(pubDateText)
+		if err == nil {
+			pubDate = &pubDateParsed
+		}
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemAuthor(rssItem *rss.Item) (author *Person) {
+	if rssItem.Author != "" {
+		name, address := shared.ParseNameAddress(rssItem.Author)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	} else if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Author != nil {
+		dcAuthor := t.firstEntry(rssItem.DublinCoreExt.Author)
+		name, address := shared.ParseNameAddress(dcAuthor)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	} else if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Creator != nil {
+		dcCreator := t.firstEntry(rssItem.DublinCoreExt.Creator)
+		name, address := shared.ParseNameAddress(dcCreator)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	} else if rssItem.ITunesExt != nil && rssItem.ITunesExt.Author != "" {
+		name, address := shared.ParseNameAddress(rssItem.ITunesExt.Author)
+		author = &Person{}
+		author.Name = name
+		author.Email = address
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemGUID(rssItem *rss.Item) (guid string) {
+	if rssItem.GUID != nil {
+		guid = rssItem.GUID.Value
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemImage(rssItem *rss.Item) (image *Image) {
+	if rssItem.ITunesExt != nil && rssItem.ITunesExt.Image != "" {
+		image = &Image{}
+		image.URL = rssItem.ITunesExt.Image
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemCategories(rssItem *rss.Item) (categories []string) {
+	cats := []string{}
+	if rssItem.Categories != nil {
+		for _, c := range rssItem.Categories {
+			cats = append(cats, c.Value)
+		}
+	}
+
+	if rssItem.ITunesExt != nil && rssItem.ITunesExt.Keywords != "" {
+		keywords := strings.Split(rssItem.ITunesExt.Keywords, ",")
+		for _, k := range keywords {
+			cats = append(cats, k)
+		}
+	}
+
+	if rssItem.DublinCoreExt != nil && rssItem.DublinCoreExt.Subject != nil {
+		for _, c := range rssItem.DublinCoreExt.Subject {
+			cats = append(cats, c)
+		}
+	}
+
+	if len(cats) > 0 {
+		categories = cats
+	}
+
+	return
+}
+
+func (t *DefaultRSSTranslator) translateItemEnclosures(rssItem *rss.Item) (enclosures []*Enclosure) {
+	if rssItem.Enclosure != nil {
+		e := &Enclosure{}
+		e.URL = rssItem.Enclosure.URL
+		e.Type = rssItem.Enclosure.Type
+		e.Length = rssItem.Enclosure.Length
+		enclosures = []*Enclosure{e}
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) extensionsForKeys(keys []string, extensions ext.Extensions) (matches []map[string][]ext.Extension) {
+	matches = []map[string][]ext.Extension{}
+
+	if extensions == nil {
+		return
+	}
+
+	for _, key := range keys {
+		if match, ok := extensions[key]; ok {
+			matches = append(matches, match)
+		}
+	}
+	return
+}
+
+func (t *DefaultRSSTranslator) firstEntry(entries []string) (value string) {
+	if entries == nil {
+		return
+	}
+
+	if len(entries) == 0 {
+		return
+	}
+
+	return entries[0]
+}
+
+// DefaultAtomTranslator converts an atom.Feed struct
+// into the generic Feed struct.
+//
+// This default implementation defines a set of
+// mapping rules between atom.Feed -> Feed
+// for each of the fields in Feed.
+type DefaultAtomTranslator struct{}
+
+// Translate converts an Atom feed into the universal
+// feed type.
+func (t *DefaultAtomTranslator) Translate(feed interface{}) (*Feed, error) {
+	atom, found := feed.(*atom.Feed)
+	if !found {
+		return nil, fmt.Errorf("Feed did not match expected type of *atom.Feed")
+	}
+
+	result := &Feed{}
+	result.Title = t.translateFeedTitle(atom)
+	result.Description = t.translateFeedDescription(atom)
+	result.Link = t.translateFeedLink(atom)
+	result.FeedLink = t.translateFeedFeedLink(atom)
+	result.Updated = t.translateFeedUpdated(atom)
+	result.UpdatedParsed = t.translateFeedUpdatedParsed(atom)
+	result.Author = t.translateFeedAuthor(atom)
+	result.Language = t.translateFeedLanguage(atom)
+	result.Image = t.translateFeedImage(atom)
+	result.Copyright = t.translateFeedCopyright(atom)
+	result.Categories = t.translateFeedCategories(atom)
+	result.Generator = t.translateFeedGenerator(atom)
+	result.Items = t.translateFeedItems(atom)
+	result.Extensions = atom.Extensions
+	result.FeedVersion = atom.Version
+	result.FeedType = "atom"
+	return result, nil
+}
+
+func (t *DefaultAtomTranslator) translateFeedItem(entry *atom.Entry) (item *Item) {
+	item = &Item{}
+	item.Title = t.translateItemTitle(entry)
+	item.Description = t.translateItemDescription(entry)
+	item.Content = t.translateItemContent(entry)
+	item.Link = t.translateItemLink(entry)
+	item.Updated = t.translateItemUpdated(entry)
+	item.UpdatedParsed = t.translateItemUpdatedParsed(entry)
+	item.Published = t.translateItemPublished(entry)
+	item.PublishedParsed = t.translateItemPublishedParsed(entry)
+	item.Author = t.translateItemAuthor(entry)
+	item.GUID = t.translateItemGUID(entry)
+	item.Image = t.translateItemImage(entry)
+	item.Categories = t.translateItemCategories(entry)
+	item.Enclosures = t.translateItemEnclosures(entry)
+	item.Extensions = entry.Extensions
+	return
+}
+
+func (t *DefaultAtomTranslator) translateFeedTitle(atom *atom.Feed) (title string) {
+	return atom.Title
+}
+
+func (t *DefaultAtomTranslator) translateFeedDescription(atom *atom.Feed) (desc string) {
+	return atom.Subtitle
+}
+
+func (t *DefaultAtomTranslator) translateFeedLink(atom *atom.Feed) (link string) {
+	l := t.firstLinkWithType("alternate", atom.Links)
+	if l != nil {
+		link = l.Href
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateFeedFeedLink(atom *atom.Feed) (link string) {
+	feedLink := t.firstLinkWithType("self", atom.Links)
+	if feedLink != nil {
+		link = feedLink.Href
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateFeedUpdated(atom *atom.Feed) (updated string) {
+	return atom.Updated
+}
+
+func (t *DefaultAtomTranslator) translateFeedUpdatedParsed(atom *atom.Feed) (updated *time.Time) {
+	return atom.UpdatedParsed
+}
+
+func (t *DefaultAtomTranslator) translateFeedAuthor(atom *atom.Feed) (author *Person) {
+	a := t.firstPerson(atom.Authors)
+	if a != nil {
+		feedAuthor := Person{}
+		feedAuthor.Name = a.Name
+		feedAuthor.Email = a.Email
+		author = &feedAuthor
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateFeedLanguage(atom *atom.Feed) (language string) {
+	return atom.Language
+}
+
+func (t *DefaultAtomTranslator) translateFeedImage(atom *atom.Feed) (image *Image) {
+	if atom.Logo != "" {
+		feedImage := Image{}
+		feedImage.URL = atom.Logo
+		image = &feedImage
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateFeedCopyright(atom *atom.Feed) (rights string) {
+	return atom.Rights
+}
+
+func (t *DefaultAtomTranslator) translateFeedGenerator(atom *atom.Feed) (generator string) {
+	if atom.Generator != nil {
+		if atom.Generator.Value != "" {
+			generator += atom.Generator.Value
+		}
+		if atom.Generator.Version != "" {
+			generator += " v" + atom.Generator.Version
+		}
+		if atom.Generator.URI != "" {
+			generator += " " + atom.Generator.URI
+		}
+		generator = strings.TrimSpace(generator)
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateFeedCategories(atom *atom.Feed) (categories []string) {
+	if atom.Categories != nil {
+		categories = []string{}
+		for _, c := range atom.Categories {
+			categories = append(categories, c.Term)
+		}
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateFeedItems(atom *atom.Feed) (items []*Item) {
+	items = []*Item{}
+	for _, entry := range atom.Entries {
+		items = append(items, t.translateFeedItem(entry))
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateItemTitle(entry *atom.Entry) (title string) {
+	return entry.Title
+}
+
+func (t *DefaultAtomTranslator) translateItemDescription(entry *atom.Entry) (desc string) {
+	return entry.Summary
+}
+
+func (t *DefaultAtomTranslator) translateItemContent(entry *atom.Entry) (content string) {
+	if entry.Content != nil {
+		content = entry.Content.Value
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateItemLink(entry *atom.Entry) (link string) {
+	l := t.firstLinkWithType("alternate", entry.Links)
+	if l != nil {
+		link = l.Href
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateItemUpdated(entry *atom.Entry) (updated string) {
+	return entry.Updated
+}
+
+func (t *DefaultAtomTranslator) translateItemUpdatedParsed(entry *atom.Entry) (updated *time.Time) {
+	return entry.UpdatedParsed
+}
+
+func (t *DefaultAtomTranslator) translateItemPublished(entry *atom.Entry) (updated string) {
+	return entry.Published
+}
+
+func (t *DefaultAtomTranslator) translateItemPublishedParsed(entry *atom.Entry) (updated *time.Time) {
+	return entry.PublishedParsed
+}
+
+func (t *DefaultAtomTranslator) translateItemAuthor(entry *atom.Entry) (author *Person) {
+	a := t.firstPerson(entry.Authors)
+	if a != nil {
+		author = &Person{}
+		author.Name = a.Name
+		author.Email = a.Email
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateItemGUID(entry *atom.Entry) (guid string) {
+	return entry.ID
+}
+
+func (t *DefaultAtomTranslator) translateItemImage(entry *atom.Entry) (image *Image) {
+	return nil
+}
+
+func (t *DefaultAtomTranslator) translateItemCategories(entry *atom.Entry) (categories []string) {
+	if entry.Categories != nil {
+		categories = []string{}
+		for _, c := range entry.Categories {
+			categories = append(categories, c.Term)
+		}
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) translateItemEnclosures(entry *atom.Entry) (enclosures []*Enclosure) {
+	if entry.Links != nil {
+		enclosures = []*Enclosure{}
+		for _, e := range entry.Links {
+			if e.Rel == "enclosure" {
+				enclosure := &Enclosure{}
+				enclosure.URL = e.Href
+				enclosure.Length = e.Length
+				enclosure.Type = e.Type
+				enclosures = append(enclosures, enclosure)
+			}
+		}
+
+		if len(enclosures) == 0 {
+			enclosures = nil
+		}
+	}
+	return
+}
+
+func (t *DefaultAtomTranslator) firstLinkWithType(linkType string, links []*atom.Link) *atom.Link {
+	if links == nil {
+		return nil
+	}
+
+	for _, link := range links {
+		if link.Rel == linkType {
+			return link
+		}
+	}
+	return nil
+}
+
+func (t *DefaultAtomTranslator) firstPerson(persons []*atom.Person) (person *atom.Person) {
+	if persons == nil || len(persons) == 0 {
+		return
+	}
+
+	person = persons[0]
+	return
+}

+ 24 - 0
vendor/github.com/mmcdole/goxpp/.gitignore

@@ -0,0 +1,24 @@
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe
+*.test
+*.prof

+ 17 - 0
vendor/github.com/mmcdole/goxpp/.travis.yml

@@ -0,0 +1,17 @@
+language: go
+go:
+- tip
+- 1.6
+- 1.5
+- 1.4
+install:
+- go get github.com/stretchr/testify/assert
+- go get golang.org/x/tools/cmd/cover
+- go get github.com/mattn/goveralls
+script:
+- go test -v -covermode=count -coverprofile=coverage.out
+- $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken=$COVERALLS_TOKEN
+
+env:
+  global:
+    secure: IZqRp8DmY6LLP+9PTjttLFuCeA/IqW4qQWGnNqyc8nwKoqbHTVY/MZav9WrocGBZZGdI/zLghnE3wp2bywPyBoBPJv9oq7NUZ47DGpdkaLazgP9O68dtshOZ/zDZU7kSbGDF6mePVYjPjkBkWFyt13np89n/bB0L0zdsQE3bKukJ0lSnN32aOjphL+nFEfxM5ghnOuZ7ZDyXTjehQNkNZG73T2ttic8pxi1M+xxl1FXODNgTz0D6qs2ZdKSjJeE9n5iOJqNxIRfW1iIXPn8L2UQBV1+8aohxGy22flwz1ZCO2MZJLqdR1apGZdqVrYhKjxOnyyRWfEX3mpl6/EiW1gLqPgpzjKPuu/wiwfUJOBdFbrn5WGoR6f16XJ6bmxo4NGUEtBXeZz932HWl8XXD+CAGfLNw1NAabH1HpNAYBd4CFpiIi5RtU5sRtumzXwjvgHxlRhwIRb6jWqWezBbRL10MrnhnUyyXu1AWV7LrSvPDbXZ/5NL4/fbW/Piop1vhuNhHauLvZxWa1yv9q1CN8Uad8KEiJcjRj6lac7CCTspoEaPEVlL01tJZDllga1XQCJcRJVPSzt0qEzG2XqhwjWaRkbMLqLXJ9/0YsY/QU9BA0mtPwo+2e2J8ZRmqqXNatysNk9l5sH5TBk3lP+n4jFtI0nGndCxwqPHFGsjhPsE=

+ 21 - 0
vendor/github.com/mmcdole/goxpp/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 mmcdole
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 9 - 0
vendor/github.com/mmcdole/goxpp/README.md

@@ -0,0 +1,9 @@
+# goxpp
+
+[![Build Status](https://travis-ci.org/mmcdole/goxpp.svg?branch=master)](https://travis-ci.org/mmcdole/goxpp) [![Coverage Status](https://coveralls.io/repos/github/mmcdole/goxpp/badge.svg?branch=master)](https://coveralls.io/github/mmcdole/goxpp?branch=master) [![License](http://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org)
+[![GoDoc](https://godoc.org/github.com/mmcdole/goxpp?status.svg)](https://godoc.org/github.com/mmcdole/goxpp)
+
+The `goxpp` library is an XML parser library that is loosely based on the [Java XMLPullParser](http://www.xmlpull.org/v1/download/unpacked/doc/quick_intro.html).  This library allows you to easily parse arbitary XML content using a pull parser.  You can think of `goxpp` as a lightweight wrapper around Go's XML `Decoder` that provides a set of functions that make it easier to parse XML content than using the raw decoder itself.
+
+This project is licensed under the [MIT License](https://raw.githubusercontent.com/mmcdole/goxpp/master/LICENSE)
+

+ 342 - 0
vendor/github.com/mmcdole/goxpp/xpp.go

@@ -0,0 +1,342 @@
+package xpp
+
+import (
+	"encoding/xml"
+	"errors"
+	"fmt"
+	"io"
+	"strings"
+)
+
+type XMLEventType int
+type CharsetReader func(charset string, input io.Reader) (io.Reader, error)
+
+const (
+	StartDocument XMLEventType = iota
+	EndDocument
+	StartTag
+	EndTag
+	Text
+	Comment
+	ProcessingInstruction
+	Directive
+	IgnorableWhitespace // TODO: ?
+	// TODO: CDSECT ?
+)
+
+type XMLPullParser struct {
+	// Document State
+	Spaces      map[string]string
+	SpacesStack []map[string]string
+
+	// Token State
+	Depth int
+	Event XMLEventType
+	Attrs []xml.Attr
+	Name  string
+	Space string
+	Text  string
+
+	decoder *xml.Decoder
+	token   interface{}
+}
+
+func NewXMLPullParser(r io.Reader, strict bool, cr CharsetReader) *XMLPullParser {
+	d := xml.NewDecoder(r)
+	d.Strict = strict
+	d.CharsetReader = cr
+	return &XMLPullParser{
+		decoder: d,
+		Event:   StartDocument,
+		Depth:   0,
+		Spaces:  map[string]string{},
+	}
+}
+
+func (p *XMLPullParser) NextTag() (event XMLEventType, err error) {
+	t, err := p.Next()
+	if err != nil {
+		return event, err
+	}
+
+	for t == Text && p.IsWhitespace() {
+		t, err = p.Next()
+		if err != nil {
+			return event, err
+		}
+	}
+
+	if t != StartTag && t != EndTag {
+		return event, fmt.Errorf("Expected StartTag or EndTag but got %s at offset: %d", p.EventName(t), p.decoder.InputOffset())
+	}
+
+	return t, nil
+}
+
+func (p *XMLPullParser) Next() (event XMLEventType, err error) {
+	for {
+		event, err = p.NextToken()
+		if err != nil {
+			return event, err
+		}
+
+		// Return immediately after encountering a StartTag
+		// EndTag, Text, EndDocument
+		if event == StartTag ||
+			event == EndTag ||
+			event == EndDocument ||
+			event == Text {
+			return event, nil
+		}
+
+		// Skip Comment/Directive and ProcessingInstruction
+		if event == Comment ||
+			event == Directive ||
+			event == ProcessingInstruction {
+			continue
+		}
+	}
+	return event, nil
+}
+
+func (p *XMLPullParser) NextToken() (event XMLEventType, err error) {
+	// Clear any state held for the previous token
+	p.resetTokenState()
+
+	token, err := p.decoder.Token()
+	if err != nil {
+		if err == io.EOF {
+			// XML decoder returns the EOF as an error
+			// but we want to return it as a valid
+			// EndDocument token instead
+			p.token = nil
+			p.Event = EndDocument
+			return p.Event, nil
+		}
+		return event, err
+	}
+
+	p.token = xml.CopyToken(token)
+	p.processToken(p.token)
+	p.Event = p.EventType(p.token)
+
+	return p.Event, nil
+}
+
+func (p *XMLPullParser) NextText() (string, error) {
+	if p.Event != StartTag {
+		return "", errors.New("Parser must be on StartTag to get NextText()")
+	}
+
+	t, err := p.Next()
+	if err != nil {
+		return "", err
+	}
+
+	if t != EndTag && t != Text {
+		return "", errors.New("Parser must be on EndTag or Text to read text")
+	}
+
+	var result string
+	for t == Text {
+		result = result + p.Text
+		t, err = p.Next()
+		if err != nil {
+			return "", err
+		}
+
+		if t != EndTag && t != Text {
+			errstr := fmt.Sprintf("Event Text must be immediately followed by EndTag or Text but got %s", p.EventName(t))
+			return "", errors.New(errstr)
+		}
+	}
+
+	return result, nil
+}
+
+func (p *XMLPullParser) Skip() error {
+	for {
+		tok, err := p.NextToken()
+		if err != nil {
+			return err
+		}
+		if tok == StartTag {
+			if err := p.Skip(); err != nil {
+				return err
+			}
+		} else if tok == EndTag {
+			return nil
+		}
+	}
+}
+
+func (p *XMLPullParser) Attribute(name string) string {
+	for _, attr := range p.Attrs {
+		if attr.Name.Local == name {
+			return attr.Value
+		}
+	}
+	return ""
+}
+
+func (p *XMLPullParser) Expect(event XMLEventType, name string) (err error) {
+	return p.ExpectAll(event, "*", name)
+}
+
+func (p *XMLPullParser) ExpectAll(event XMLEventType, space string, name string) (err error) {
+	if !(p.Event == event && (strings.ToLower(p.Space) == strings.ToLower(space) || space == "*") && (strings.ToLower(p.Name) == strings.ToLower(name) || name == "*")) {
+		err = fmt.Errorf("Expected Space:%s Name:%s Event:%s but got Space:%s Name:%s Event:%s at offset: %d", space, name, p.EventName(event), p.Space, p.Name, p.EventName(p.Event), p.decoder.InputOffset())
+	}
+	return
+}
+
+func (p *XMLPullParser) DecodeElement(v interface{}) error {
+	if p.Event != StartTag {
+		return errors.New("DecodeElement can only be called from a StartTag event")
+	}
+
+	//tok := &p.token
+
+	startToken := p.token.(xml.StartElement)
+
+	// Consumes all tokens until the matching end token.
+	err := p.decoder.DecodeElement(v, &startToken)
+	if err != nil {
+		return err
+	}
+
+	name := p.Name
+
+	// Need to set the "current" token name/event
+	// to the previous StartTag event's name
+	p.resetTokenState()
+	p.Event = EndTag
+	p.Depth--
+	p.Name = name
+	p.token = nil
+	return nil
+}
+
+func (p *XMLPullParser) IsWhitespace() bool {
+	return strings.TrimSpace(p.Text) == ""
+}
+
+func (p *XMLPullParser) EventName(e XMLEventType) (name string) {
+	switch e {
+	case StartTag:
+		name = "StartTag"
+	case EndTag:
+		name = "EndTag"
+	case StartDocument:
+		name = "StartDocument"
+	case EndDocument:
+		name = "EndDocument"
+	case ProcessingInstruction:
+		name = "ProcessingInstruction"
+	case Directive:
+		name = "Directive"
+	case Comment:
+		name = "Comment"
+	case Text:
+		name = "Text"
+	case IgnorableWhitespace:
+		name = "IgnorableWhitespace"
+	}
+	return
+}
+
+func (p *XMLPullParser) EventType(t xml.Token) (event XMLEventType) {
+	switch t.(type) {
+	case xml.StartElement:
+		event = StartTag
+	case xml.EndElement:
+		event = EndTag
+	case xml.CharData:
+		event = Text
+	case xml.Comment:
+		event = Comment
+	case xml.ProcInst:
+		event = ProcessingInstruction
+	case xml.Directive:
+		event = Directive
+	}
+	return
+}
+
+func (p *XMLPullParser) processToken(t xml.Token) {
+	switch tt := t.(type) {
+	case xml.StartElement:
+		p.processStartToken(tt)
+	case xml.EndElement:
+		p.processEndToken(tt)
+	case xml.CharData:
+		p.processCharDataToken(tt)
+	case xml.Comment:
+		p.processCommentToken(tt)
+	case xml.ProcInst:
+		p.processProcInstToken(tt)
+	case xml.Directive:
+		p.processDirectiveToken(tt)
+	}
+}
+
+func (p *XMLPullParser) processStartToken(t xml.StartElement) {
+	p.Depth++
+	p.Attrs = t.Attr
+	p.Name = t.Name.Local
+	p.Space = t.Name.Space
+	p.trackNamespaces(t)
+}
+
+func (p *XMLPullParser) processEndToken(t xml.EndElement) {
+	p.Depth--
+	p.SpacesStack = p.SpacesStack[:len(p.SpacesStack)-1]
+	if len(p.SpacesStack) == 0 {
+		p.Spaces = map[string]string{}
+	} else {
+		p.Spaces = p.SpacesStack[len(p.SpacesStack)-1]
+	}
+	p.Name = t.Name.Local
+}
+
+func (p *XMLPullParser) processCharDataToken(t xml.CharData) {
+	p.Text = string([]byte(t))
+}
+
+func (p *XMLPullParser) processCommentToken(t xml.Comment) {
+	p.Text = string([]byte(t))
+}
+
+func (p *XMLPullParser) processProcInstToken(t xml.ProcInst) {
+	p.Text = fmt.Sprintf("%s %s", t.Target, string(t.Inst))
+}
+
+func (p *XMLPullParser) processDirectiveToken(t xml.Directive) {
+	p.Text = string([]byte(t))
+}
+
+func (p *XMLPullParser) resetTokenState() {
+	p.Attrs = nil
+	p.Name = ""
+	p.Space = ""
+	p.Text = ""
+}
+
+func (p *XMLPullParser) trackNamespaces(t xml.StartElement) {
+	newSpace := map[string]string{}
+	for k, v := range p.Spaces {
+		newSpace[k] = v
+	}
+	for _, attr := range t.Attr {
+		if attr.Name.Space == "xmlns" {
+			space := strings.TrimSpace(attr.Value)
+			spacePrefix := strings.TrimSpace(strings.ToLower(attr.Name.Local))
+			newSpace[space] = spacePrefix
+		} else if attr.Name.Local == "xmlns" {
+			space := strings.TrimSpace(attr.Value)
+			newSpace[space] = ""
+		}
+	}
+	p.Spaces = newSpace
+	p.SpacesStack = append(p.SpacesStack, newSpace)
+}

+ 27 - 0
vendor/github.com/pmezard/go-difflib/LICENSE

@@ -0,0 +1,27 @@
+Copyright (c) 2013, Patrick Mezard
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+    Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+    The names of its contributors may not be used to endorse or promote
+products derived from this software without specific prior written
+permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Some files were not shown because too many files changed in this diff