├── www
├── .gitignore
├── static
│ ├── favicon.ico
│ ├── gopher.png
│ ├── bot-token.png
│ ├── app-creation.png
│ ├── bot-creation.png
│ ├── bot-privilege.png
│ ├── bot-permissions.png
│ └── apple-touch-icon.png
├── archetypes
│ └── default.md
├── content
│ ├── links.md
│ ├── commands.md
│ ├── credits.md
│ ├── introduction.md
│ ├── usage.md
│ ├── install.md
│ └── requirements.md
└── config.yml
├── Dockerfile.cgo
├── .github
├── images
│ └── gopher.png
└── workflows
│ ├── lint.yml
│ ├── publish.yml
│ └── release.yml
├── .dockerignore
├── .gitmodules
├── Dockerfile
├── models
├── website.go
└── db.go
├── .gitignore
├── go.mod
├── .goreleaser.yml
├── watcher
├── check.go
├── utils.go
├── watcher.go
├── task.go
└── commands.go
├── LICENSE
├── main.go
├── README.md
└── go.sum
/www/.gitignore:
--------------------------------------------------------------------------------
1 | public/
--------------------------------------------------------------------------------
/Dockerfile.cgo:
--------------------------------------------------------------------------------
1 | FROM golang:1.20.5
2 |
3 | COPY web-watcher /
4 |
5 | ENTRYPOINT ["/web-watcher"]
--------------------------------------------------------------------------------
/www/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/www/static/favicon.ico
--------------------------------------------------------------------------------
/www/static/gopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/www/static/gopher.png
--------------------------------------------------------------------------------
/.github/images/gopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/.github/images/gopher.png
--------------------------------------------------------------------------------
/www/static/bot-token.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/www/static/bot-token.png
--------------------------------------------------------------------------------
/www/archetypes/default.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "{{ replace .Name "-" " " | title }}"
3 | menu: true
4 | ---
5 |
6 |
--------------------------------------------------------------------------------
/www/static/app-creation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/www/static/app-creation.png
--------------------------------------------------------------------------------
/www/static/bot-creation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/www/static/bot-creation.png
--------------------------------------------------------------------------------
/www/static/bot-privilege.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/www/static/bot-privilege.png
--------------------------------------------------------------------------------
/www/static/bot-permissions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/www/static/bot-permissions.png
--------------------------------------------------------------------------------
/www/static/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shellbear/web-watcher/HEAD/www/static/apple-touch-icon.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .gitignore
3 | Dockerfile
4 | .DS_Store
5 | README.md
6 | LICENSE
7 | web-watcher
8 | db.sqlite
9 | dist/
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "www/themes/hugo-apex-theme"]
2 | path = www/themes/hugo-apex-theme
3 | url = https://github.com/caarlos0/hugo-apex-theme.git
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:latest
2 |
3 | WORKDIR /app
4 |
5 | COPY go.mod go.sum ./
6 | RUN go mod download
7 |
8 | COPY . .
9 | RUN go build -o web-watcher .
10 |
11 | ENTRYPOINT ["/app/web-watcher"]
12 |
13 |
--------------------------------------------------------------------------------
/www/content/links.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Links"
3 | menu: true
4 | weight: 0
5 | ---
6 |
7 | - Follow the progress on the [GitHub repository](https://github.com/shellbear/web-watcher)
8 | - Follow me on twitter [@_shellbear](https://twitter.com/_shellbear)
--------------------------------------------------------------------------------
/models/website.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "github.com/jinzhu/gorm"
5 | )
6 |
7 | // The default model used to store data about a web page.
8 | type Task struct {
9 | *gorm.Model
10 |
11 | URL string
12 | ChannelID string
13 | GuildID string
14 | Hash string
15 | Body []byte
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, build with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | .DS_Store
15 |
16 | db.sqlite
17 | web-watcher
18 | .idea/*
19 | vendor/
20 | dist/
--------------------------------------------------------------------------------
/models/db.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/jinzhu/gorm"
7 | )
8 |
9 | func New() (*gorm.DB, error) {
10 | db, err := gorm.Open("sqlite3", "db.sqlite")
11 | if err != nil {
12 | return nil, fmt.Errorf("failed to connect to models: %s", err)
13 | }
14 |
15 | if err = db.AutoMigrate(&Task{}).Error; err != nil {
16 | return nil, err
17 | }
18 |
19 | return db, nil
20 | }
21 |
--------------------------------------------------------------------------------
/www/content/commands.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Commands"
3 | menu: true
4 | weight: 5
5 | ---
6 |
7 | `!watch [URL]`
8 |
9 | Add a URL to the watchlist.
10 |
11 | `!unwatch [URL]`
12 |
13 | Remove a URL from the watchlist.
14 |
15 | `!watchlist`
16 |
17 | Get the complete watchlist.
18 |
19 |
20 | Note: you can customize the command prefix (**!**) with the `--prefix` parameter, for information, check the [usage](/web-watcher/usage) page.
21 |
--------------------------------------------------------------------------------
/www/config.yml:
--------------------------------------------------------------------------------
1 | ---
2 | baseURL: https://shellbear.github.io/web-watcher/
3 | languageCode: en-us
4 | title: Web-watcher
5 | copyright: Made with ❤️️ by shellbear.
6 | theme: hugo-apex-theme
7 | pygmentsCodeFences: true
8 | pygmentsStyle: dracula
9 | staticDir:
10 | - static
11 | params:
12 | logo: /web-watcher/gopher.png
13 | name: Web-watcher
14 | description: Receive alerts on website changes
15 | ghdocsrepo: https://github.com/shellbear/web-watcher/edit/master/www
16 |
--------------------------------------------------------------------------------
/www/content/credits.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Credits"
3 | menu: true
4 | weight: 0
5 | ---
6 |
7 | This bot is built with these awesome dependencies:
8 |
9 | - [go-difflib](https://github.com/pmezard/go-difflib) - Partial port of Python difflib package to Go
10 | - [xxhash](https://github.com/cespare/xxhash) - Go implementation of the 64-bit xxHash algorithm (XXH64)
11 | - [Gorm](https://github.com/jinzhu/gorm) - The fantastic ORM library for Golang
12 | - [DiscordGo](https://github.com/bwmarrin/discordgo) - Go bindings for Discord
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/shellbear/web-watcher
2 |
3 | go 1.20
4 |
5 | require (
6 | github.com/OneOfOne/xxhash v1.2.8
7 | github.com/bwmarrin/discordgo v0.27.1
8 | github.com/jinzhu/gorm v1.9.16
9 | github.com/pmezard/go-difflib v1.0.0
10 | golang.org/x/net v0.12.0
11 | )
12 |
13 | require (
14 | github.com/gorilla/websocket v1.5.0 // indirect
15 | github.com/jinzhu/inflection v1.0.0 // indirect
16 | github.com/mattn/go-sqlite3 v1.14.17 // indirect
17 | golang.org/x/crypto v0.11.0 // indirect
18 | golang.org/x/sys v0.10.0 // indirect
19 | )
20 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: lint
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | build:
9 | name: Build
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Set up Go 1.20
13 | uses: actions/setup-go@v1
14 | with:
15 | go-version: 1.20
16 | id: go
17 |
18 | - name: Check out code into the Go module directory
19 | uses: actions/checkout@v2
20 |
21 | - name: Build
22 | run: go build -v .
23 |
24 | - name: golangci-lint
25 | uses: golangci/golangci-lint-action@v3
26 | env:
27 | GOROOT: ""
28 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: github pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | defaults:
9 | run:
10 | shell: bash
11 | working-directory: www
12 |
13 | jobs:
14 | deploy:
15 | runs-on: ubuntu-18.04
16 | steps:
17 | - uses: actions/checkout@v2
18 | with:
19 | submodules: true
20 | fetch-depth: 0
21 |
22 | - name: Setup Hugo
23 | uses: peaceiris/actions-hugo@v2
24 | with:
25 | hugo-version: 'latest'
26 |
27 | - name: Build
28 | run: hugo --minify
29 |
30 | - name: Deploy
31 | uses: peaceiris/actions-gh-pages@v3
32 | with:
33 | github_token: ${{ secrets.GITHUB_TOKEN }}
34 | publish_dir: ./www/public
--------------------------------------------------------------------------------
/www/content/introduction.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Introduction"
3 | menu: true
4 | weight: 1
5 | ---
6 |
7 | I did this project because I wanted to track changes to a website.
8 | It helped me a lot, so I wanted to share my code with others.
9 |
10 | ## How it works?
11 |
12 | Currently, web-watcher only supports static HTML pages, it analyzes the structure of the page and the tags and defines a
13 | ratio which determines whether the page has changed or not.
14 | Thus, text changes are not detected, only changes in the structure of the page itself are detected.
15 |
16 | **Example**
17 |
18 | Not detected:
19 | ```html
20 | first.html
21 | ....
22 |
Hello world!
23 | ....
24 |
25 | second.html
26 | ....
27 | Goodbye world!
28 | ....
29 | ```
30 |
31 | Detected:
32 | ```html
33 | first.html
34 | ....
35 | Hello world!
36 | ....
37 |
38 | second.html
39 | ....
40 | Goodbye world!
41 | ....
42 | ```
43 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | jobs:
9 | goreleaser:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v3
14 | - name: Unshallow
15 | run: git fetch --prune --unshallow
16 | - name: Set up Go
17 | uses: actions/setup-go@v4
18 | with:
19 | go-version: 1.20.x
20 | - name: Docker login
21 | uses: azure/docker-login@v1
22 | with:
23 | login-server: docker.pkg.github.com
24 | username: ${{ secrets.DOCKER_USERNAME }}
25 | password: ${{ secrets.DOCKER_PASSWORD }}
26 | - name: Run GoReleaser
27 | uses: goreleaser/goreleaser-action@v4
28 | with:
29 | version: latest
30 | args: release --clean
31 | env:
32 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
33 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | before:
2 | hooks:
3 | - go mod download
4 |
5 | checksum:
6 | name_template: "checksums.txt"
7 | snapshot:
8 | name_template: "{{ .Tag }}-next"
9 | changelog:
10 | sort: asc
11 | filters:
12 | exclude:
13 | - "^docs:"
14 | - "^test:"
15 |
16 | release:
17 | github:
18 | owner: shellbear
19 | name: web-watcher
20 |
21 | dockers:
22 | - ids:
23 | - web-watcher
24 | dockerfile: Dockerfile.cgo
25 | image_templates:
26 | - "docker.pkg.github.com/shellbear/web-watcher/web-watcher:{{ .Tag }}"
27 | - "docker.pkg.github.com/shellbear/web-watcher/web-watcher:latest"
28 | build_flag_templates:
29 | - "--pull"
30 | - "--label=org.opencontainers.image.created={{.Date}}"
31 | - "--label=org.opencontainers.image.name={{.ProjectName}}"
32 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
33 | - "--label=org.opencontainers.image.version={{.Version}}"
34 |
--------------------------------------------------------------------------------
/watcher/check.go:
--------------------------------------------------------------------------------
1 | package watcher
2 |
3 | import (
4 | "bytes"
5 | "compress/zlib"
6 | "log"
7 |
8 | "github.com/pmezard/go-difflib/difflib"
9 | "golang.org/x/net/html"
10 |
11 | "github.com/shellbear/web-watcher/models"
12 | )
13 |
14 | func (w *Watcher) checkChanges(task *models.Task, body []byte) (bool, error) {
15 | if task.Body == nil {
16 | return true, nil
17 | }
18 |
19 | r, err := zlib.NewReader(bytes.NewBuffer(task.Body))
20 | if err != nil {
21 | return false, err
22 | }
23 |
24 | defer r.Close()
25 |
26 | previousHTML, err := html.Parse(r)
27 | if err != nil {
28 | return false, err
29 | }
30 |
31 | newHTML, err := html.Parse(bytes.NewBuffer(body))
32 | if err != nil {
33 | return false, err
34 | }
35 |
36 | matcher := difflib.NewMatcher(extractTags(previousHTML), extractTags(newHTML))
37 | ratio := matcher.Ratio()
38 |
39 | if ratio < w.ChangeRatio {
40 | log.Printf("Changes detected for: %s. Changes ratio: %f < %f\n", task.URL, ratio, w.ChangeRatio)
41 | return true, nil
42 | }
43 |
44 | log.Println("No changed detected for:", task.URL)
45 | return false, nil
46 | }
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Shellbear
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/watcher/utils.go:
--------------------------------------------------------------------------------
1 | package watcher
2 |
3 | import (
4 | "errors"
5 |
6 | "golang.org/x/net/html"
7 | )
8 |
9 | // A simple helper function to iterate over HTML node.
10 | func crawlDocument(node *html.Node, handler func(node *html.Node) bool) bool {
11 | if handler(node) {
12 | return true
13 | }
14 |
15 | for child := node.FirstChild; child != nil; child = child.NextSibling {
16 | if crawlDocument(child, handler) {
17 | return true
18 | }
19 | }
20 |
21 | return false
22 | }
23 |
24 | // Extract all tags from HTML page.
25 | func extractTags(doc *html.Node) []string {
26 | var tags []string
27 | crawlDocument(doc, func(node *html.Node) bool {
28 | if node.Type == html.ElementNode {
29 | tags = append(tags, node.Data)
30 | }
31 |
32 | return false
33 | })
34 |
35 | return tags
36 | }
37 |
38 | // Extract body from HTML page.
39 | func getBody(doc *html.Node) (*html.Node, error) {
40 | var body *html.Node
41 | crawlDocument(doc, func(node *html.Node) bool {
42 | if node.Type == html.ElementNode && node.Data == "body" {
43 | body = node
44 | return true
45 | }
46 |
47 | return false
48 | })
49 |
50 | if body != nil {
51 | return body, nil
52 | }
53 | return nil, errors.New("missing in the node tree")
54 | }
55 |
--------------------------------------------------------------------------------
/www/content/usage.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Usage"
3 | menu: true
4 | weight: 4
5 | ---
6 |
7 | You can pass customize some options.
8 |
9 | ```sh
10 | > web-watcher --help
11 | Web-watcher discord Bot.
12 |
13 | Options:
14 | -interval int
15 | The watcher interval in minutes (default 60)
16 | -prefix string
17 | The discord commands prefix (default "!")
18 | -ratio float
19 | Changes detection ratio (default 1)
20 | -token string
21 | Discord token
22 |
23 | ```
24 |
25 | ## Arguments
26 |
27 | `--interval`
28 |
29 | The watcher interval in minutes (default 60).
30 |
31 | The watcher will check for website changes at this given interval.
32 |
33 | `--prefix`
34 |
35 | The discord commands prefix (default !).
36 |
37 | `--ratio`
38 |
39 | The web page changes ratio. Must be between 0.0 and 1.0.
40 |
41 | Every x minutes the watcher will fetch the website page and compares it with the previous version. It will check changes
42 | and convert these changes to a ratio. If page are identical, this ratio is equals to 1.0, and it will decrease for every
43 | detected change.
44 |
45 | `--token`
46 |
47 | The discord bot token. The token can also be passed with the `DISCORD_TOKEN` environment variable.
48 |
49 | If you don't know how to generate one, a quick tutorial describes all the steps in the [requirements page](/web-watcher/requirements/).
--------------------------------------------------------------------------------
/www/content/install.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Install"
3 | menu: true
4 | weight: 3
5 | ---
6 |
7 | You can install the pre-compiled binary (in several different ways), use Docker or compile from source.
8 |
9 | Here are the steps for each of them:
10 |
11 | ## Install the pre-compiled binary
12 |
13 | Download the pre-compiled binaries from the [releases page](https://github.com/shellbear/web-watcher/releases) and
14 | copy to the desired location.
15 |
16 | ## Running with Docker
17 |
18 | You can also run it within a Docker container. Here as follows an example command:
19 |
20 | ```sh
21 | export DISCORD_TOKEN=XXXXXXXXX....
22 | ```
23 |
24 | ```sh
25 | docker run -d \
26 | -e DISCORD_TOKEN \
27 | -v web-watcher-data:/app \
28 | --name web-watcher \
29 | docker.pkg.github.com/shellbear/web-watcher/web-watcher
30 | ```
31 |
32 | The container is based on latest Go docker image.
33 |
34 | ## Compiling from source
35 |
36 | If you feel adventurous you can compile the code from source:
37 |
38 | ```sh
39 | git clone https://github.com/shellbear/web-watcher.git
40 | cd web-watcher
41 |
42 | # get dependencies using go modules (needs go 1.11+)
43 | go get ./...
44 |
45 | # build
46 | go build -o web-watcher .
47 |
48 | # check it works
49 | ./web-watcher
50 | ```
--------------------------------------------------------------------------------
/www/content/requirements.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Requirements"
3 | menu: true
4 | weight: 2
5 | ---
6 |
7 | First of all, you have to create a new discord bot, generate a token and add the bot to the server of your choice.
8 |
9 | If you don't know how to so, here are some few steps:
10 |
11 | Go to the [discord developer portal](https://discordapp.com/developers/applications) and create a new application with the name of your choice:
12 |
13 | 
14 |
15 | Click on the `Bot` tab on the left and create a new bot:
16 |
17 | 
18 |
19 | Then go to the `OAuth2` tab, check the `Bot` scope and the `Send messages` permission:
20 |
21 | 
22 |
23 | **⚠️ Recent Update**
24 |
25 | Due to Discord API changes, you must enable `Message Content Intent` privilege for the bot to work correctly.
26 |
27 | 
28 |
29 | Then, copy the generated URL in the middle of the screen, open it and add the bot to the server of your choice.
30 |
31 | Congrats, you added the bot to your discord server!
32 |
33 | Now all you have to do is to obtain your secret token. This is the token that will be used by web-watcher to connect to
34 | discord with the `--token` option or `DISCORD_TOKEN` environment variable. For more details check the [usage page](/web-watcher/usage).
35 |
36 | Return to the `Bot` tab and click on `Click to Reveal Token`:
37 |
38 | 
39 |
40 | Copy it for the next steps and make sure to keep this token secret!
41 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 | "os/signal"
9 | "syscall"
10 | "time"
11 |
12 | _ "github.com/jinzhu/gorm/dialects/sqlite"
13 |
14 | "github.com/shellbear/web-watcher/watcher"
15 | )
16 |
17 | var (
18 | watchInterval int64
19 | changeRatio float64
20 | discordToken string
21 | commandPrefix string
22 | )
23 |
24 | func init() {
25 | flag.Int64Var(&watchInterval, "interval", int64(time.Hour.Minutes()), "The watcher interval in minutes")
26 | flag.StringVar(&discordToken, "token", "", "Discord token")
27 | flag.Float64Var(&changeRatio, "ratio", 1.0, "Changes detection ratio")
28 | flag.StringVar(&commandPrefix, "prefix", "!", "The discord commands prefix")
29 |
30 | flag.Usage = usage
31 | flag.Parse()
32 |
33 | if discordToken == "" {
34 | if token := os.Getenv("DISCORD_TOKEN"); token == "" {
35 | log.Fatalln("you must provide a discord token")
36 | } else {
37 | discordToken = token
38 | }
39 | }
40 |
41 | if changeRatio <= 0 || changeRatio > 1 {
42 | log.Fatalln("change ratio must be between 0 and 1")
43 | }
44 | }
45 |
46 | func usage() {
47 | fmt.Println("Web-watcher discord Bot.\n\nOptions:")
48 | flag.PrintDefaults()
49 | }
50 |
51 | func main() {
52 | instance, err := watcher.New(time.Duration(watchInterval)*time.Minute, changeRatio, discordToken, commandPrefix)
53 | if err != nil {
54 | log.Fatalln(err)
55 | }
56 |
57 | defer func() {
58 | instance.DB.Close()
59 | instance.Session.Close()
60 | }()
61 |
62 | if err := instance.Run(); err != nil {
63 | log.Fatalln("failed to run tasks:", err)
64 | }
65 |
66 | log.Println("Bot is now running. Press CTRL-C to exit.")
67 |
68 | sc := make(chan os.Signal, 1)
69 | signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
70 | <-sc
71 | log.Println("Gracefully stopping bot...")
72 | }
73 |
--------------------------------------------------------------------------------
/watcher/watcher.go:
--------------------------------------------------------------------------------
1 | package watcher
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/bwmarrin/discordgo"
9 | "github.com/jinzhu/gorm"
10 | "github.com/shellbear/web-watcher/models"
11 | "golang.org/x/net/context"
12 | )
13 |
14 | type Watcher struct {
15 | // The database instance.
16 | DB *gorm.DB
17 |
18 | // The discord session.
19 | Session *discordgo.Session
20 |
21 | // A custom HTTP client.
22 | Client *http.Client
23 |
24 | // The discord command Prefix. Usually '!'.
25 | Prefix string
26 |
27 | // The web page changes ratio. Must be between 0 and 1.
28 | // Every x minutes the watcher will fetch the website page and compares it with the previous version.
29 | // It will check changes and convert these changes to a ratio. If page are identical, this ratio is equals to 1.0,
30 | // and it will decrease for every detected change.
31 | ChangeRatio float64
32 |
33 | // The WatchInterval determines the interval at which the watcher will crawl web pages.
34 | WatchInterval time.Duration
35 |
36 | // A list of running tasks.
37 | Tasks map[string]context.CancelFunc
38 | }
39 |
40 | func New(watchInterval time.Duration, changeRatio float64, discordToken string, prefix string) (*Watcher, error) {
41 | db, err := models.New()
42 | if err != nil {
43 | return nil, fmt.Errorf("failed to init database: %s", err)
44 | }
45 |
46 | session, err := discordgo.New("Bot " + discordToken)
47 | if err != nil {
48 | return nil, fmt.Errorf("failed to connect to discord: %s", err)
49 | }
50 |
51 | session.Client = &http.Client{Timeout: time.Second * 15}
52 | w := Watcher{
53 | DB: db,
54 | Session: session,
55 | Prefix: prefix,
56 | WatchInterval: watchInterval,
57 | ChangeRatio: changeRatio,
58 | Client: session.Client,
59 | Tasks: map[string]context.CancelFunc{},
60 | }
61 |
62 | session.AddHandlerOnce(w.onReady)
63 | session.AddHandler(w.onNewMessage)
64 |
65 | return &w, nil
66 | }
67 |
68 | // Fetch existing tasks in database and run them, then connect to Discord API.
69 | func (w *Watcher) Run() error {
70 | var tasks []models.Task
71 | if err := w.DB.Find(&tasks).Error; err != nil {
72 | return fmt.Errorf("failed to fetch existing tasks: %s", err)
73 | }
74 |
75 | for i := range tasks {
76 | w.NewTask(&tasks[i])
77 | }
78 |
79 | return w.Session.Open()
80 | }
81 |
82 | // Update task in database and alert sender on Discord.
83 | func (w *Watcher) updateTask(task *models.Task, hash string, body []byte) error {
84 | if _, err := w.Session.ChannelMessageSend(
85 | task.ChannelID,
86 | fmt.Sprintf("%s has been updated! Last update : %s", task.URL, task.UpdatedAt.Format(updateFormat)),
87 | ); err != nil {
88 | return err
89 | }
90 |
91 | return w.DB.Model(task).Updates(&models.Task{
92 | Hash: hash,
93 | Body: body,
94 | }).Error
95 | }
96 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
web-watcher
4 | A small Discord bot which aims to alert you on website changes.
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ---
24 |
25 | ## Recent Update
26 |
27 | Due to Discord API changes, please make sure to enable your Discord Bot `Message Content Intent` permission to work and be able to read Discord commands.
28 |
29 | 
30 |
31 | More instructions here: [https://shellbear.github.io/web-watcher/requirements/](https://shellbear.github.io/web-watcher/requirements/)
32 |
33 | ---
34 |
35 | ## Documentation
36 |
37 | A full documentation is available at [https://shellbear.github.io/web-watcher](https://shellbear.github.io/web-watcher).
38 |
39 | ## Usage
40 |
41 | ```bash
42 | > web-watcher --help
43 | Web-watcher discord Bot.
44 |
45 | Options:
46 | -delay int
47 | Watch delay in minutes (default 60)
48 | -prefix string
49 | The discord commands prefix (default "!")
50 | -ratio float
51 | Changes detection ratio (default 1)
52 | -token string
53 | Discord token
54 | ```
55 |
56 | By default, the watch interval for every website is 1 hour, but you can easily change this with the `interval` parameter
57 | followed by the interval in minutes.
58 |
59 | ```bash
60 | export DISCORD_TOKEN=YOUR_DISCORD_TOKEN
61 | # Set watch interval to 10 minutes, defaults to 60 minutes
62 | web-watcher --interval 10
63 |
64 | # OR
65 |
66 | # Set watch interval to 10 minutes, defaults to 60 minutes
67 | web-watcher --token YOUR_DISCORD_TOKEN --interval 10
68 | ```
69 |
70 | ## Discord commands
71 |
72 | #### !watch [URL]
73 |
74 | Add a URL to the watchlist.
75 |
76 | #### !unwatch [URL]
77 |
78 | Remove a URL from the watchlist.
79 |
80 | #### !watchlist
81 |
82 | Get the complete watchlist.
83 |
84 | ## Built With
85 |
86 | - [go-difflib](https://github.com/pmezard/go-difflib) - Partial port of Python difflib package to Go
87 | - [xxhash](https://github.com/cespare/xxhash) - Go implementation of the 64-bit xxHash algorithm (XXH64)
88 | - [Gorm](https://github.com/jinzhu/gorm) - The fantastic ORM library for Golang
89 | - [DiscordGo](https://github.com/bwmarrin/discordgo) - Go bindings for Discord
90 |
91 | ## License
92 |
93 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
94 |
--------------------------------------------------------------------------------
/watcher/task.go:
--------------------------------------------------------------------------------
1 | package watcher
2 |
3 | import (
4 | "bytes"
5 | "compress/zlib"
6 | "context"
7 | "fmt"
8 | "io"
9 | "log"
10 | "net/http"
11 | "strconv"
12 | "time"
13 |
14 | "github.com/OneOfOne/xxhash"
15 | "github.com/shellbear/web-watcher/models"
16 | "golang.org/x/net/html"
17 | )
18 |
19 | const updateFormat = "2006-01-02 15:04:05"
20 |
21 | // Add a new task to the list and run it.
22 | // If a task already exits for the given URL then cancel it and override it.
23 | func (w *Watcher) NewTask(task *models.Task) {
24 | taskName := task.URL + task.ChannelID
25 | ctx, cancel := context.WithCancel(context.Background())
26 |
27 | if cancel, ok := w.Tasks[taskName]; ok {
28 | cancel()
29 | }
30 |
31 | w.Tasks[taskName] = cancel
32 |
33 | go func() {
34 | if err := w.runTask(ctx, task); err != nil {
35 | log.Printf("Failed to run task for %s. Error: %s\n", task.URL, err)
36 | }
37 | }()
38 | }
39 |
40 | // Extract body and generate a unique hash for the web page.
41 | // This hash will be used to speed up future page comparisons.
42 | func (w *Watcher) getHash(resp *http.Response) (string, []byte, error) {
43 | xxHash := xxhash.New64()
44 |
45 | // Parse page as HTML.
46 | doc, err := html.Parse(resp.Body)
47 | if err != nil {
48 | return "", nil, err
49 | }
50 |
51 | // Try to parse only the content of body by default.
52 | if bn, err := getBody(doc); err == nil {
53 | var buf bytes.Buffer
54 |
55 | if err := html.Render(io.Writer(&buf), bn); err != nil {
56 | return "", nil, err
57 | }
58 |
59 | body := buf.Bytes()
60 | // Create a unique hash for the page content.
61 | if _, err := xxHash.Write(body); err != nil {
62 | return "", nil, err
63 | }
64 |
65 | return strconv.FormatUint(xxHash.Sum64(), 10), body, nil
66 | }
67 |
68 | // Create a unique hash for the page content.
69 | hash := []byte{}
70 | if _, err := xxHash.Write(hash); err != nil {
71 | return "", nil, err
72 | }
73 |
74 | return strconv.FormatUint(xxHash.Sum64(), 10), hash, nil
75 | }
76 |
77 | // Analyze page structure, extract tags and check difference ratio between changes.
78 | func (w *Watcher) hasChanged(task *models.Task, body []byte, hash string) (bool, error) {
79 | // Skip furthers checks if hashes are identical.
80 | if task.Hash == hash {
81 | log.Println("Hashes are identical. Skipping further checks...")
82 | return false, nil
83 | }
84 |
85 | // Check if web page has changed.
86 | updated, err := w.checkChanges(task, body)
87 | if err != nil {
88 | return false, err
89 | }
90 |
91 | if updated {
92 | // Compress body to decrease size in database.
93 | var b bytes.Buffer
94 | compress := zlib.NewWriter(&b)
95 |
96 | if _, err = compress.Write(body); err != nil {
97 | return false, err
98 | }
99 |
100 | if err = compress.Close(); err != nil {
101 | return false, err
102 | }
103 |
104 | if err := w.updateTask(task, hash, b.Bytes()); err != nil {
105 | return false, err
106 | }
107 |
108 | return true, nil
109 | }
110 |
111 | return true, nil
112 | }
113 |
114 | func (w *Watcher) analyzeChanges(task *models.Task) error {
115 | resp, err := http.Get(task.URL)
116 | if err != nil {
117 | return fmt.Errorf("failed to fetch task URL: %s", err)
118 | }
119 |
120 | defer resp.Body.Close()
121 |
122 | hash, body, err := w.getHash(resp)
123 | if err != nil {
124 | return fmt.Errorf("failed to parse and generate hash for page. Error: %s", err)
125 | }
126 |
127 | updated, err := w.hasChanged(task, body, hash)
128 | if err != nil {
129 | return fmt.Errorf("failed to check page changes: %s", err)
130 | }
131 |
132 | if updated {
133 | log.Printf("%s has been updated.\n", task.URL)
134 | }
135 |
136 | return nil
137 | }
138 |
139 | // Run the task every X minutes.
140 | func (w *Watcher) runTask(ctx context.Context, task *models.Task) error {
141 | log.Println("Crawling:", task.URL)
142 |
143 | if err := w.analyzeChanges(task); err != nil {
144 | fmt.Println(err)
145 | }
146 |
147 | select {
148 | case <-time.After(w.WatchInterval):
149 | if err := w.DB.Find(task, task.ID).Error; err != nil {
150 | return err
151 | }
152 |
153 | return w.runTask(ctx, task)
154 | case <-ctx.Done():
155 | log.Println("Stopped task for", task.URL)
156 | return nil
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/watcher/commands.go:
--------------------------------------------------------------------------------
1 | package watcher
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/url"
7 | "strings"
8 |
9 | "github.com/bwmarrin/discordgo"
10 | "github.com/shellbear/web-watcher/models"
11 | )
12 |
13 | // The default handler to use when a new message is sent.
14 | func (w *Watcher) onNewMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
15 | args := strings.Split(strings.TrimSpace(m.Content), " ")
16 | if m.Author.ID == s.State.User.ID || len(args) == 0 {
17 | return
18 | }
19 |
20 | var err error
21 | switch args[0] {
22 | case w.Prefix + "watch":
23 | _, err = w.watch(s, m, args)
24 | case w.Prefix + "unwatch":
25 | _, err = w.unwatch(s, m, args)
26 | case w.Prefix + "watchlist":
27 | _, err = w.watchList(s, m, args)
28 | }
29 |
30 | if err != nil {
31 | log.Printf("Failed to execute command '%s'. Error: %s\n", args[0], err)
32 | }
33 | }
34 |
35 | // The watch discord command handler.
36 | // Used to add a task to the list.
37 | func (w *Watcher) watch(s *discordgo.Session, m *discordgo.MessageCreate, args []string) (*discordgo.Message, error) {
38 | if len(args) != 2 {
39 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" Usage: watch URL")
40 | }
41 |
42 | u, err := url.ParseRequestURI(args[1])
43 | if err != nil || u.String() == "" {
44 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" provided URL is invalid")
45 | }
46 |
47 | var task models.Task
48 | if !w.DB.Where("url = ? AND guild_id = ?", u.String(), m.GuildID).First(&task).RecordNotFound() {
49 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" this URL has already been registered")
50 | }
51 |
52 | task.URL = u.String()
53 | task.GuildID = m.GuildID
54 | task.ChannelID = m.ChannelID
55 | if err := w.DB.Create(&task).Error; err != nil {
56 | return nil, err
57 | }
58 |
59 | w.NewTask(&task)
60 |
61 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" successfully registered URL")
62 | }
63 |
64 | // The unwatch discord command handler.
65 | // Used to remove a task from the list.
66 | func (w *Watcher) unwatch(s *discordgo.Session, m *discordgo.MessageCreate, args []string) (*discordgo.Message, error) {
67 | if len(args) != 2 {
68 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" Usage: unwatch URL")
69 | }
70 |
71 | u, err := url.ParseRequestURI(args[1])
72 | if err != nil {
73 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" provided URL is invalid")
74 | }
75 |
76 | var task models.Task
77 | if w.DB.Where("url = ? AND guild_id = ?", u.String(), m.GuildID).First(&task).RecordNotFound() {
78 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" URL doesn't exist")
79 | }
80 |
81 | taskName := task.URL + task.ChannelID
82 | if cancel, ok := w.Tasks[taskName]; ok {
83 | cancel()
84 | delete(w.Tasks, taskName)
85 | }
86 |
87 | if err := w.DB.Delete(&task).Error; err != nil {
88 | return nil, err
89 | }
90 |
91 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" successfully deleted URL")
92 | }
93 |
94 | // The watchlist discord command handler.
95 | // Used to retrieve the list of tasks.
96 | func (w *Watcher) watchList(s *discordgo.Session, m *discordgo.MessageCreate, args []string) (*discordgo.Message, error) {
97 | var tasks []models.Task
98 |
99 | if err := w.DB.Where("guild_id = ?", m.GuildID).Find(&tasks).Error; err != nil {
100 | return nil, err
101 | }
102 |
103 | if len(tasks) == 0 {
104 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+"There is no registered URL. Add one with `watch` command")
105 | }
106 |
107 | var urls []string
108 | for i, task := range tasks {
109 | urls = append(urls, fmt.Sprintf("%d - %s", i+1, task.URL))
110 | }
111 |
112 | return s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+"\n"+strings.Join(urls, "\n"))
113 | }
114 |
115 | // The ready discord handler.
116 | // Used to set the bot status.
117 | func (w *Watcher) onReady(discord *discordgo.Session, ready *discordgo.Ready) {
118 | if err := discord.UpdateGameStatus(0, "Looking at other people's websites"); err != nil {
119 | log.Fatalln("Error attempting to set my status,", err)
120 | }
121 |
122 | log.Printf("Web-watcher has started on %d servers\n", len(discord.State.Guilds))
123 | log.Printf("Inspecting websites every %d minutes", int(w.WatchInterval.Minutes()))
124 | }
125 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
2 | github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
3 | github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
4 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
5 | github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
6 | github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
7 | github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
8 | github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
9 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
10 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
11 | github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
12 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
13 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
14 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
15 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
16 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
17 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
18 | github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
19 | github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
20 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
21 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
22 | github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
23 | github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
24 | github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
25 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
26 | github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
27 | github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
28 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
31 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
32 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
33 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
34 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
35 | golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
36 | golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
37 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
38 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
39 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
40 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
41 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
42 | golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
43 | golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
44 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
45 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
46 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
47 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
48 | golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
49 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
50 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
51 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
52 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
53 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
54 |
--------------------------------------------------------------------------------