├── 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 | ![App creation](/web-watcher/app-creation.png) 14 | 15 | Click on the `Bot` tab on the left and create a new bot: 16 | 17 | ![Bot creation](/web-watcher/bot-creation.png) 18 | 19 | Then go to the `OAuth2` tab, check the `Bot` scope and the `Send messages` permission: 20 | 21 | ![Bot permissions](/web-watcher/bot-permissions.png) 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 | ![Bot privilege](/web-watcher/bot-privilege.png) 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 | ![Bot token](/web-watcher/bot-token.png) 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 | Gopher 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 | Go version 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 | ![Bot Privilege](www/static/bot-privilege.png) 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 | --------------------------------------------------------------------------------