├── web ├── static │ ├── script.ts │ ├── script.js │ ├── favicon.ico │ ├── script.js.map │ ├── style.css │ ├── backup.js.map │ ├── backup.js │ ├── create.js.map │ ├── backup.ts │ ├── create.js │ ├── create.ts │ ├── diagram.js.map │ └── diagram.ts ├── templates │ ├── 500.html │ ├── cache │ │ └── view.html │ ├── notifiers.html │ ├── index.html │ ├── backup │ │ ├── test.html │ │ ├── restore.html │ │ └── view.html │ ├── schedules.html │ ├── watch │ │ ├── create.html │ │ ├── view.html │ │ └── edit.html │ └── base.html ├── web_test.go ├── config.tmpl ├── util.go └── watchTemplates │ ├── Amazon.json │ ├── Etsy.json │ ├── NewEgg.json │ ├── Ebay.json │ └── Tweakers.json ├── docs ├── images │ └── 4090_watch.png ├── compose │ ├── gowatch.yml │ ├── apprise.yml │ ├── browserless.yml │ ├── tor.yml │ ├── apprise-browserless.yml │ ├── browserless-tor.yml │ ├── postgresql.yml │ ├── apprise-browserless-postgresql.yml │ ├── apprise-browserless-postgresql-tor.yml │ └── auth.yml └── proxy │ ├── docker-compose-proxy-test.yml │ ├── squid-1.conf │ └── squid-2.conf ├── todo.md ├── notifiers ├── notifier.go ├── shoutrrr.go ├── file.go └── apprise.go ├── models ├── export.go ├── backup.go ├── watch.go ├── expect.go ├── filteroutput.go ├── connection.go └── filter.go ├── docker-compose.yml ├── _config.yml ├── tsconfig.json ├── .gitignore ├── Dockerfile ├── .air.toml ├── LICENSE ├── .drone.yaml ├── main.go ├── .github └── workflows │ ├── build-binaries.yml │ └── docker-publish.yml ├── go.mod └── README.md /web/static/script.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/static/script.js: -------------------------------------------------------------------------------- 1 | //# sourceMappingURL=script.js.map -------------------------------------------------------------------------------- /web/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/broodjeaap/go-watch/HEAD/web/static/favicon.ico -------------------------------------------------------------------------------- /docs/images/4090_watch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/broodjeaap/go-watch/HEAD/docs/images/4090_watch.png -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # Todo 2 | - fix watchTemplates after filter.Var2 change 3 | - fix schedules layout, h5 schedule td ? -------------------------------------------------------------------------------- /web/static/script.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"script.js","sourceRoot":"","sources":["script.ts"],"names":[],"mappings":""} -------------------------------------------------------------------------------- /notifiers/notifier.go: -------------------------------------------------------------------------------- 1 | package notifiers 2 | 3 | type Notifier interface { 4 | Open(configPath string) bool 5 | Message(message string) bool 6 | Close() bool 7 | } 8 | -------------------------------------------------------------------------------- /models/export.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type WatchExport struct { 4 | Filters []Filter `json:"filters"` 5 | Connections []FilterConnection `json:"connections"` 6 | } 7 | -------------------------------------------------------------------------------- /docs/compose/gowatch.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | volumes: 8 | - ./:/config 9 | ports: 10 | - "8080:8080" -------------------------------------------------------------------------------- /web/templates/500.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch Error 3 | {{end}} 4 | 5 | {{define "content"}} 6 | {{ if .message }} 7 |

{{ .message }}

8 | {{ else }} 9 |

Something went wrong, try again.

10 | {{ end }} 11 | {{end}} -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | target: base 9 | container_name: go-watch 10 | volumes: 11 | - ./:/config 12 | ports: 13 | - "8080:8080" -------------------------------------------------------------------------------- /models/backup.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Backup struct { 4 | Watches []Watch `json:"watches"` 5 | Filters []Filter `json:"filters"` 6 | Connections []FilterConnection `json:"connections"` 7 | Values []FilterOutput `json:"values"` 8 | } 9 | -------------------------------------------------------------------------------- /docs/compose/apprise.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | volumes: 8 | - /host/path/to/config:/config 9 | ports: 10 | - "8080:8080" 11 | apprise: 12 | image: caronc/apprise:latest -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | lsi: false 2 | remote_theme: pages-themes/hacker@v0.2.0 3 | plugins: 4 | - jekyll-remote-theme # add this line to the plugins list if you already have one 5 | show_downloads: true 6 | safe: true 7 | source: docs/ 8 | incremental: false 9 | highlighter: rouge 10 | gist: 11 | noscript: false 12 | kramdown: 13 | math_engine: mathjax 14 | syntax_highlighter: rouge 15 | -------------------------------------------------------------------------------- /docs/compose/browserless.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | environment: 8 | - GOWATCH_BROWSERLESS_URL=http://browserless:3000/content 9 | volumes: 10 | - /host/path/to/config:/config 11 | ports: 12 | - "8080:8080" 13 | browserless: 14 | image: browserless/chrome:latest -------------------------------------------------------------------------------- /models/watch.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/robfig/cron/v3" 5 | ) 6 | 7 | type WatchID uint 8 | type Watch struct { 9 | ID WatchID `form:"watch_id" yaml:"watch_id"` 10 | Name string `form:"watch_name" gorm:"index" yaml:"watch_name" binding:"required" validate:"min=1"` 11 | CronEntry *cron.Entry `gorm:"-:all"` 12 | LastValue string `gorm:"-:all"` 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES5", 5 | "lib": ["ES2020", "dom"], 6 | "noImplicitAny": false, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "sourceMap": true, 10 | "downlevelIteration": true 11 | }, 12 | "include": [ 13 | "web/static/*.ts" 14 | ], 15 | } -------------------------------------------------------------------------------- /docs/compose/tor.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | environment: 8 | - HTTP_PROXY=http://tor-privoxy:8118 9 | - HTTPS_PROXY=http://tor-privoxy:8118 10 | volumes: 11 | - ./tmp:/config 12 | ports: 13 | - "8080:8080" 14 | tor-privoxy: 15 | image: dockage/tor-privoxy 16 | container_name: tor-privoxy -------------------------------------------------------------------------------- /models/expect.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type ExcpectFailID uint 6 | type ExpectFail struct { 7 | ID ExcpectFailID `yaml:"expect_fail_id" json:"expect_fail_id"` 8 | WatchID WatchID `yaml:"expect_fail_watch_id" gorm:"index" json:"expect_fail_watch_id"` 9 | Name string `yaml:"expect_fail_name" json:"expect_fail_name"` 10 | Time time.Time `yaml:"expect_fail_time" json:"expect_fail_time"` 11 | } 12 | -------------------------------------------------------------------------------- /docs/compose/apprise-browserless.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | environment: 8 | - GOWATCH_BROWSERLESS_URL=http://browserless:3000/content 9 | volumes: 10 | - /host/path/to/config:/config 11 | ports: 12 | - "8080:8080" 13 | apprise: 14 | image: caronc/apprise:latest 15 | browserless: 16 | image: browserless/chrome:latest -------------------------------------------------------------------------------- /models/filteroutput.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type FilterOutputID uint 6 | type FilterOutput struct { 7 | ID FilterOutputID `yaml:"filter_output_id" json:"filter_output_id"` 8 | WatchID WatchID `yaml:"filter_output_watch_id" gorm:"index" json:"filter_output_watch_id"` 9 | Name string `yaml:"filter_output_name" json:"filter_output_name"` 10 | Value string `yaml:"filter_output_value" json:"filter_output_value"` 11 | Time time.Time `yaml:"filter_output_time" json:"filter_output_time"` 12 | } 13 | -------------------------------------------------------------------------------- /web/templates/cache/view.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch URL Cache 3 | {{end}} 4 | 5 | {{define "content"}} 6 | {{ range $url, $content := .cache }} 7 |
8 |
9 | {{ $url }} 10 |
11 | 12 | 13 |
14 |
15 |
16 | {{ $content }} 17 |
18 |
19 | {{ end }} 20 | {{end}} -------------------------------------------------------------------------------- /models/connection.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type FilterConnectionID uint 4 | type FilterConnection struct { 5 | ID FilterConnectionID `form:"filter_connection_id" yaml:"filter_connection_id" json:"filter_connection_id"` 6 | WatchID WatchID `form:"connection_watch_id" gorm:"index" yaml:"connection_watch_id" json:"connection_watch_id" binding:"required"` 7 | OutputID FilterID `form:"filter_output_id" gorm:"index" yaml:"filter_output_id" json:"filter_output_id" binding:"required"` 8 | InputID FilterID `form:"filter_input_id" gorm:"index" yaml:"filter_input_id" json:"filter_input_id" binding:"required"` 9 | } 10 | -------------------------------------------------------------------------------- /web/static/style.css: -------------------------------------------------------------------------------- 1 | .bd-placeholder-img { 2 | font-size: 1.125rem; 3 | text-anchor: middle; 4 | -webkit-user-select: none; 5 | -moz-user-select: none; 6 | user-select: none; 7 | } 8 | 9 | @media (min-width: 768px) { 10 | .bd-placeholder-img-lg { 11 | font-size: 3.5rem; 12 | } 13 | } 14 | /* Show it is fixed to the top */ 15 | body { 16 | min-height: 75rem; 17 | padding-top: 4.5rem; 18 | } 19 | 20 | .pointer { 21 | cursor: pointer; 22 | } 23 | 24 | .canvas_parent { 25 | width: 100%; 26 | height: 95vh; 27 | position: relative; 28 | } 29 | #canvas { 30 | width: 100%; 31 | height: 100%; 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | config.yml 3 | 4 | # ---> Go 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | 27 | tmp/build-errors.log 28 | tmp/main 29 | watch.db 30 | backups -------------------------------------------------------------------------------- /docs/compose/browserless-tor.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: ../.. 7 | dockerfile: Dockerfile 8 | target: base 9 | container_name: go-watch 10 | environment: 11 | - GOWATCH_PROXY_URL=http://tor-privoxy:8118 12 | - GOWATCH_BROWSERLESS_URL=http://browserless:3000 13 | volumes: 14 | - ./tmp:/config 15 | ports: 16 | - "8080:8080" 17 | tor-privoxy: 18 | image: dockage/tor-privoxy 19 | container_name: tor-privoxy 20 | browserless: 21 | image: browserless/chrome:latest 22 | container_name: browserless 23 | environment: 24 | - DEFAULT_LAUNCH_ARGS=["--proxy-server=socks5://tor-privoxy:9050"] -------------------------------------------------------------------------------- /docs/proxy/docker-compose-proxy-test.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: ../.. 7 | dockerfile: Dockerfile 8 | target: base 9 | container_name: go-watch 10 | environment: 11 | - HTTP_PROXY=http://squid_proxy:3128 12 | - HTTPS_PROXY=http://squid_proxy:3128 13 | ports: 14 | - "8080:8080" 15 | squid_proxy: 16 | image: sameersbn/squid:latest 17 | volumes: 18 | - ./squid-1.conf:/etc/squid/squid.conf 19 | squid_proxy1: 20 | image: sameersbn/squid:latest 21 | volumes: 22 | - ./squid-2.conf:/etc/squid/squid.conf 23 | squid_proxy2: 24 | image: sameersbn/squid:latest 25 | volumes: 26 | - ./squid-2.conf:/etc/squid/squid.conf -------------------------------------------------------------------------------- /web/static/backup.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"backup.js","sourceRoot":"","sources":["backup.ts"],"names":[],"mappings":"AACA,IAAI,SAAS,GAAG,YAAY,EAAE,CAAC;AAC/B,SAAS,UAAU;IACf,IAAI,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAoB,CAAC;IACpE,IAAI,CAAC,MAAM,GAAG,SAAS,GAAG,aAAa,CAAC;IACxC,IAAI,CAAC,MAAM,EAAE,CAAC;AAClB,CAAC;AAED,SAAS,aAAa;IAClB,IAAI,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAoB,CAAC;IACpE,IAAI,CAAC,MAAM,GAAG,SAAS,GAAG,gBAAgB,CAAC;IAC3C,IAAI,CAAC,MAAM,EAAE,CAAC;AAClB,CAAC;AAED,SAAS,gBAAgB;IACrB,IAAI,eAAe,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAqB,CAAC;IAChF,eAAe,CAAC,OAAO,GAAG,UAAU,CAAC;IAErC,IAAI,kBAAkB,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAqB,CAAC;IACtF,kBAAkB,CAAC,OAAO,GAAG,aAAa,CAAC;AAC/C,CAAC;AAED,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,gBAAgB,EAAE,KAAK,CAAC,CAAC"} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod ./ 6 | COPY go.sum ./ 7 | 8 | RUN apk add build-base && go mod download 9 | 10 | COPY ./models ./models 11 | COPY ./notifiers ./notifiers 12 | COPY ./web ./web 13 | COPY ./main.go ./main.go 14 | 15 | RUN GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /gowatch 16 | 17 | 18 | FROM alpine AS base 19 | 20 | WORKDIR /app 21 | 22 | COPY --from=builder /gowatch /app/gowatch 23 | RUN mkdir /config 24 | 25 | RUN addgroup -S gowatch && \ 26 | adduser -S gowatch -G gowatch && \ 27 | chown gowatch:gowatch /app && \ 28 | chown gowatch:gowatch /config 29 | 30 | USER gowatch 31 | 32 | ENV GOWATCH_DATABASE_DSN "/config/database.db" 33 | 34 | ENTRYPOINT ["/app/gowatch"] -------------------------------------------------------------------------------- /web/static/backup.js: -------------------------------------------------------------------------------- 1 | var urlPrefix = getURLPrefix(); 2 | function testSubmit() { 3 | var form = document.getElementById("uploadForm"); 4 | form.action = urlPrefix + "backup/test"; 5 | form.submit(); 6 | } 7 | function restoreSubmit() { 8 | var form = document.getElementById("uploadForm"); 9 | form.action = urlPrefix + "backup/restore"; 10 | form.submit(); 11 | } 12 | function initUploadSubmit() { 13 | var testSubmitInput = document.getElementById("testSubmit"); 14 | testSubmitInput.onclick = testSubmit; 15 | var restoreSubmitInput = document.getElementById("restoreSubmit"); 16 | restoreSubmitInput.onclick = restoreSubmit; 17 | } 18 | document.addEventListener('DOMContentLoaded', initUploadSubmit, false); 19 | //# sourceMappingURL=backup.js.map -------------------------------------------------------------------------------- /docs/compose/postgresql.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | environment: 8 | - GOWATCH_DATABASE_DSN=postgres://gorm:gorm@db:5432/gorm 9 | volumes: 10 | - /host/path/to/config:/config 11 | ports: 12 | - "8080:8080" 13 | depends_on: 14 | db: 15 | condition: service_healthy 16 | db: 17 | image: postgres:15 18 | environment: 19 | - POSTGRES_USER=gorm 20 | - POSTGRES_PASSWORD=gorm 21 | - POSTGRES_DB=gorm 22 | volumes: 23 | - /host/path/to/db:/var/lib/postgresql/data 24 | healthcheck: 25 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 26 | interval: 10s 27 | timeout: 5s 28 | retries: 5 -------------------------------------------------------------------------------- /web/static/create.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"create.js","sourceRoot":"","sources":["create.ts"],"names":[],"mappings":"AAAA,SAAS,WAAW;IAChB,IAAI,QAAQ,GAAG,QAAQ,CAAC,cAAc,CAAC,KAAK,CAAqB,CAAC;IAClE,IAAI,QAAQ,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,EAAC;QAC1B,IAAI,aAAa,GAAG,QAAQ,CAAC,cAAc,CAAC,UAAU,CAAqB,CAAC;QAC5E,aAAa,CAAC,OAAO,GAAG,IAAI,CAAC;KAChC;AACL,CAAC;AAED,SAAS,YAAY;IACjB,IAAI,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,MAAM,CAAqB,CAAC;IACpE,IAAI,SAAS,CAAC,KAAK,KAAK,IAAI,EAAC;QACzB,IAAI,cAAc,GAAG,QAAQ,CAAC,cAAc,CAAC,WAAW,CAAqB,CAAC;QAC9E,cAAc,CAAC,OAAO,GAAG,IAAI,CAAC;KACjC;AACL,CAAC;AAED,SAAS,YAAY;IACjB,IAAI,QAAQ,GAAG,QAAQ,CAAC,cAAc,CAAC,KAAK,CAAqB,CAAC;IAClE,QAAQ,CAAC,QAAQ,GAAG,WAAW,CAAC;IAEhC,IAAI,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,MAAM,CAAqB,CAAC;IACpE,SAAS,CAAC,QAAQ,GAAG,YAAY,CAAC;AACtC,CAAC;AAED,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,YAAY,EAAE,KAAK,CAAC,CAAC"} -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html", "ts", "js"] 18 | kill_delay = "0s" 19 | log = "build-errors.log" 20 | send_interrupt = false 21 | stop_on_error = true 22 | 23 | [color] 24 | app = "" 25 | build = "yellow" 26 | main = "magenta" 27 | runner = "green" 28 | watcher = "cyan" 29 | 30 | [log] 31 | time = false 32 | 33 | [misc] 34 | clean_on_exit = false 35 | 36 | [screen] 37 | clear_on_rebuild = false 38 | -------------------------------------------------------------------------------- /web/static/backup.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | let urlPrefix = getURLPrefix(); 3 | function testSubmit() { 4 | let form = document.getElementById("uploadForm") as HTMLFormElement; 5 | form.action = urlPrefix + "backup/test"; 6 | form.submit(); 7 | } 8 | 9 | function restoreSubmit() { 10 | let form = document.getElementById("uploadForm") as HTMLFormElement; 11 | form.action = urlPrefix + "backup/restore"; 12 | form.submit(); 13 | } 14 | 15 | function initUploadSubmit(){ 16 | let testSubmitInput = document.getElementById("testSubmit") as HTMLInputElement; 17 | testSubmitInput.onclick = testSubmit; 18 | 19 | let restoreSubmitInput = document.getElementById("restoreSubmit") as HTMLInputElement; 20 | restoreSubmitInput.onclick = restoreSubmit; 21 | } 22 | 23 | document.addEventListener('DOMContentLoaded', initUploadSubmit, false); -------------------------------------------------------------------------------- /web/static/create.js: -------------------------------------------------------------------------------- 1 | function urlOnChange() { 2 | var urlInput = document.getElementById("url"); 3 | if (urlInput.value.length > 0) { 4 | var urlInputRadio = document.getElementById("urlRadio"); 5 | urlInputRadio.checked = true; 6 | } 7 | } 8 | function fileOnChange() { 9 | var fileInput = document.getElementById("file"); 10 | if (fileInput.files !== null) { 11 | var fileInputRadio = document.getElementById("fileRadio"); 12 | fileInputRadio.checked = true; 13 | } 14 | } 15 | function initOnChange() { 16 | var urlInput = document.getElementById("url"); 17 | urlInput.onchange = urlOnChange; 18 | var fileInput = document.getElementById("file"); 19 | fileInput.onchange = fileOnChange; 20 | } 21 | document.addEventListener('DOMContentLoaded', initOnChange, false); 22 | //# sourceMappingURL=create.js.map -------------------------------------------------------------------------------- /web/templates/notifiers.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch 3 | {{end}} 4 | 5 | {{define "content"}} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{ range $name, $notifier := .notifiers }} 16 | 17 | 18 | 24 | 25 | {{ end }} 26 | 27 |
Notifiers
NameTest
{{ $name }} 19 |
20 | 21 | 22 |
23 |
28 | {{end}} -------------------------------------------------------------------------------- /docs/compose/apprise-browserless-postgresql.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | environment: 8 | - GOWATCH_BROWSERLESS_URL=http://browserless:3000/content 9 | volumes: 10 | - /host/path/to/config:/config 11 | ports: 12 | - "8080:8080" 13 | depends_on: 14 | db: 15 | condition: service_healthy 16 | db: 17 | image: postgres:15 18 | environment: 19 | - POSTGRES_USER=gorm 20 | - POSTGRES_PASSWORD=gorm 21 | - POSTGRES_DB=gorm 22 | volumes: 23 | - /host/path/to/db:/var/lib/postgresql/data 24 | healthcheck: 25 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 26 | interval: 10s 27 | timeout: 5s 28 | retries: 5 29 | apprise: 30 | image: caronc/apprise:latest 31 | browserless: 32 | image: browserless/chrome:latest 33 | -------------------------------------------------------------------------------- /web/static/create.ts: -------------------------------------------------------------------------------- 1 | function urlOnChange(){ 2 | let urlInput = document.getElementById("url") as HTMLInputElement; 3 | if (urlInput.value.length > 0){ 4 | let urlInputRadio = document.getElementById("urlRadio") as HTMLInputElement; 5 | urlInputRadio.checked = true; 6 | } 7 | } 8 | 9 | function fileOnChange(){ 10 | let fileInput = document.getElementById("file") as HTMLInputElement; 11 | if (fileInput.files !== null){ 12 | let fileInputRadio = document.getElementById("fileRadio") as HTMLInputElement; 13 | fileInputRadio.checked = true; 14 | } 15 | } 16 | 17 | function initOnChange(){ 18 | let urlInput = document.getElementById("url") as HTMLInputElement; 19 | urlInput.onchange = urlOnChange; 20 | 21 | let fileInput = document.getElementById("file") as HTMLInputElement; 22 | fileInput.onchange = fileOnChange; 23 | } 24 | 25 | document.addEventListener('DOMContentLoaded', initOnChange, false); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2022 BroodjeAap 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /docs/compose/apprise-browserless-postgresql-tor.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | environment: 8 | - GOWATCH_PROXY_URL=http://tor-privoxy:8118 9 | - GOWATCH_BROWSERLESS_URL=http://browserless:3000/content 10 | volumes: 11 | - /host/path/to/config:/config 12 | ports: 13 | - "8080:8080" 14 | depends_on: 15 | db: 16 | condition: service_healthy 17 | db: 18 | image: postgres:15 19 | environment: 20 | - POSTGRES_USER=gorm 21 | - POSTGRES_PASSWORD=gorm 22 | - POSTGRES_DB=gorm 23 | volumes: 24 | - /host/path/to/db:/var/lib/postgresql/data 25 | healthcheck: 26 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 27 | interval: 10s 28 | timeout: 5s 29 | retries: 5 30 | apprise: 31 | image: caronc/apprise:latest 32 | browserless: 33 | image: browserless/chrome:latest 34 | environment: 35 | - DEFAULT_LAUNCH_ARGS=["--proxy-server=socks5://tor-privoxy:9050"] 36 | tor-privoxy: 37 | image: dockage/tor-privoxy -------------------------------------------------------------------------------- /.drone.yaml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: kubernetes 3 | name: test-and-sonarqube 4 | steps: 5 | - name: test 6 | image: golang 7 | commands: 8 | - go build 9 | - go test -v github.com/broodjeaap/go-watch/web -coverprofile=cov.out 10 | - name: sonarqube 11 | image: sonarsource/sonar-scanner-cli 12 | environment: 13 | SONAR_HOST_URL: https://sonarqube.broodjeaap.net 14 | SONAR_SCANNER_OPTS: -Dsonar.projectKey=go-watch -Dsonar.projectBaseDir=/drone/src/ -Dsonar.sources=. -Dsonar.exclusions=**/*.js,**/*_test.go -Dsonar.tests=. -Dsonar.test.inclusions=**/*_test.go -Dsonar.go.coverage.reportPaths=/drone/src/cov.out 15 | SONAR_TOKEN: 16 | from_secret: sonarqube_token 17 | trigger: 18 | branch: 19 | - master 20 | event: 21 | - push 22 | --- 23 | kind: pipeline 24 | type: kubernetes 25 | name: github-push 26 | steps: 27 | - name: push commit 28 | image: appleboy/drone-git-push:0.2.0-linux-amd64 29 | settings: 30 | branch: master 31 | remote: git@github.com:broodjeaap/go-watch.git 32 | force: true 33 | ssh_key: 34 | from_secret: id_rsa 35 | trigger: 36 | branch: 37 | - master 38 | event: 39 | - push -------------------------------------------------------------------------------- /notifiers/shoutrrr.go: -------------------------------------------------------------------------------- 1 | package notifiers 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/containrrr/shoutrrr" 8 | "github.com/containrrr/shoutrrr/pkg/types" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | type ShoutrrrNotifier struct { 13 | URLs []string 14 | } 15 | 16 | func (shoutr *ShoutrrrNotifier) Open(configPath string) bool { 17 | urlsPath := fmt.Sprintf("%s.urls", configPath) 18 | if !viper.IsSet(urlsPath) { 19 | log.Println("Need 'urls' for Shoutrrr") 20 | return false 21 | } 22 | shoutr.URLs = viper.GetStringSlice(urlsPath) 23 | log.Println("Shoutrrr version:", shoutrrr.Version()) 24 | return true 25 | } 26 | 27 | func (shoutr *ShoutrrrNotifier) Message(message string) bool { 28 | sender, err := shoutrrr.CreateSender(shoutr.URLs...) 29 | if err != nil { 30 | log.Println("Could not create Shoutrrr sender:", err) 31 | return false 32 | } 33 | 34 | errs := sender.Send(message, &types.Params{}) 35 | if errs != nil { 36 | for _, err := range errs { 37 | if err != nil { 38 | log.Println("Something went wrong sending Shoutrrr:", err) 39 | } 40 | } 41 | } 42 | return true 43 | } 44 | 45 | func (shoutr *ShoutrrrNotifier) Close() bool { 46 | return true 47 | } 48 | -------------------------------------------------------------------------------- /notifiers/file.go: -------------------------------------------------------------------------------- 1 | package notifiers 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | type FileNotifier struct { 12 | Path string 13 | } 14 | 15 | func (file *FileNotifier) Open(configPath string) bool { 16 | pathPath := fmt.Sprintf("%s.path", configPath) 17 | if !viper.IsSet(pathPath) { 18 | log.Println("Need 'path' for FileNotifier") 19 | return false 20 | } 21 | file.Path = viper.GetString(pathPath) 22 | 23 | f, err := os.OpenFile(file.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) 24 | if err != nil { 25 | log.Println("Could not open:", file.Path) 26 | return false 27 | } 28 | f.Close() 29 | log.Println("File notifier path: ", file.Path) 30 | return true 31 | } 32 | 33 | func (file *FileNotifier) Message(message string) bool { 34 | f, err := os.OpenFile(file.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) 35 | if err != nil { 36 | log.Println("Could not open:", file.Path) 37 | return false 38 | } 39 | defer f.Close() 40 | _, err = f.WriteString(message + "\n") 41 | if err != nil { 42 | log.Println("Could not write notification to:", file.Path) 43 | return false 44 | } 45 | return true 46 | } 47 | 48 | func (file *FileNotifier) Close() bool { 49 | return true 50 | } 51 | -------------------------------------------------------------------------------- /docs/compose/auth.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/broodjeaap/go-watch:latest 6 | container_name: go-watch 7 | environment: 8 | - GOWATCH_DATABASE_DSN=postgres://gorm:gorm@db:5432/gorm 9 | volumes: 10 | - /host/path/to/config:/config 11 | ports: 12 | - "8181:8080" 13 | depends_on: 14 | db: 15 | condition: service_healthy 16 | labels: 17 | - "traefik.http.routers.gowatch.rule=Host(`192.168.178.254`)" 18 | - "traefik.http.routers.gowatch.middlewares=test-auth" 19 | db: 20 | image: postgres:15 21 | environment: 22 | - POSTGRES_USER=gorm 23 | - POSTGRES_PASSWORD=gorm 24 | - POSTGRES_DB=gorm 25 | volumes: 26 | - /host/path/to/db:/var/lib/postgresql/data 27 | healthcheck: 28 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 29 | interval: 10s 30 | timeout: 5s 31 | retries: 5 32 | depends_on: 33 | - proxy 34 | proxy: 35 | image: traefik:v2.9.6 36 | command: --providers.docker 37 | labels: 38 | - "traefik.http.middlewares.test-auth.basicauth.users=broodjeaap:$$2y$$10$$aUvoh7HNdt5tvf8PYMKaaOyCLD3Uel03JtEIPxFEBklJE62VX4rD6" 39 | ports: 40 | - "8080:80" 41 | volumes: 42 | - /var/run/docker.sock:/var/run/docker.sock -------------------------------------------------------------------------------- /models/filter.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | 7 | "github.com/robfig/cron/v3" 8 | ) 9 | 10 | type FilterID uint 11 | type FilterType string 12 | type Filter struct { 13 | ID FilterID `form:"filter_id" yaml:"filter_id" json:"filter_id"` 14 | WatchID WatchID `form:"filter_watch_id" gorm:"index" yaml:"filter_watch_id" json:"filter_watch_id" binding:"required"` 15 | Name string `form:"filter_name" gorm:"index" yaml:"filter_name" json:"filter_name" binding:"required" validate:"min=1"` 16 | X int `form:"x" yaml:"x" json:"x" validate:"default=0"` 17 | Y int `form:"y" yaml:"y" json:"y" validate:"default=0"` 18 | Type FilterType `form:"filter_type" yaml:"filter_type" json:"filter_type" binding:"required" validate:"oneof=url xpath json css replace match substring math store condition cron"` 19 | Var1 string `form:"var1" yaml:"var1" json:"var1" binding:"required"` 20 | Var2 string `form:"var2" yaml:"var2" json:"var2"` 21 | Parents []*Filter `gorm:"-:all"` 22 | Children []*Filter `gorm:"-:all"` 23 | Results []string `gorm:"-:all"` 24 | Logs []string `gorm:"-:all"` 25 | CronEntry *cron.Entry `gorm:"-:all"` 26 | } 27 | 28 | func (filter *Filter) Log(v ...any) { 29 | filter.Logs = append(filter.Logs, html.EscapeString(fmt.Sprint(v...))) 30 | } 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/spf13/viper" 10 | 11 | "github.com/broodjeaap/go-watch/web" 12 | ) 13 | 14 | func main() { 15 | writeConfFlag := flag.String("writeConfig", "-", "Path to write template config to") 16 | var printConfigFlag bool 17 | flag.BoolVar(&printConfigFlag, "printConfig", false, "Print the template config to stdout") 18 | flag.Parse() 19 | 20 | if *writeConfFlag != "-" { 21 | conf, err := web.EMBED_FS.ReadFile("config.tmpl") 22 | if err != nil { 23 | log.Fatalln("Could not read config.tmpl") 24 | } 25 | os.WriteFile(*writeConfFlag, conf, 0666) 26 | log.Println("Wrote template config to:", *writeConfFlag) 27 | return 28 | } 29 | 30 | if printConfigFlag { 31 | conf, err := web.EMBED_FS.ReadFile("config.tmpl") 32 | if err != nil { 33 | log.Fatalln("Could not read config.tmpl") 34 | } 35 | log.SetFlags(0) 36 | log.Println(string(conf)) 37 | return 38 | } 39 | 40 | viper.SetConfigName("config") 41 | viper.SetConfigType("yaml") 42 | viper.AddConfigPath(".") 43 | viper.AddConfigPath("/config") 44 | 45 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 46 | viper.SetEnvPrefix("GOWATCH") 47 | viper.AutomaticEnv() 48 | 49 | err := viper.ReadInConfig() 50 | if err != nil { 51 | log.Println("Could not load config file, using env/defaults") 52 | } 53 | 54 | web := web.NewWeb() 55 | web.Run() 56 | } 57 | -------------------------------------------------------------------------------- /web/web_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func TestIndex(t *testing.T) { 10 | router := NewWeb() 11 | w := httptest.NewRecorder() 12 | 13 | req, _ := http.NewRequest("GET", "/", nil) 14 | router.router.ServeHTTP(w, req) 15 | 16 | if w.Code != 200 { 17 | t.Error("Status != 200") 18 | } 19 | } 20 | 21 | func TestNotifiersView(t *testing.T) { 22 | router := NewWeb() 23 | w := httptest.NewRecorder() 24 | 25 | req, _ := http.NewRequest("GET", "/notifiers/view", nil) 26 | router.router.ServeHTTP(w, req) 27 | 28 | if w.Code != 200 { 29 | t.Error("Status != 200") 30 | } 31 | } 32 | 33 | func TestSchedulesView(t *testing.T) { 34 | router := NewWeb() 35 | w := httptest.NewRecorder() 36 | 37 | req, _ := http.NewRequest("GET", "/schedules/view", nil) 38 | router.router.ServeHTTP(w, req) 39 | 40 | if w.Code != 200 { 41 | t.Error("Status != 200") 42 | } 43 | } 44 | 45 | func TestCreateWatchGet(t *testing.T) { 46 | router := NewWeb() 47 | w := httptest.NewRecorder() 48 | 49 | req, _ := http.NewRequest("GET", "/watch/create", nil) 50 | router.router.ServeHTTP(w, req) 51 | 52 | if w.Code != 200 { 53 | t.Error("Status != 200") 54 | } 55 | } 56 | 57 | func TestCreateBackupView(t *testing.T) { 58 | router := NewWeb() 59 | w := httptest.NewRecorder() 60 | 61 | req, _ := http.NewRequest("GET", "/backup/view", nil) 62 | router.router.ServeHTTP(w, req) 63 | 64 | if w.Code != 200 { 65 | t.Error("Status != 200") 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/build-binaries.yml: -------------------------------------------------------------------------------- 1 | name: build-binaries 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | release-linux-amd64: 9 | name: release linux/amd64 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: wangyoucao577/go-release-action@v1.34 14 | with: 15 | github_token: ${{ secrets.GITHUB_TOKEN }} 16 | goos: linux 17 | goarch: amd64 18 | compress_assets: false 19 | ldflags: -s -w 20 | md5sum: false 21 | overwrite: true 22 | release-linux-arm: 23 | name: release linux/arm 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: wangyoucao577/go-release-action@v1.34 28 | with: 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | goos: linux 31 | goarch: arm 32 | compress_assets: false 33 | ldflags: -s -w 34 | md5sum: false 35 | overwrite: true 36 | release-linux-arm64: 37 | name: release linux/arm64 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v3 41 | - uses: wangyoucao577/go-release-action@v1.34 42 | with: 43 | github_token: ${{ secrets.GITHUB_TOKEN }} 44 | goos: linux 45 | goarch: arm64 46 | compress_assets: false 47 | ldflags: -s -w 48 | md5sum: false 49 | overwrite: true 50 | release-linux-windows: 51 | name: release windows 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v3 55 | - uses: wangyoucao577/go-release-action@v1.34 56 | with: 57 | github_token: ${{ secrets.GITHUB_TOKEN }} 58 | goos: windows 59 | goarch: amd64 60 | compress_assets: false 61 | ldflags: -s -w 62 | md5sum: false 63 | overwrite: true -------------------------------------------------------------------------------- /web/config.tmpl: -------------------------------------------------------------------------------- 1 | notifiers: 2 | Apprise: # See: https://github.com/caronc/apprise-api#api-details 3 | type: "apprise" 4 | url: "http://apprise/notify" 5 | title: "GoWatch notification through Apprise" 6 | mtype: "info" # Note 'mtype' to avoid conflict with 'type' 7 | format: "text" 8 | urls: 9 | - "tgram:////" 10 | - "discord:////" 11 | Shoutrrr: # See: https://containrrr.dev/shoutrrr/v0.5/services/overview/ 12 | type: "shoutrrr" 13 | urls: 14 | - "telegram://@telegram?chats=," 15 | Telegram: 16 | token: "" # See: https://core.telegram.org/bots#how-do-i-create-a-bot 17 | chat: "" # See: https://www.alphr.com/find-chat-id-telegram/ 18 | debug: false 19 | Discord: 20 | type: "discord" 21 | token: "" # See: https://www.writebots.com/discord-bot-token/ 22 | userID: "" # See: https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID- 23 | server: 24 | ID: "" 25 | channel: "" 26 | debug: false 27 | Email: 28 | type: "email" 29 | server: "smtp.relay.com" 30 | port: "465" 31 | from: "from@email.com" 32 | user: "" 33 | password: "" 34 | to: "to@email.com" 35 | File: 36 | type: "file" 37 | path: /config/notifications.log 38 | database: 39 | dsn: "/config/watch.db" # for docker usage 40 | prune: "@every 1h" 41 | backup: 42 | schedule: "@every 1d" 43 | path: "/backup/{{.Year}}_{{.Month}}_{{.Day}}T{{.Hour}}-{{.Minute}}-{{.Second}}.gzip" # https://pkg.go.dev/time available 44 | proxy: 45 | url: http://proxy.com:1234 46 | browserless: 47 | url: http://your.browserless:1234 48 | gin: 49 | debug: false 50 | urlprefix: "/" 51 | schedule: 52 | delay: "5s" -------------------------------------------------------------------------------- /web/templates/index.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch 3 | {{end}} 4 | 5 | {{define "content"}} 6 | {{ if .warnings }} 7 |
Startup Warnings
8 | {{ range .warnings }} 9 |
{{ . }}
10 | {{ end }} 11 | {{ end }} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{ range .watches }} 26 | 27 | 28 | {{ if .CronEntry }} 29 | 30 | 31 | 32 | {{ else }} 33 | 34 | {{ end }} 35 | 38 | 41 | 47 | 48 | {{ end }} 49 | 50 |
Watches
NameLast RunNext RunLast ValueEditDelete
{{ .Name }}{{ .CronEntry.Prev.Format "2006-01-02 15:04:05" }}{{ .CronEntry.Next.Format "2006-01-02 15:04:05" }}No schedule (Add cron filter) 36 | {{ .LastValue }} 37 | 39 | Edit 40 | 42 |
43 | 44 | 45 |
46 |
51 | {{end}} -------------------------------------------------------------------------------- /web/templates/backup/test.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch Backups 3 | {{end}} 4 | {{define "content"}} 5 | 6 |
7 |
8 | Backup Test: {{ .BackupPath }} 9 |
10 | {{ if .Error }} 11 |
12 | {{ .Error }} 13 |
14 | {{ end }} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {{ range $watch := .Backup.Watches }} 25 | 26 | 27 | 28 | 29 | {{ end }} 30 | 31 |
Watches
IDName
{{ $watch.ID }}{{ $watch.Name }}
32 |
33 | Stored Values: {{ len .Backup.Values }} 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {{ range $filter := .Backup.Filters }} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {{ end }} 60 | 61 |
Filters
IDWatchIDNameTypeVar1Var2X/Y
{{ $filter.ID }}{{ $filter.WatchID }}{{ $filter.Name }}{{ $filter.Type }}{{ $filter.Var1 }}{{ $filter.Var2 }}{{ $filter.X }}/{{ $filter.Y }}
62 |
63 | FilterConnections: {{ len .Backup.Connections }} 64 |
65 |
66 | 67 | {{end}} -------------------------------------------------------------------------------- /web/templates/backup/restore.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch Backup Restore 3 | {{end}} 4 | {{define "content"}} 5 | 6 |
7 |
8 | Restore: {{ .BackupPath }} 9 |
10 | {{ if .Error }} 11 |
12 | {{ .Error }} 13 |
14 | {{ end }} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {{ range $watch := .Backup.Watches }} 25 | 26 | 27 | 28 | 29 | {{ end }} 30 | 31 |
Watches
IDName
{{ $watch.ID }}{{ $watch.Name }}
32 |
33 | Stored Values: {{ len .Backup.Values }} 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {{ range $filter := .Backup.Filters }} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {{ end }} 60 | 61 |
Filters
IDWatchIDNameTypeVar1Var2X/Y
{{ $filter.ID }}{{ $filter.WatchID }}{{ $filter.Name }}{{ $filter.Type }}{{ $filter.Var1 }}{{ $filter.Var2 }}{{ $filter.X }}/{{ $filter.Y }}
62 |
63 | FilterConnections: {{ len .Backup.Connections }} 64 |
65 |
66 | 67 | {{end}} -------------------------------------------------------------------------------- /web/templates/schedules.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch Schedules 3 | {{end}} 4 | 5 | {{define "content"}} 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {{ range $watch, $scheduleFilters := .watchSchedules }} 21 | {{ range $i, $scheduleFilter := $scheduleFilters }} 22 | 23 | 24 | 25 | 26 | {{ if $scheduleFilter.CronEntry }} 27 | 28 | 29 | {{ else }} 30 | 31 | {{ end }} 32 | {{ if eq $scheduleFilter.Var2 "yes" }} 33 | 36 | {{ else }} 37 | 40 | {{ end}} 41 | 42 | {{ end }} 43 | {{ end }} 44 | 45 |
Schedules
WatchNameScheduleLastNextEnabled
{{ $watch.Name }}{{ $scheduleFilter.Name }}{{ $scheduleFilter.Var1 }}{{ $scheduleFilter.CronEntry.Prev.Format "2006-01-02 15:04:05" }}{{ $scheduleFilter.CronEntry.Next.Format "2006-01-02 15:04:05" }}Not scheduled 34 | 35 | 38 | 39 |
46 |
47 | 48 |
49 |
50 | {{end}} -------------------------------------------------------------------------------- /docs/proxy/squid-1.conf: -------------------------------------------------------------------------------- 1 | # 2 | # Recommended minimum configuration: 3 | # 4 | 5 | # Example rule allowing access from your local networks. 6 | # Adapt to list your (internal) IP networks from where browsing 7 | # should be allowed 8 | 9 | # Auth 10 | #auth_param basic program /usr/lib64/squid/basic_ncsa_auth /etc/squid/squid_passwd 11 | #acl ncsa_users proxy_auth REQUIRED 12 | #http_access allow ncsa_users 13 | 14 | 15 | acl all src all 16 | acl manager proto cache_object 17 | acl localhost src 127.0.0.1/32 18 | acl to_localhost dst 127.0.0.0/8 0.0.0.0/32 19 | 20 | acl localnet src 10.0.0.0/8 # RFC1918 possible internal network 21 | acl localnet src 172.16.0.0/12 # RFC1918 possible internal network 22 | acl localnet src 192.168.0.0/16 # RFC1918 possible internal network 23 | acl localnet src fc00::/7 # RFC 4193 local private network range 24 | acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines 25 | 26 | acl SSL_ports port 443 27 | acl Safe_ports port 80 # http 28 | acl Safe_ports port 1025-65535 # unregistered ports 29 | acl Safe_ports port 443 # https 30 | acl CONNECT method CONNECT 31 | 32 | # 33 | # Recommended minimum Access Permission configuration: 34 | # 35 | # Deny requests to certain unsafe ports 36 | http_access deny !Safe_ports 37 | 38 | # Deny CONNECT to other than secure SSL ports 39 | http_access deny CONNECT !SSL_ports 40 | 41 | http_access allow localhost manager 42 | 43 | 44 | # We strongly recommend the following be uncommented to protect innocent 45 | # web applications running on the proxy server who think the only 46 | # one who can access services on "localhost" is a local user 47 | #http_access deny to_localhost 48 | 49 | # 50 | # INSERT YOUR OWN RULE(S) HERE TO ALLOW ACCESS FROM YOUR CLIENTS 51 | # 52 | 53 | # Example rule allowing access from your local networks. 54 | # Adapt localnet in the ACL section to list your (internal) IP networks 55 | # from where browsing should be allowed 56 | http_access allow localnet 57 | http_access allow localhost 58 | 59 | # And finally deny all other access to this proxy 60 | http_access deny all 61 | 62 | # Squid normally listens to port 3128 63 | http_port 3128 64 | 65 | # Leave coredumps in the first cache dir 66 | coredump_dir /var/spool/squid 67 | 68 | cache_peer squid_proxy1 parent 3128 0 round-robin no-query never_direct 69 | cache_peer squid_proxy2 parent 3128 0 round-robin no-query never_direct 70 | never_direct allow all 71 | -------------------------------------------------------------------------------- /notifiers/apprise.go: -------------------------------------------------------------------------------- 1 | package notifiers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | type AppriseNotifier struct { 16 | URL string 17 | Title string 18 | Type string 19 | Format string 20 | URLs []string 21 | } 22 | 23 | func (apprise *AppriseNotifier) Open(configPath string) bool { 24 | urlPath := fmt.Sprintf("%s.url", configPath) 25 | if !viper.IsSet(urlPath) { 26 | log.Println("Need 'url' for Apprise") 27 | return false 28 | } 29 | apprise.URL = viper.GetString(urlPath) 30 | 31 | urlsPath := fmt.Sprintf("%s.urls", configPath) 32 | if !viper.IsSet(urlsPath) { 33 | log.Println("Need 'urls' for Apprise") 34 | return false 35 | } 36 | 37 | apprise.Title = "GoWatch Notification" 38 | titlePath := fmt.Sprintf("%s.title", configPath) 39 | if viper.IsSet(titlePath) { 40 | apprise.Title = viper.GetString(titlePath) 41 | } 42 | apprise.Type = "info" 43 | typePath := fmt.Sprintf("%s.mtype", configPath) 44 | if viper.IsSet(typePath) { 45 | apprise.Type = viper.GetString(typePath) 46 | } 47 | apprise.Format = "text" 48 | formatPath := fmt.Sprintf("%s.format", configPath) 49 | if viper.IsSet(formatPath) { 50 | apprise.Format = viper.GetString(formatPath) 51 | } 52 | apprise.URLs = viper.GetStringSlice(urlsPath) 53 | log.Println("Apprise notifier:", apprise.URL, apprise.Type, apprise.Format) 54 | return true 55 | } 56 | 57 | type ApprisePostData struct { 58 | Title string `json:"title"` 59 | Type string `json:"type"` 60 | Format string `json:"format"` 61 | URLs []string `json:"urls"` 62 | Body string `json:"body"` 63 | } 64 | 65 | func (apprise *AppriseNotifier) Message(message string) bool { 66 | data := ApprisePostData{ 67 | URLs: apprise.URLs, 68 | Title: apprise.Title, 69 | Type: apprise.Type, 70 | Format: apprise.Format, 71 | Body: message, 72 | } 73 | jsn, err := json.Marshal(data) 74 | if err != nil { 75 | log.Println("Could not create JSON post data:", err) 76 | return false 77 | } 78 | 79 | resp, err := http.Post(apprise.URL, "application/json", bytes.NewBuffer(jsn)) 80 | if err != nil { 81 | log.Println("Could not send Apprise notification:", err) 82 | return false 83 | } 84 | body, err := ioutil.ReadAll(resp.Body) 85 | if err != nil { 86 | log.Println("Could not parse Apprise response:", err) 87 | return false 88 | } 89 | bdy := string(body) 90 | if !strings.Contains(bdy, "Notification(s) sent.") { 91 | log.Println("Sending notifications failed:", bdy) 92 | } 93 | return true 94 | } 95 | 96 | func (apprise *AppriseNotifier) Close() bool { 97 | return true 98 | } 99 | -------------------------------------------------------------------------------- /web/util.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/go-playground/validator/v10" 11 | 12 | . "github.com/broodjeaap/go-watch/models" 13 | ) 14 | 15 | func bindAndValidateWatch(watch *Watch, c *gin.Context) (map[string]string, error) { 16 | err := c.ShouldBind(watch) 17 | return validate(err), err 18 | } 19 | 20 | func prettyError(fieldError validator.FieldError) string { 21 | switch fieldError.Tag() { 22 | case "required": 23 | return fieldError.Field() + " is required" 24 | default: 25 | return "No prettyError for " + fieldError.Tag() 26 | } 27 | } 28 | 29 | func validate(err error) map[string]string { 30 | out := make(map[string]string) 31 | if err != nil { 32 | var ve validator.ValidationErrors 33 | if errors.As(err, &ve) { 34 | for _, fe := range ve { 35 | out[fe.Field()] = prettyError(fe) 36 | } 37 | } 38 | } 39 | return out 40 | } 41 | 42 | func buildFilterTree(filters []Filter, connections []FilterConnection) { 43 | filterMap := make(map[FilterID]*Filter, len(filters)) 44 | for i := range filters { 45 | filter := &filters[i] 46 | filterMap[filter.ID] = filter 47 | } 48 | for i := range connections { 49 | connection := &connections[i] 50 | outputFilter := filterMap[connection.OutputID] 51 | inputFilter := filterMap[connection.InputID] 52 | 53 | outputFilter.Children = append(outputFilter.Children, inputFilter) 54 | //log.Println("Adding", inputFilter.Name, "as child to", outputFilter.Name) 55 | inputFilter.Parents = append(inputFilter.Parents, outputFilter) 56 | //log.Println("Adding", outputFilter.Name, "as parent to", inputFilter.Name) 57 | } 58 | /* 59 | for _, filter := range filters { 60 | log.Println("Children of", filter.Name) 61 | for _, child := range filter.Children { 62 | log.Println(" ", child.Name) 63 | } 64 | 65 | log.Println("Parents of", filter.Name) 66 | for _, parent := range filter.Parents { 67 | log.Println(" ", parent.Name) 68 | } 69 | } 70 | */ 71 | } 72 | 73 | func getJittersFromScheduleString(scheduleString string) ([]time.Duration, error) { 74 | split := strings.Split(scheduleString, "+") 75 | split = split[1:] 76 | durations := []time.Duration{} 77 | if len(split) == 0 { 78 | return durations, nil 79 | } 80 | 81 | rand.Seed(time.Now().UnixMilli()) 82 | 83 | for _, jitter := range split { 84 | trimmed := strings.TrimSpace(jitter) 85 | 86 | duration, err := time.ParseDuration(trimmed) 87 | if err != nil { 88 | return durations, err 89 | } 90 | 91 | duration = time.Duration(float64(duration.Nanoseconds()) * rand.Float64()) 92 | durations = append(durations, duration) 93 | } 94 | return durations, nil 95 | } 96 | -------------------------------------------------------------------------------- /docs/proxy/squid-2.conf: -------------------------------------------------------------------------------- 1 | # 2 | # Recommended minimum configuration: 3 | # 4 | 5 | # Example rule allowing access from your local networks. 6 | # Adapt to list your (internal) IP networks from where browsing 7 | # should be allowed 8 | 9 | # Auth 10 | #auth_param basic program /usr/lib64/squid/basic_ncsa_auth /etc/squid/squid_passwd 11 | #acl ncsa_users proxy_auth REQUIRED 12 | #http_access allow ncsa_users 13 | 14 | 15 | acl all src all 16 | acl manager proto cache_object 17 | acl localhost src 127.0.0.1/32 18 | acl to_localhost dst 127.0.0.0/8 0.0.0.0/32 19 | 20 | acl localnet src 10.0.0.0/8 # RFC1918 possible internal network 21 | acl localnet src 172.16.0.0/12 # RFC1918 possible internal network 22 | acl localnet src 192.168.0.0/16 # RFC1918 possible internal network 23 | acl localnet src fc00::/7 # RFC 4193 local private network range 24 | acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines 25 | 26 | acl SSL_ports port 443 27 | acl Safe_ports port 80 # http 28 | acl Safe_ports port 21 # ftp 29 | acl Safe_ports port 443 # https 30 | acl Safe_ports port 70 # gopher 31 | acl Safe_ports port 210 # wais 32 | acl Safe_ports port 1025-65535 # unregistered ports 33 | acl Safe_ports port 280 # http-mgmt 34 | acl Safe_ports port 488 # gss-http 35 | acl Safe_ports port 591 # filemaker 36 | acl Safe_ports port 777 # multiling http 37 | acl CONNECT method CONNECT 38 | 39 | # 40 | # Recommended minimum Access Permission configuration: 41 | # 42 | # Deny requests to certain unsafe ports 43 | http_access deny !Safe_ports 44 | 45 | # Deny CONNECT to other than secure SSL ports 46 | http_access deny CONNECT !SSL_ports 47 | 48 | # Only allow cachemgr access from localhost 49 | http_access allow localhost manager 50 | http_access deny manager 51 | 52 | # We strongly recommend the following be uncommented to protect innocent 53 | # web applications running on the proxy server who think the only 54 | # one who can access services on "localhost" is a local user 55 | #http_access deny to_localhost 56 | 57 | # 58 | # INSERT YOUR OWN RULE(S) HERE TO ALLOW ACCESS FROM YOUR CLIENTS 59 | # 60 | 61 | # Example rule allowing access from your local networks. 62 | # Adapt localnet in the ACL section to list your (internal) IP networks 63 | # from where browsing should be allowed 64 | http_access allow localnet 65 | http_access allow localhost 66 | 67 | # And finally deny all other access to this proxy 68 | http_access deny all 69 | 70 | # Squid normally listens to port 3128 71 | http_port 3128 72 | 73 | # Uncomment and adjust the following to add a disk cache directory. 74 | #cache_dir ufs /var/spool/squid 100 16 256 75 | 76 | # Leave coredumps in the first cache dir 77 | coredump_dir /var/spool/squid 78 | 79 | # 80 | # Add any of your own refresh_pattern entries above these. 81 | # 82 | -------------------------------------------------------------------------------- /web/templates/watch/create.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch Create 3 | {{end}} 4 | 5 | {{define "head"}} 6 | 7 | {{ end }} 8 | 9 | {{define "content"}} 10 | 11 |
12 |
13 |
14 |
15 |
16 | 17 | 18 |
19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 |
27 |
With Watch Template:
28 |
29 |
30 | {{ range $i, $name := .templates }} 31 |
32 | {{ if eq $i 0 }} 33 | 34 | {{ else }} 35 | 36 | {{ end }} 37 | 40 |
41 | {{ end }} 42 |
43 |
44 | 45 |
46 |
47 | 48 |
49 |
50 |
51 |
52 | 53 |
54 |
55 | 56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | 64 | {{end}} -------------------------------------------------------------------------------- /web/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | {{ template "title" .}} 15 | {{ template "head" .}} 16 | 17 | 18 | 19 | 47 |
48 |
49 |
50 |
51 | {{ template "left" .}} 52 |
53 | 54 |
55 | {{ template "content" .}} 56 |
57 | 58 |
59 | {{ template "right" .}} 60 |
61 | 62 |
63 |
64 | 65 |
66 |
67 |
68 | Broodjeaap 2023 69 |
70 |
71 | 72 | 73 | {{ template "scripts" .}} 74 | 75 | 76 | 77 | {{define "title" }}{{ end }} 78 | {{define "head" }}{{ end }} 79 | {{define "navbar" }}{{ end }} 80 | {{define "left" }}{{ end }} 81 | {{define "right" }}{{ end }} 82 | {{define "scripts" }}{{ end }} -------------------------------------------------------------------------------- /web/templates/backup/view.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch Backups 3 | {{end}} 4 | 5 | {{define "head"}} 6 | 7 | {{ end }} 8 | 9 | {{define "content"}} 10 | 11 |
12 | {{ if .Error }} 13 |
14 | {{ .Error }} 15 |
16 | {{ end }} 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 36 | 39 | 42 | 43 | 44 | 45 | {{ range $i, $backup := .Backups }} 46 | 47 | 48 | 54 | 60 | 66 | 71 | 72 | {{ end }} 73 | 74 |
Backups
FileTestRestoreDeleteDownload
31 |
32 | 33 | 34 |
35 |
37 | 38 | 40 | 41 |
{{ $backup }} 49 |
50 | 51 | 52 |
53 |
55 |
56 | 57 | 58 |
59 |
61 |
62 | 63 | 64 |
65 |
67 | 68 | Download 69 | 70 |
75 | 76 | Backup Now 77 | 78 |
79 | 80 | {{end}} -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: ghcr-push 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | # Use docker.io for Docker Hub if empty 9 | REGISTRY: ghcr.io 10 | # github.repository as / 11 | IMAGE_NAME: ${{ github.repository }} 12 | 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | # This is used to complete the identity challenge 22 | # with sigstore/fulcio when running outside of PRs. 23 | id-token: write 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v3 28 | 29 | # Install the cosign tool except on PR 30 | # https://github.com/sigstore/cosign-installer 31 | - name: Install cosign 32 | if: github.event_name != 'pull_request' 33 | uses: sigstore/cosign-installer@f3c664df7af409cb4873aa5068053ba9d61a57b6 #v2.6.0 34 | with: 35 | cosign-release: 'v1.13.1' 36 | 37 | 38 | # Workaround: https://github.com/docker/build-push-action/issues/461 39 | - name: Setup Docker buildx 40 | uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf 41 | 42 | # Login against a Docker registry except on PR 43 | # https://github.com/docker/login-action 44 | - name: Log into registry ${{ env.REGISTRY }} 45 | if: github.event_name != 'pull_request' 46 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 47 | with: 48 | registry: ${{ env.REGISTRY }} 49 | username: ${{ github.actor }} 50 | password: ${{ secrets.GITHUB_TOKEN }} 51 | 52 | # Extract metadata (tags, labels) for Docker 53 | # https://github.com/docker/metadata-action 54 | - name: Extract Docker metadata 55 | id: meta 56 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 57 | with: 58 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 59 | 60 | # Build and push Docker image with Buildx (don't push on PR) 61 | # https://github.com/docker/build-push-action 62 | - name: Build and push Docker image 63 | id: build-and-push 64 | uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a 65 | with: 66 | context: . 67 | push: ${{ github.event_name != 'pull_request' }} 68 | tags: ${{ steps.meta.outputs.tags }} 69 | labels: ${{ steps.meta.outputs.labels }} 70 | cache-from: type=gha 71 | cache-to: type=gha,mode=max 72 | 73 | 74 | # Sign the resulting Docker image digest except on PRs. 75 | # This will only write to the public Rekor transparency log when the Docker 76 | # repository is public to avoid leaking data. If you would like to publish 77 | # transparency data even for private images, pass --force to cosign below. 78 | # https://github.com/sigstore/cosign 79 | - name: Sign the published Docker image 80 | if: ${{ github.event_name != 'pull_request' }} 81 | env: 82 | COSIGN_EXPERIMENTAL: "true" 83 | # This step uses the identity token to provision an ephemeral certificate 84 | # against the sigstore community Fulcio instance. 85 | run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign {}@${{ steps.build-and-push.outputs.digest }} 86 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/broodjeaap/go-watch 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/andybalholm/cascadia v1.3.1 7 | github.com/antchfx/htmlquery v1.3.0 8 | github.com/containrrr/shoutrrr v0.7.1 9 | github.com/gin-contrib/multitemplate v0.0.0-20220829131020-8c2a8441bc2b 10 | github.com/gin-gonic/gin v1.8.2 11 | github.com/go-playground/validator/v10 v10.11.2 12 | github.com/robfig/cron/v3 v3.0.1 13 | github.com/spf13/viper v1.15.0 14 | github.com/tidwall/gjson v1.14.4 15 | github.com/vadv/gopher-lua-libs v0.4.1 16 | github.com/yuin/gopher-lua v1.1.0 17 | golang.org/x/net v0.5.0 18 | gorm.io/driver/mysql v1.4.5 19 | gorm.io/driver/postgres v1.4.6 20 | gorm.io/driver/sqlite v1.4.4 21 | gorm.io/driver/sqlserver v1.4.2 22 | gorm.io/gorm v1.24.5 23 | ) 24 | 25 | require ( 26 | github.com/VividCortex/ewma v1.2.0 // indirect 27 | github.com/alessio/shellescape v1.4.1 // indirect 28 | github.com/antchfx/xpath v1.2.3 // indirect 29 | github.com/aws/aws-sdk-go v1.44.194 // indirect 30 | github.com/beorn7/perks v1.0.1 // indirect 31 | github.com/cbroglie/mustache v1.4.0 // indirect 32 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 33 | github.com/cheggaaa/pb/v3 v3.1.0 // indirect 34 | github.com/dustin/go-humanize v1.0.1 // indirect 35 | github.com/fatih/color v1.14.1 // indirect 36 | github.com/fsnotify/fsnotify v1.6.0 // indirect 37 | github.com/gin-contrib/sse v0.1.0 // indirect 38 | github.com/go-playground/locales v0.14.1 // indirect 39 | github.com/go-playground/universal-translator v0.18.1 // indirect 40 | github.com/go-sql-driver/mysql v1.7.0 // indirect 41 | github.com/goccy/go-json v0.10.0 // indirect 42 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect 43 | github.com/golang-sql/sqlexp v0.1.0 // indirect 44 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 45 | github.com/golang/protobuf v1.5.2 // indirect 46 | github.com/hashicorp/hcl v1.0.0 // indirect 47 | github.com/jackc/pgpassfile v1.0.0 // indirect 48 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 49 | github.com/jackc/pgx/v5 v5.2.0 // indirect 50 | github.com/jinzhu/inflection v1.0.0 // indirect 51 | github.com/jinzhu/now v1.1.5 // indirect 52 | github.com/jmespath/go-jmespath v0.4.0 // indirect 53 | github.com/json-iterator/go v1.1.12 // indirect 54 | github.com/leodido/go-urn v1.2.1 // indirect 55 | github.com/lib/pq v1.10.7 // indirect 56 | github.com/magiconair/properties v1.8.7 // indirect 57 | github.com/mattn/go-colorable v0.1.13 // indirect 58 | github.com/mattn/go-isatty v0.0.17 // indirect 59 | github.com/mattn/go-runewidth v0.0.14 // indirect 60 | github.com/mattn/go-sqlite3 v1.14.16 // indirect 61 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 62 | github.com/microsoft/go-mssqldb v0.20.0 // indirect 63 | github.com/mitchellh/mapstructure v1.5.0 // indirect 64 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 65 | github.com/modern-go/reflect2 v1.0.2 // indirect 66 | github.com/montanaflynn/stats v0.7.0 // indirect 67 | github.com/pelletier/go-toml/v2 v2.0.6 // indirect 68 | github.com/prometheus/client_golang v1.14.0 // indirect 69 | github.com/prometheus/client_model v0.3.0 // indirect 70 | github.com/prometheus/common v0.39.0 // indirect 71 | github.com/prometheus/procfs v0.9.0 // indirect 72 | github.com/rivo/uniseg v0.4.3 // indirect 73 | github.com/spf13/afero v1.9.3 // indirect 74 | github.com/spf13/cast v1.5.0 // indirect 75 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 76 | github.com/spf13/pflag v1.0.5 // indirect 77 | github.com/subosito/gotenv v1.4.2 // indirect 78 | github.com/technoweenie/multipartstreamer v1.0.1 // indirect 79 | github.com/tidwall/match v1.1.1 // indirect 80 | github.com/tidwall/pretty v1.2.1 // indirect 81 | github.com/ugorji/go/codec v1.2.9 // indirect 82 | github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect 83 | golang.org/x/crypto v0.5.0 // indirect 84 | golang.org/x/sys v0.4.0 // indirect 85 | golang.org/x/text v0.6.0 // indirect 86 | google.golang.org/protobuf v1.28.1 // indirect 87 | gopkg.in/ini.v1 v1.67.0 // indirect 88 | gopkg.in/xmlpath.v2 v2.0.0-20150820204837-860cbeca3ebc // indirect 89 | gopkg.in/yaml.v2 v2.4.0 // indirect 90 | gopkg.in/yaml.v3 v3.0.1 // indirect 91 | ) 92 | -------------------------------------------------------------------------------- /web/watchTemplates/Amazon.json: -------------------------------------------------------------------------------- 1 | { 2 | "filters": [ 3 | { 4 | "filter_id": 80, 5 | "filter_watch_id": 2, 6 | "filter_name": "Fetch", 7 | "x": 144, 8 | "y": 242, 9 | "filter_type": "gurl", 10 | "var1": "-", 11 | "var2": "", 12 | "Parents": null, 13 | "Children": null, 14 | "Results": null, 15 | "Logs": null, 16 | "CronEntry": null 17 | }, 18 | { 19 | "filter_id": 81, 20 | "filter_watch_id": 2, 21 | "filter_name": "XPath", 22 | "x": 362, 23 | "y": 245, 24 | "filter_type": "xpath", 25 | "var1": "//div[@id='corePrice_feature_div']//span[@class='a-price-whole']", 26 | "var2": "inner", 27 | "Parents": null, 28 | "Children": null, 29 | "Results": null, 30 | "Logs": null, 31 | "CronEntry": null 32 | }, 33 | { 34 | "filter_id": 82, 35 | "filter_watch_id": 2, 36 | "filter_name": "Sanitize", 37 | "x": 584, 38 | "y": 244, 39 | "filter_type": "match", 40 | "var1": "[0-9]+", 41 | "var2": "", 42 | "Parents": null, 43 | "Children": null, 44 | "Results": null, 45 | "Logs": null, 46 | "CronEntry": null 47 | }, 48 | { 49 | "filter_id": 83, 50 | "filter_watch_id": 2, 51 | "filter_name": "Price", 52 | "x": 815, 53 | "y": 245, 54 | "filter_type": "store", 55 | "var1": "", 56 | "var2": "", 57 | "Parents": null, 58 | "Children": null, 59 | "Results": null, 60 | "Logs": null, 61 | "CronEntry": null 62 | }, 63 | { 64 | "filter_id": 84, 65 | "filter_watch_id": 2, 66 | "filter_name": "Diff", 67 | "x": 813, 68 | "y": 379, 69 | "filter_type": "condition", 70 | "var1": "diff", 71 | "var2": "Price", 72 | "Parents": null, 73 | "Children": null, 74 | "Results": null, 75 | "Logs": null, 76 | "CronEntry": null 77 | }, 78 | { 79 | "filter_id": 85, 80 | "filter_watch_id": 2, 81 | "filter_name": "Schedule", 82 | "x": 104, 83 | "y": 70, 84 | "filter_type": "cron", 85 | "var1": "@every 30m", 86 | "var2": "no", 87 | "Parents": null, 88 | "Children": null, 89 | "Results": null, 90 | "Logs": null, 91 | "CronEntry": null 92 | }, 93 | { 94 | "filter_id": 86, 95 | "filter_watch_id": 2, 96 | "filter_name": "Expect", 97 | "x": 540, 98 | "y": 557, 99 | "filter_type": "expect", 100 | "var1": "2", 101 | "var2": "", 102 | "Parents": null, 103 | "Children": null, 104 | "Results": null, 105 | "Logs": null, 106 | "CronEntry": null 107 | }, 108 | { 109 | "filter_id": 87, 110 | "filter_watch_id": 2, 111 | "filter_name": "Disable", 112 | "x": 735, 113 | "y": 557, 114 | "filter_type": "disable", 115 | "var1": "", 116 | "var2": "", 117 | "Parents": null, 118 | "Children": null, 119 | "Results": null, 120 | "Logs": null, 121 | "CronEntry": null 122 | }, 123 | { 124 | "filter_id": 88, 125 | "filter_watch_id": 2, 126 | "filter_name": "DisableNotify", 127 | "x": 733, 128 | "y": 659, 129 | "filter_type": "notify", 130 | "var1": "Disabled schedule for {{ .WatchName }}", 131 | "var2": "All", 132 | "Parents": null, 133 | "Children": null, 134 | "Results": null, 135 | "Logs": null, 136 | "CronEntry": null 137 | } 138 | ], 139 | "connections": [ 140 | { 141 | "filter_connection_id": 72, 142 | "connection_watch_id": 2, 143 | "filter_output_id": 80, 144 | "filter_input_id": 81 145 | }, 146 | { 147 | "filter_connection_id": 73, 148 | "connection_watch_id": 2, 149 | "filter_output_id": 81, 150 | "filter_input_id": 82 151 | }, 152 | { 153 | "filter_connection_id": 74, 154 | "connection_watch_id": 2, 155 | "filter_output_id": 82, 156 | "filter_input_id": 83 157 | }, 158 | { 159 | "filter_connection_id": 75, 160 | "connection_watch_id": 2, 161 | "filter_output_id": 83, 162 | "filter_input_id": 84 163 | }, 164 | { 165 | "filter_connection_id": 76, 166 | "connection_watch_id": 2, 167 | "filter_output_id": 85, 168 | "filter_input_id": 80 169 | }, 170 | { 171 | "filter_connection_id": 77, 172 | "connection_watch_id": 2, 173 | "filter_output_id": 81, 174 | "filter_input_id": 86 175 | }, 176 | { 177 | "filter_connection_id": 78, 178 | "connection_watch_id": 2, 179 | "filter_output_id": 86, 180 | "filter_input_id": 87 181 | }, 182 | { 183 | "filter_connection_id": 79, 184 | "connection_watch_id": 2, 185 | "filter_output_id": 86, 186 | "filter_input_id": 88 187 | } 188 | ] 189 | } -------------------------------------------------------------------------------- /web/templates/watch/view.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | GoWatch {{ .Watch.Name }} 3 | {{end}} 4 | 5 | {{define "content"}} 6 | {{ if .error }} 7 | Could not find entry 8 | {{ end }} 9 | 10 |
11 |
12 |
13 |
14 |
15 | {{ .Watch.Name }} 16 | Edit 17 |
18 | {{ if not .Watch.CronEntry }} 19 |
No Schedule
20 | {{ else }} 21 |
22 |
Previous
23 |
{{ .Watch.CronEntry.Prev.Format "2006-01-02 15:04:05" }}
24 |
25 |
26 |
Next
27 |
{{ .Watch.CronEntry.Next.Format "2006-01-02 15:04:05" }}
28 |
29 | {{ end }} 30 | 31 |
32 |
33 |
34 |
35 | {{ if .numericalMap }} 36 | 37 | 38 | 39 | {{ end }} 40 | 41 | {{ $first := true }} 42 | {{ if .categoricalMap }} 43 | 51 |
52 | {{ $first = true }} 53 | {{ range $name, $values := .categoricalMap }} 54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | {{ range $value := $values }} 64 | 65 | 66 | 67 | 68 | {{ end }} 69 | 70 |
WhenValue
{{ $value.Time.Format "2006-01-02 15:04:05" }}{{ $value.Value }}
71 |
72 | {{ $first = false }} 73 | {{ end }} 74 |
75 | {{ end }} 76 | 77 | 78 | {{end}} 79 | 80 | {{ define "scripts"}} 81 | {{ if .numericalMap }} 82 | 83 | 84 | 85 | 86 | 138 | {{ end }} 139 | {{ end }} -------------------------------------------------------------------------------- /web/watchTemplates/Etsy.json: -------------------------------------------------------------------------------- 1 | { 2 | "filters": [ 3 | { 4 | "filter_id": 45, 5 | "filter_watch_id": 4, 6 | "filter_name": "Fetch", 7 | "x": 168, 8 | "y": 214, 9 | "filter_type": "gurl", 10 | "var1": "-", 11 | "var2": "", 12 | "Parents": null, 13 | "Children": null, 14 | "Results": null, 15 | "Logs": null, 16 | "CronEntry": null 17 | }, 18 | { 19 | "filter_id": 46, 20 | "filter_watch_id": 4, 21 | "filter_name": "CSS", 22 | "x": 398, 23 | "y": 213, 24 | "filter_type": "css", 25 | "var1": ".wt-text-title-03", 26 | "var2": "", 27 | "Parents": null, 28 | "Children": null, 29 | "Results": null, 30 | "Logs": null, 31 | "CronEntry": null 32 | }, 33 | { 34 | "filter_id": 47, 35 | "filter_watch_id": 4, 36 | "filter_name": "Sanitize", 37 | "x": 629, 38 | "y": 214, 39 | "filter_type": "replace", 40 | "var1": "[^$]*[$]([0-9.]+)[^z]+", 41 | "var2": "$1", 42 | "Parents": null, 43 | "Children": null, 44 | "Results": null, 45 | "Logs": null, 46 | "CronEntry": null 47 | }, 48 | { 49 | "filter_id": 48, 50 | "filter_watch_id": 4, 51 | "filter_name": "Price", 52 | "x": 878, 53 | "y": 217, 54 | "filter_type": "store", 55 | "var1": "", 56 | "var2": "", 57 | "Parents": null, 58 | "Children": null, 59 | "Results": null, 60 | "Logs": null, 61 | "CronEntry": null 62 | }, 63 | { 64 | "filter_id": 49, 65 | "filter_watch_id": 4, 66 | "filter_name": "Different", 67 | "x": 878, 68 | "y": 322, 69 | "filter_type": "condition", 70 | "var1": "diff", 71 | "var2": "Price", 72 | "Parents": null, 73 | "Children": null, 74 | "Results": null, 75 | "Logs": null, 76 | "CronEntry": null 77 | }, 78 | { 79 | "filter_id": 50, 80 | "filter_watch_id": 4, 81 | "filter_name": "Notify", 82 | "x": 1080, 83 | "y": 326, 84 | "filter_type": "notify", 85 | "var1": "{{ .WatchName }} changed price {{ .Sanitize }}", 86 | "var2": "All", 87 | "Parents": null, 88 | "Children": null, 89 | "Results": null, 90 | "Logs": null, 91 | "CronEntry": null 92 | }, 93 | { 94 | "filter_id": 51, 95 | "filter_watch_id": 4, 96 | "filter_name": "Schedule", 97 | "x": 167, 98 | "y": 74, 99 | "filter_type": "cron", 100 | "var1": "@every 15m", 101 | "var2": "no", 102 | "Parents": null, 103 | "Children": null, 104 | "Results": null, 105 | "Logs": null, 106 | "CronEntry": null 107 | }, 108 | { 109 | "filter_id": 52, 110 | "filter_watch_id": 4, 111 | "filter_name": "Expect", 112 | "x": 624, 113 | "y": 507, 114 | "filter_type": "expect", 115 | "var1": "1", 116 | "var2": "", 117 | "Parents": null, 118 | "Children": null, 119 | "Results": null, 120 | "Logs": null, 121 | "CronEntry": null 122 | }, 123 | { 124 | "filter_id": 53, 125 | "filter_watch_id": 4, 126 | "filter_name": "Disable", 127 | "x": 856, 128 | "y": 510, 129 | "filter_type": "disable", 130 | "var1": "", 131 | "var2": "", 132 | "Parents": null, 133 | "Children": null, 134 | "Results": null, 135 | "Logs": null, 136 | "CronEntry": null 137 | }, 138 | { 139 | "filter_id": 54, 140 | "filter_watch_id": 4, 141 | "filter_name": "DisableNotify", 142 | "x": 857, 143 | "y": 629, 144 | "filter_type": "notify", 145 | "var1": "Disabled schedule for {{ .WatchName }}", 146 | "var2": "All", 147 | "Parents": null, 148 | "Children": null, 149 | "Results": null, 150 | "Logs": null, 151 | "CronEntry": null 152 | } 153 | ], 154 | "connections": [ 155 | { 156 | "filter_connection_id": 40, 157 | "connection_watch_id": 4, 158 | "filter_output_id": 45, 159 | "filter_input_id": 46 160 | }, 161 | { 162 | "filter_connection_id": 41, 163 | "connection_watch_id": 4, 164 | "filter_output_id": 46, 165 | "filter_input_id": 47 166 | }, 167 | { 168 | "filter_connection_id": 42, 169 | "connection_watch_id": 4, 170 | "filter_output_id": 47, 171 | "filter_input_id": 48 172 | }, 173 | { 174 | "filter_connection_id": 43, 175 | "connection_watch_id": 4, 176 | "filter_output_id": 47, 177 | "filter_input_id": 49 178 | }, 179 | { 180 | "filter_connection_id": 44, 181 | "connection_watch_id": 4, 182 | "filter_output_id": 49, 183 | "filter_input_id": 50 184 | }, 185 | { 186 | "filter_connection_id": 45, 187 | "connection_watch_id": 4, 188 | "filter_output_id": 51, 189 | "filter_input_id": 45 190 | }, 191 | { 192 | "filter_connection_id": 46, 193 | "connection_watch_id": 4, 194 | "filter_output_id": 46, 195 | "filter_input_id": 52 196 | }, 197 | { 198 | "filter_connection_id": 47, 199 | "connection_watch_id": 4, 200 | "filter_output_id": 52, 201 | "filter_input_id": 53 202 | }, 203 | { 204 | "filter_connection_id": 48, 205 | "connection_watch_id": 4, 206 | "filter_output_id": 52, 207 | "filter_input_id": 54 208 | } 209 | ] 210 | } -------------------------------------------------------------------------------- /web/watchTemplates/NewEgg.json: -------------------------------------------------------------------------------- 1 | { 2 | "filters": [ 3 | { 4 | "filter_id": 55, 5 | "filter_watch_id": 5, 6 | "filter_name": "Fetch", 7 | "x": 168, 8 | "y": 214, 9 | "filter_type": "gurl", 10 | "var1": "-", 11 | "var2": "", 12 | "Parents": null, 13 | "Children": null, 14 | "Results": null, 15 | "Logs": null, 16 | "CronEntry": null 17 | }, 18 | { 19 | "filter_id": 56, 20 | "filter_watch_id": 5, 21 | "filter_name": "CSS", 22 | "x": 398, 23 | "y": 213, 24 | "filter_type": "css", 25 | "var1": ".product-price .price-current strong", 26 | "var2": "", 27 | "Parents": null, 28 | "Children": null, 29 | "Results": null, 30 | "Logs": null, 31 | "CronEntry": null 32 | }, 33 | { 34 | "filter_id": 57, 35 | "filter_watch_id": 5, 36 | "filter_name": "Sanitize", 37 | "x": 629, 38 | "y": 214, 39 | "filter_type": "replace", 40 | "var1": "[^0-9]", 41 | "var2": "", 42 | "Parents": null, 43 | "Children": null, 44 | "Results": null, 45 | "Logs": null, 46 | "CronEntry": null 47 | }, 48 | { 49 | "filter_id": 58, 50 | "filter_watch_id": 5, 51 | "filter_name": "Price", 52 | "x": 878, 53 | "y": 217, 54 | "filter_type": "store", 55 | "var1": "", 56 | "var2": "", 57 | "Parents": null, 58 | "Children": null, 59 | "Results": null, 60 | "Logs": null, 61 | "CronEntry": null 62 | }, 63 | { 64 | "filter_id": 59, 65 | "filter_watch_id": 5, 66 | "filter_name": "Different", 67 | "x": 878, 68 | "y": 322, 69 | "filter_type": "condition", 70 | "var1": "diff", 71 | "var2": "Price", 72 | "Parents": null, 73 | "Children": null, 74 | "Results": null, 75 | "Logs": null, 76 | "CronEntry": null 77 | }, 78 | { 79 | "filter_id": 60, 80 | "filter_watch_id": 5, 81 | "filter_name": "Notify", 82 | "x": 1080, 83 | "y": 326, 84 | "filter_type": "notify", 85 | "var1": "{{ .WatchName }} changed price {{ .Sanitize }}", 86 | "var2": "All", 87 | "Parents": null, 88 | "Children": null, 89 | "Results": null, 90 | "Logs": null, 91 | "CronEntry": null 92 | }, 93 | { 94 | "filter_id": 61, 95 | "filter_watch_id": 5, 96 | "filter_name": "Schedule", 97 | "x": 167, 98 | "y": 74, 99 | "filter_type": "cron", 100 | "var1": "@every 15m", 101 | "var2": "no", 102 | "Parents": null, 103 | "Children": null, 104 | "Results": null, 105 | "Logs": null, 106 | "CronEntry": null 107 | }, 108 | { 109 | "filter_id": 62, 110 | "filter_watch_id": 5, 111 | "filter_name": "Expect", 112 | "x": 634, 113 | "y": 475, 114 | "filter_type": "expect", 115 | "var1": "1", 116 | "var2": "", 117 | "Parents": null, 118 | "Children": null, 119 | "Results": null, 120 | "Logs": null, 121 | "CronEntry": null 122 | }, 123 | { 124 | "filter_id": 63, 125 | "filter_watch_id": 5, 126 | "filter_name": "Disable", 127 | "x": 872, 128 | "y": 480, 129 | "filter_type": "disable", 130 | "var1": "", 131 | "var2": "", 132 | "Parents": null, 133 | "Children": null, 134 | "Results": null, 135 | "Logs": null, 136 | "CronEntry": null 137 | }, 138 | { 139 | "filter_id": 64, 140 | "filter_watch_id": 5, 141 | "filter_name": "DisableNotify", 142 | "x": 873, 143 | "y": 603, 144 | "filter_type": "notify", 145 | "var1": "Disabled schedule for {{ .WatchName }}", 146 | "var2": "All", 147 | "Parents": null, 148 | "Children": null, 149 | "Results": null, 150 | "Logs": null, 151 | "CronEntry": null 152 | } 153 | ], 154 | "connections": [ 155 | { 156 | "filter_connection_id": 49, 157 | "connection_watch_id": 5, 158 | "filter_output_id": 55, 159 | "filter_input_id": 56 160 | }, 161 | { 162 | "filter_connection_id": 50, 163 | "connection_watch_id": 5, 164 | "filter_output_id": 56, 165 | "filter_input_id": 57 166 | }, 167 | { 168 | "filter_connection_id": 51, 169 | "connection_watch_id": 5, 170 | "filter_output_id": 57, 171 | "filter_input_id": 58 172 | }, 173 | { 174 | "filter_connection_id": 52, 175 | "connection_watch_id": 5, 176 | "filter_output_id": 57, 177 | "filter_input_id": 59 178 | }, 179 | { 180 | "filter_connection_id": 53, 181 | "connection_watch_id": 5, 182 | "filter_output_id": 59, 183 | "filter_input_id": 60 184 | }, 185 | { 186 | "filter_connection_id": 54, 187 | "connection_watch_id": 5, 188 | "filter_output_id": 61, 189 | "filter_input_id": 55 190 | }, 191 | { 192 | "filter_connection_id": 55, 193 | "connection_watch_id": 5, 194 | "filter_output_id": 56, 195 | "filter_input_id": 62 196 | }, 197 | { 198 | "filter_connection_id": 56, 199 | "connection_watch_id": 5, 200 | "filter_output_id": 62, 201 | "filter_input_id": 63 202 | }, 203 | { 204 | "filter_connection_id": 57, 205 | "connection_watch_id": 5, 206 | "filter_output_id": 62, 207 | "filter_input_id": 64 208 | } 209 | ] 210 | } -------------------------------------------------------------------------------- /web/watchTemplates/Ebay.json: -------------------------------------------------------------------------------- 1 | { 2 | "filters": [ 3 | { 4 | "filter_id": 35, 5 | "filter_watch_id": 3, 6 | "filter_name": "Fetch", 7 | "x": 168, 8 | "y": 214, 9 | "filter_type": "gurl", 10 | "var1": "-", 11 | "var2": "", 12 | "Parents": null, 13 | "Children": null, 14 | "Results": null, 15 | "Logs": null, 16 | "CronEntry": null 17 | }, 18 | { 19 | "filter_id": 36, 20 | "filter_watch_id": 3, 21 | "filter_name": "CSS", 22 | "x": 398, 23 | "y": 213, 24 | "filter_type": "css", 25 | "var1": ".x-price-primary span span", 26 | "var2": "", 27 | "Parents": null, 28 | "Children": null, 29 | "Results": null, 30 | "Logs": null, 31 | "CronEntry": null 32 | }, 33 | { 34 | "filter_id": 37, 35 | "filter_watch_id": 3, 36 | "filter_name": "Sanitize", 37 | "x": 629, 38 | "y": 214, 39 | "filter_type": "replace", 40 | "var1": ".*US[ ][$]([0-9]+[.][0-9]+).*", 41 | "var2": "$1", 42 | "Parents": null, 43 | "Children": null, 44 | "Results": null, 45 | "Logs": null, 46 | "CronEntry": null 47 | }, 48 | { 49 | "filter_id": 38, 50 | "filter_watch_id": 3, 51 | "filter_name": "Price", 52 | "x": 878, 53 | "y": 217, 54 | "filter_type": "store", 55 | "var1": "", 56 | "var2": "", 57 | "Parents": null, 58 | "Children": null, 59 | "Results": null, 60 | "Logs": null, 61 | "CronEntry": null 62 | }, 63 | { 64 | "filter_id": 39, 65 | "filter_watch_id": 3, 66 | "filter_name": "Different", 67 | "x": 878, 68 | "y": 322, 69 | "filter_type": "condition", 70 | "var1": "diff", 71 | "var2": "Price", 72 | "Parents": null, 73 | "Children": null, 74 | "Results": null, 75 | "Logs": null, 76 | "CronEntry": null 77 | }, 78 | { 79 | "filter_id": 40, 80 | "filter_watch_id": 3, 81 | "filter_name": "Notify", 82 | "x": 1080, 83 | "y": 326, 84 | "filter_type": "notify", 85 | "var1": "{{ .WatchName }} changed price {{ .Sanitize }}", 86 | "var2": "All", 87 | "Parents": null, 88 | "Children": null, 89 | "Results": null, 90 | "Logs": null, 91 | "CronEntry": null 92 | }, 93 | { 94 | "filter_id": 41, 95 | "filter_watch_id": 3, 96 | "filter_name": "Schedule", 97 | "x": 167, 98 | "y": 74, 99 | "filter_type": "cron", 100 | "var1": "@every 15m", 101 | "var2": "no", 102 | "Parents": null, 103 | "Children": null, 104 | "Results": null, 105 | "Logs": null, 106 | "CronEntry": null 107 | }, 108 | { 109 | "filter_id": 42, 110 | "filter_watch_id": 3, 111 | "filter_name": "Expect", 112 | "x": 621, 113 | "y": 493, 114 | "filter_type": "expect", 115 | "var1": "1", 116 | "var2": "", 117 | "Parents": null, 118 | "Children": null, 119 | "Results": null, 120 | "Logs": null, 121 | "CronEntry": null 122 | }, 123 | { 124 | "filter_id": 43, 125 | "filter_watch_id": 3, 126 | "filter_name": "Disable", 127 | "x": 863, 128 | "y": 497, 129 | "filter_type": "disable", 130 | "var1": "", 131 | "var2": "", 132 | "Parents": null, 133 | "Children": null, 134 | "Results": null, 135 | "Logs": null, 136 | "CronEntry": null 137 | }, 138 | { 139 | "filter_id": 44, 140 | "filter_watch_id": 3, 141 | "filter_name": "DisableNotify", 142 | "x": 867, 143 | "y": 619, 144 | "filter_type": "notify", 145 | "var1": "Disabled schedule for {{ .WatchName }}", 146 | "var2": "All", 147 | "Parents": null, 148 | "Children": null, 149 | "Results": null, 150 | "Logs": null, 151 | "CronEntry": null 152 | } 153 | ], 154 | "connections": [ 155 | { 156 | "filter_connection_id": 31, 157 | "connection_watch_id": 3, 158 | "filter_output_id": 35, 159 | "filter_input_id": 36 160 | }, 161 | { 162 | "filter_connection_id": 32, 163 | "connection_watch_id": 3, 164 | "filter_output_id": 36, 165 | "filter_input_id": 37 166 | }, 167 | { 168 | "filter_connection_id": 33, 169 | "connection_watch_id": 3, 170 | "filter_output_id": 37, 171 | "filter_input_id": 38 172 | }, 173 | { 174 | "filter_connection_id": 34, 175 | "connection_watch_id": 3, 176 | "filter_output_id": 37, 177 | "filter_input_id": 39 178 | }, 179 | { 180 | "filter_connection_id": 35, 181 | "connection_watch_id": 3, 182 | "filter_output_id": 39, 183 | "filter_input_id": 40 184 | }, 185 | { 186 | "filter_connection_id": 36, 187 | "connection_watch_id": 3, 188 | "filter_output_id": 41, 189 | "filter_input_id": 35 190 | }, 191 | { 192 | "filter_connection_id": 37, 193 | "connection_watch_id": 3, 194 | "filter_output_id": 36, 195 | "filter_input_id": 42 196 | }, 197 | { 198 | "filter_connection_id": 38, 199 | "connection_watch_id": 3, 200 | "filter_output_id": 42, 201 | "filter_input_id": 43 202 | }, 203 | { 204 | "filter_connection_id": 39, 205 | "connection_watch_id": 3, 206 | "filter_output_id": 42, 207 | "filter_input_id": 44 208 | } 209 | ] 210 | } -------------------------------------------------------------------------------- /web/watchTemplates/Tweakers.json: -------------------------------------------------------------------------------- 1 | { 2 | "filters": [ 3 | { 4 | "filter_id": 89, 5 | "filter_watch_id": 6, 6 | "filter_name": "Fetch", 7 | "x": 81, 8 | "y": 212, 9 | "filter_type": "gurl", 10 | "var1": "-", 11 | "var2": "", 12 | "Parents": null, 13 | "Children": null, 14 | "Results": null, 15 | "Logs": null, 16 | "CronEntry": null 17 | }, 18 | { 19 | "filter_id": 90, 20 | "filter_watch_id": 6, 21 | "filter_name": "Schedule", 22 | "x": 86, 23 | "y": 63, 24 | "filter_type": "cron", 25 | "var1": "@every 15m + 10m", 26 | "var2": "no", 27 | "Parents": null, 28 | "Children": null, 29 | "Results": null, 30 | "Logs": null, 31 | "CronEntry": null 32 | }, 33 | { 34 | "filter_id": 91, 35 | "filter_watch_id": 6, 36 | "filter_name": "XPath", 37 | "x": 298, 38 | "y": 210, 39 | "filter_type": "xpath", 40 | "var1": "//table[not(contains(@class,'refurbished-items'))]//td[@class='shop-price']//a", 41 | "var2": "", 42 | "Parents": null, 43 | "Children": null, 44 | "Results": null, 45 | "Logs": null, 46 | "CronEntry": null 47 | }, 48 | { 49 | "filter_id": 92, 50 | "filter_watch_id": 6, 51 | "filter_name": "Sanitize", 52 | "x": 523, 53 | "y": 208, 54 | "filter_type": "match", 55 | "var1": "€.[0-9.]+", 56 | "var2": "", 57 | "Parents": null, 58 | "Children": null, 59 | "Results": null, 60 | "Logs": null, 61 | "CronEntry": null 62 | }, 63 | { 64 | "filter_id": 93, 65 | "filter_watch_id": 6, 66 | "filter_name": "Sanitize2", 67 | "x": 728, 68 | "y": 210, 69 | "filter_type": "replace", 70 | "var1": "[^0-9]", 71 | "var2": "", 72 | "Parents": null, 73 | "Children": null, 74 | "Results": null, 75 | "Logs": null, 76 | "CronEntry": null 77 | }, 78 | { 79 | "filter_id": 94, 80 | "filter_watch_id": 6, 81 | "filter_name": "Min", 82 | "x": 941, 83 | "y": 209, 84 | "filter_type": "math", 85 | "var1": "min", 86 | "var2": "", 87 | "Parents": null, 88 | "Children": null, 89 | "Results": null, 90 | "Logs": null, 91 | "CronEntry": null 92 | }, 93 | { 94 | "filter_id": 95, 95 | "filter_watch_id": 6, 96 | "filter_name": "Minimum", 97 | "x": 1130, 98 | "y": 210, 99 | "filter_type": "store", 100 | "var1": "", 101 | "var2": "", 102 | "Parents": null, 103 | "Children": null, 104 | "Results": null, 105 | "Logs": null, 106 | "CronEntry": null 107 | }, 108 | { 109 | "filter_id": 96, 110 | "filter_watch_id": 6, 111 | "filter_name": "Diff", 112 | "x": 897, 113 | "y": 370, 114 | "filter_type": "condition", 115 | "var1": "diff", 116 | "var2": "Minimum", 117 | "Parents": null, 118 | "Children": null, 119 | "Results": null, 120 | "Logs": null, 121 | "CronEntry": null 122 | }, 123 | { 124 | "filter_id": 97, 125 | "filter_watch_id": 6, 126 | "filter_name": "Notify", 127 | "x": 1083, 128 | "y": 486, 129 | "filter_type": "notify", 130 | "var1": "{{ .WatchName }} Price change: {{ .Min }}", 131 | "var2": "All", 132 | "Parents": null, 133 | "Children": null, 134 | "Results": null, 135 | "Logs": null, 136 | "CronEntry": null 137 | }, 138 | { 139 | "filter_id": 98, 140 | "filter_watch_id": 6, 141 | "filter_name": "Round", 142 | "x": 1086, 143 | "y": 364, 144 | "filter_type": "replace", 145 | "var1": "[.].*", 146 | "var2": "", 147 | "Parents": null, 148 | "Children": null, 149 | "Results": null, 150 | "Logs": null, 151 | "CronEntry": null 152 | }, 153 | { 154 | "filter_id": 99, 155 | "filter_watch_id": 6, 156 | "filter_name": "Expect", 157 | "x": 517, 158 | "y": 453, 159 | "filter_type": "expect", 160 | "var1": "1", 161 | "var2": "", 162 | "Parents": null, 163 | "Children": null, 164 | "Results": null, 165 | "Logs": null, 166 | "CronEntry": null 167 | }, 168 | { 169 | "filter_id": 100, 170 | "filter_watch_id": 6, 171 | "filter_name": "Disable", 172 | "x": 732, 173 | "y": 455, 174 | "filter_type": "disable", 175 | "var1": "", 176 | "var2": "", 177 | "Parents": null, 178 | "Children": null, 179 | "Results": null, 180 | "Logs": null, 181 | "CronEntry": null 182 | }, 183 | { 184 | "filter_id": 101, 185 | "filter_watch_id": 6, 186 | "filter_name": "DisableNotify", 187 | "x": 736, 188 | "y": 570, 189 | "filter_type": "notify", 190 | "var1": "Disabled schedule for {{ .WatchName }}", 191 | "var2": "All", 192 | "Parents": null, 193 | "Children": null, 194 | "Results": null, 195 | "Logs": null, 196 | "CronEntry": null 197 | } 198 | ], 199 | "connections": [ 200 | { 201 | "filter_connection_id": 80, 202 | "connection_watch_id": 6, 203 | "filter_output_id": 90, 204 | "filter_input_id": 89 205 | }, 206 | { 207 | "filter_connection_id": 81, 208 | "connection_watch_id": 6, 209 | "filter_output_id": 89, 210 | "filter_input_id": 91 211 | }, 212 | { 213 | "filter_connection_id": 82, 214 | "connection_watch_id": 6, 215 | "filter_output_id": 91, 216 | "filter_input_id": 92 217 | }, 218 | { 219 | "filter_connection_id": 83, 220 | "connection_watch_id": 6, 221 | "filter_output_id": 92, 222 | "filter_input_id": 93 223 | }, 224 | { 225 | "filter_connection_id": 84, 226 | "connection_watch_id": 6, 227 | "filter_output_id": 93, 228 | "filter_input_id": 94 229 | }, 230 | { 231 | "filter_connection_id": 85, 232 | "connection_watch_id": 6, 233 | "filter_output_id": 94, 234 | "filter_input_id": 95 235 | }, 236 | { 237 | "filter_connection_id": 86, 238 | "connection_watch_id": 6, 239 | "filter_output_id": 94, 240 | "filter_input_id": 96 241 | }, 242 | { 243 | "filter_connection_id": 87, 244 | "connection_watch_id": 6, 245 | "filter_output_id": 96, 246 | "filter_input_id": 98 247 | }, 248 | { 249 | "filter_connection_id": 88, 250 | "connection_watch_id": 6, 251 | "filter_output_id": 98, 252 | "filter_input_id": 97 253 | }, 254 | { 255 | "filter_connection_id": 89, 256 | "connection_watch_id": 6, 257 | "filter_output_id": 91, 258 | "filter_input_id": 99 259 | }, 260 | { 261 | "filter_connection_id": 90, 262 | "connection_watch_id": 6, 263 | "filter_output_id": 99, 264 | "filter_input_id": 100 265 | }, 266 | { 267 | "filter_connection_id": 91, 268 | "connection_watch_id": 6, 269 | "filter_output_id": 99, 270 | "filter_input_id": 101 271 | } 272 | ] 273 | } -------------------------------------------------------------------------------- /web/templates/watch/edit.html: -------------------------------------------------------------------------------- 1 | {{define "head"}} 2 | 3 | 4 | {{ end }} 5 | {{define "content"}} 6 |
7 | 8 | 9 | 10 |
11 | 12 | {{ end }} 13 | 14 | {{define "title"}} 15 | GoWatch Edit {{ .Watch.Name }} 16 | {{end}} 17 | 18 | {{ define "left" }} 19 | 20 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 56 | 57 |
22 | 25 | 27 | 30 | 32 | 35 | 37 | 38 | Export Watch 39 | 40 | 42 | 45 | 47 | 50 | 52 | 53 | View Watch 54 | 55 |
58 | 59 | 60 | 131 | 132 | 162 | 163 | 190 | 191 | {{ end }} 192 | 193 | {{define "scripts"}} 194 | 230 | {{ end }} -------------------------------------------------------------------------------- /web/static/diagram.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"diagram.js","sourceRoot":"","sources":["diagram.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoWatch 2 | [![Build Status](https://drone.broodjeaap.net/api/badges/broodjeaap/go-watch/status.svg)](https://drone.broodjeaap.net/broodjeaap/go-watch) 3 | [![Maintainability](https://sonarqube.broodjeaap.net/api/project_badges/measure?project=go-watch&metric=sqale_rating&token=sqb_7549038bebbcea93381917d4944968aad542b09a)](https://sonarqube.broodjeaap.net/dashboard?id=go-watch) 4 | [![Reliability Rating](https://sonarqube.broodjeaap.net/api/project_badges/measure?project=go-watch&metric=reliability_rating&token=sqb_7549038bebbcea93381917d4944968aad542b09a)](https://sonarqube.broodjeaap.net/dashboard?id=go-watch) 5 | [![Security Rating](https://sonarqube.broodjeaap.net/api/project_badges/measure?project=go-watch&metric=security_rating&token=sqb_7549038bebbcea93381917d4944968aad542b09a)](https://sonarqube.broodjeaap.net/dashboard?id=go-watch) 6 | 7 | A change detection server that can notify through various services, written in Go 8 | 9 | Some out-of-the-box highlights: 10 | - Create watches by connecting filters in a DAG 11 | - A small runtime footprint, a basic instance uses around 20MB of memory 12 | - Supports Lua scripting to filter/modify/reduce your data any way you want 13 | - Send notifications through Discord, Matrix, Slack, Telegram and many more services 14 | - Disable watches on (repeated) failures 15 | 16 | # Index 17 | - [Intro](#intro) 18 | - [Run](#run) 19 | - [Binary](#binary) 20 | - [Docker](#docker) 21 | - [Compose Templates](#compose-templates) 22 | - [Config](#config) 23 | - [Database](#database) 24 | - [Pruning](#pruning) 25 | - [Proxy](#proxy) 26 | - [Proxy Pools](#proxy-pools) 27 | - [Tor](#tor) 28 | - [Reverse Proxy](#reverse-proxy) 29 | - [Browserless](#browserless) 30 | - [Authentication](#Authentication) 31 | - [Filters](#filters) 32 | - [Schedule](#schedule) 33 | - [Get URL](#get-url) 34 | - [Get URLs](#get-urls) 35 | - [CSS](#css) 36 | - [XPath](#xpath) 37 | - [JSON](#json) 38 | - [Replace](#replace) 39 | - [Match](#match) 40 | - [Substring](#substring) 41 | - [Contains](#contains) 42 | - [Store](#store) 43 | - [Expect](#expect) 44 | - [Notify](#notify) 45 | - [Math](#math) 46 | - [Sum](#sum) 47 | - [Minimum](#minimum) 48 | - [Maximum](#maximum) 49 | - [Average](#average) 50 | - [Count](#count) 51 | - [Round](#round) 52 | - [Condition](#condition) 53 | - [Different Than Last](#different-than-last) 54 | - [Lower Than Last](#lower-than-last) 55 | - [Lowest](#lowest) 56 | - [Lower Than](#lower-than) 57 | - [Higher Than Last](#higher-than-last) 58 | - [Highest](#highest) 59 | - [Higher Than](#higher-than) 60 | - [Browserless](#browserless-1) 61 | - [Browserless Get URL](#browserless-get-url) 62 | - [Browserless Get URLs](#browserless-get-urls) 63 | - [Browserless Function](#browserless-function) 64 | - [Browserless Function On Results](#browserless-function-on-results) 65 | - [Lua](#lua) 66 | - [Notifiers](#notifiers) 67 | - [Shoutrrr](#shoutrrr) 68 | - [Apprise](#apprise) 69 | - [File](#file) 70 | - [Build/Development](#builddevelopment) 71 | - [Typescript compilation](#type-script-compilation) 72 | - [Dependencies](#dependencies) 73 | 74 | # Intro 75 | 76 | GoWatch works through filters, a filter performs operations on the input it recieves. 77 | Here is an example of a 'Watch' that calculates the lowest and average price of 4090s from NewEgg and MicroCenter and notifies the user if the lowest price changed: 78 | ![4090_watch](docs/images/4090_watch.png) 79 | 80 | Note that everything, including scheduling/storing/notifying, is a `filter`. 81 | 82 | `Schedule` is a [cron](#cron) filter with a '@every 15m + 5m' value, this will run every 15-20 minutes. 83 | 84 | 85 | `MicroCenterFetch` is a [Browserless Get URL](#browserless-get-url) filter with a 'https://www.microcenter.com/search/search_results.aspx?Ntk=all&sortby=match&N=4294966937+4294821460+4294805677+4294805676&myStore=true' value, it's output will be the HTTP response. 86 | 87 | `XPath` is an [XPath](#xpath) filter, with the value '//span[@itemprop='price']', its output will be the `span` elements containing the prices. 88 | 89 | `Match` is a [Match](#match) filter, with the value '[0-9]+', it will, for every result from its parent, return just the numbers. 90 | 91 | `NewEggFetch` is a [Get URL](#get-url) filter with a 'https://www.newegg.com/p/pl?N=100007709&d=4090&isdeptsrh=1&PageSize=96' value, it's output will be the HTTP response. 92 | 93 | `CSS` is a [CSS](#css) filter with the value '.item-container .item-action strong[class!="item-buying-choices-price"]' value, it's output will be the html elements containing the prices. 94 | 95 | `Replace` is a [Replace](#replace) filter, using a regular expression ('[^0-9]') it removes anything that's not a number. 96 | 97 | `Avg` is an [Average](#average) filter, it calculates the average value of its inputs. 98 | 99 | `Min` is a [Minimum](#minimum) filter, it calculates the minimum value of its inputs. 100 | 101 | `Average` and `Minimum` are [Store](#store) filters, they store its input values in the database. 102 | 103 | `Diff` is a [Different Than Last](#different-than-last) filter, only passing on the inputs that are different then the last value stored in the database. 104 | 105 | `Notify` is a [Notify](#notify) filter, if there are any inputs to this filter, it will execute a template and send the result to a user defined 'notifier' (Telegram/Discord/etc). 106 | 107 | `Expect` is an [Expect](#expect) filter, it only outputs if it gets no inputs. 108 | `Disable` is a [Disable Schedules](#disable-schedules) filter, it disables all schedules of a watch when it gets any inputs. 109 | `DisableNotify` is another [Notify](#notify) filter. 110 | These 3 filters disable the watch when there are no prices to be found, something is probably going wrong, so we don't want to keep spamming these websites. 111 | # Run 112 | 113 | ## Binary 114 | 115 | Download the binary for your platform from the [releases page](https://github.com/broodjeaap/go-watch/releases), for example for Linux: 116 | `wget https://github.com/broodjeaap/go-watch/releases/download/1.0/go-watch-1.0-linux-amd64 -O ./gowatch` 117 | 118 | And make it executable: 119 | `chmod +x ./gowatch` 120 | 121 | Download the config template: 122 | `wget https://raw.githubusercontent.com/broodjeaap/go-watch/master/web/config.tmpl -O ./config.yaml` 123 | 124 | Or use the binary to generate it: 125 | ```bash 126 | ./gowatch -printConfig 2> config.yaml 127 | # or 128 | ./gowatch -writeConfig config.yaml 129 | ``` 130 | 131 | And modify it to fit your needs, then simply run: 132 | `./gowatch` 133 | 134 | ## Docker 135 | 136 | Probably the easiest way to get started is with the prebuilt docker image `ghcr.io/broodjeaap/go-watch:latest`, first get a config template: 137 | `docker run --rm ghcr.io/broodjeaap/go-watch:latest -printConfig 2> config.yaml` 138 | 139 | Or: 140 | `docker run --rm -v $PWD:/config ghcr.io/broodjeaap/go-watch:latest -writeConfig /config/config.yaml` 141 | 142 | After modifying the config to fit your needs, start the docker container 143 | ```bash 144 | docker run \ 145 | -p 8080:8080 \ 146 | -v $PWD/:/config \ 147 | ghcr.io/broodjeaap/go-watch:latest 148 | ``` 149 | ### Compose templates 150 | 151 | There are a few docker-compose templates in the [docs/compose](https://github.com/broodjeaap/go-watch/tree/master/docs/compose) directory that can be downloaded and used as starting points. 152 | For example, if you want to set up GoWatch with Browserless, Apprise and a PostgreSQL database backend: 153 | `wget https://raw.githubusercontent.com/broodjeaap/go-watch/master/docs/compose/apprise-browserless-postgresql.yml -O ./docker-compose.yml` 154 | 155 | # Config 156 | ## Database 157 | 158 | By default, GoWatch will use an SQLite database, stored in the `/config` directory for the docker image. 159 | If you have only a few watches with schedules of minutes+ then SQLite is probably fine. 160 | But with more watches, especially with shorter schedules, Gorm will start logging warnings about `SLOW SQL`. 161 | Which are just warnings, but at that point it's probably better to switch to another database. 162 | 163 | You can use another database by changing the `database.dsn` value in the config or `GOWATCH_DATABASE_DSN` environment variable, for example with a PostgreSQL database: 164 | ```yaml 165 | version: "3" 166 | 167 | services: 168 | app: 169 | image: ghcr.io/broodjeaap/go-watch:latest 170 | container_name: go-watch 171 | environment: 172 | - GOWATCH_DATABASE_DSN=postgres://gorm:gorm@db:5432/gorm 173 | volumes: 174 | - /host/path/to/config:/config 175 | ports: 176 | - "8080:8080" 177 | depends_on: 178 | db: 179 | condition: service_healthy 180 | db: 181 | image: postgres:15 182 | environment: 183 | - POSTGRES_USER=gorm 184 | - POSTGRES_PASSWORD=gorm 185 | - POSTGRES_DB=gorm 186 | volumes: 187 | - /host/path/to/db:/var/lib/postgresql/data 188 | healthcheck: 189 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 190 | interval: 10s 191 | timeout: 5s 192 | retries: 5 193 | ``` 194 | 195 | ### Pruning 196 | 197 | An automatic database prune job that removes repeating values, this can be scheduled by adding a cron schedule to the config or with the `GOWATCH_SCHEDULE_DELAY` environment variable: 198 | ```yaml 199 | database: 200 | dsn: "/config/watch.db" 201 | prune: "@every 1h" 202 | ``` 203 | 204 | 205 | ## Startup CronJob delay 206 | 207 | If there are multiple watches set up with the same schedule then if GoWatch is restarted, all these watches will trigger at the same time, which causes a short burst of activity. 208 | It might be preferable to spread out these schedules a bit, this can be done by setting `schedule.delay` in the config or with the `GOWATCH_SCHEDULE_DELAY` environment variable: 209 | ```yaml 210 | schedule: 211 | delay: "5s" 212 | ``` 213 | 214 | ## Proxy 215 | 216 | An HTTP/HTTPS proxy can be configured in the config or through the `GOWATCH_PROXY_URL` environment variable: 217 | ```yaml 218 | proxy: 219 | url: http://proxy.com:1234 220 | ``` 221 | This will not work automatically for requests made through Lua filters, but when using the docker image, the `HTTP_PROXY` and `HTTPS_PROXY` environment variables can also be used which will route all traffic through the proxy: 222 | ```yaml 223 | version: "3" 224 | 225 | services: 226 | app: 227 | image: ghcr.io/broodjeaap/go-watch:latest 228 | container_name: go-watch 229 | environment: 230 | - HTTP_PROXY=http://proxy.com:1234 231 | - HTTPS_PROXY=http://proxy.com:1234 232 | ``` 233 | ### Proxy pools 234 | 235 | Proxy 'pools' can be created by configuring the proxy that GoWatch points to, for example with [Squid](http://www.squid-cache.org/): 236 | ```yaml 237 | version: "3" 238 | 239 | services: 240 | app: 241 | image: ghcr.io/broodjeaap/go-watch:latest 242 | container_name: go-watch 243 | environment: 244 | - HTTP_PROXY=http://squid_proxy:3128 245 | - HTTPS_PROXY=http://squid_proxy:3128 246 | squid_proxy: 247 | image: sameersbn/squid:latest 248 | volumes: 249 | - /path/to/squid.conf:/etc/squid/squid.conf 250 | ``` 251 | 252 | And in the `squid.conf` the proxy pool would be defined with [cache_peer](http://www.squid-cache.org/Doc/config/cache_peer/)s like this: 253 | ```conf 254 | cache_peer proxy1.com parent 3128 0 round-robin no-query 255 | cache_peer proxy2.com parent 3128 0 round-robin no-query login=user:pass 256 | ``` 257 | 258 | An example `squid.conf` can be found in [docs/proxy/squid-1.conf](docs/proxy/squid-1.conf). 259 | 260 | ### Tor 261 | 262 | [Tor](https://www.torproject.org/) can also be used to proxy your requests, for example with the [tor-privoxy](https://github.com/dockage/tor-privoxy) container: 263 | ```yaml 264 | version: "3" 265 | 266 | services: 267 | app: 268 | image: ghcr.io/broodjeaap/go-watch:latest 269 | environment: 270 | - HTTP_PROXY=http://tor-privoxy:8118 271 | - HTTPS_PROXY=http://tor-privoxy:8118 272 | volumes: 273 | - ./tmp:/config 274 | ports: 275 | - "8080:8080" 276 | tor-privoxy: 277 | image: dockage/tor-privoxy 278 | ``` 279 | 280 | To test if it's working, add a [Get URL](#get-url) filter with a `https://check.torproject.org/api/ip` value, and check the result. 281 | 282 | ## Reverse Proxy 283 | 284 | GoWatch can be run behind a reverse proxy, if it's hosted under a subdomain (https://gowatch.domain.tld), no changes to the config are needed. 285 | But if you want to run GoWatch under a path (https://domain.tld/gowatch), you can set the `gin.urlprefix` value in the config or the `GOWATCH_GIN_URLPREFIX` environment variable can be used. 286 | ```yaml 287 | gin: 288 | urlprefix: "/gowatch" 289 | ``` 290 | 291 | ## Browserless 292 | 293 | Some websites (Amazon for example) don't send all content on the first request, it's added later through javascript. 294 | To still be able to watch products from these types of websites, GoWatch supports [Browserless](https://www.browserless.io/), the Browserless URL can be added to the config: 295 | ```yaml 296 | browserless: 297 | url: http://your.browserless:3000 298 | ``` 299 | 300 | Or as an environment variable, for example in a docker-compose: 301 | ```yaml 302 | version: "3" 303 | 304 | services: 305 | app: 306 | image: ghcr.io/broodjeaap/go-watch:latest 307 | container_name: go-watch 308 | environment: 309 | - GOWATCH_BROWSERLESS_URL=http://browserless:3000 310 | volumes: 311 | - /host/path/to/config:/config 312 | ports: 313 | - "8080:8080" 314 | browserless: 315 | image: browserless/chrome:latest 316 | ``` 317 | 318 | To use Browserless, the [Browserless Get URL](#browserless-get-url), [Browserless Get URLs](#browserless-get-urls), [Browserless Function](#browserless-function) or [Browserless Function on result](#browserless-function-on-results) filters must be used. 319 | 320 | Note that for Browserless request to be proxied, Browserless needs to be configured to do so: 321 | ```yaml 322 | version: "3" 323 | 324 | services: 325 | app: 326 | image: ghcr.io/broodjeaap/go-watch:latest 327 | container_name: go-watch 328 | environment: 329 | - GOWATCH_PROXY_URL=http://tor-privoxy:8118 330 | - GOWATCH_BROWSERLESS_URL=http://browserless:3000 331 | volumes: 332 | - /host/path/to/config:/config 333 | ports: 334 | - "8080:8080" 335 | tor-privoxy: 336 | image: dockage/tor-privoxy 337 | browserless: 338 | image: browserless/chrome:latest 339 | environment: 340 | - DEFAULT_LAUNCH_ARGS=["--proxy-server=socks5://tor-privoxy:9050"] 341 | ``` 342 | 343 | ## Authentication 344 | 345 | GoWatch doesn't have built in authentication, but we can use a reverse proxy for that, for example through Traefik: 346 | ```yaml 347 | version: "3" 348 | 349 | services: 350 | app: 351 | image: ghcr.io/broodjeaap/go-watch:latest 352 | container_name: go-watch 353 | environment: 354 | - GOWATCH_DATABASE_DSN=postgres://gorm:gorm@db:5432/gorm 355 | volumes: 356 | - /host/path/to/config:/config 357 | ports: 358 | - "8181:8080" 359 | depends_on: 360 | db: 361 | condition: service_healthy 362 | labels: 363 | - "traefik.http.routers.gowatch.rule=Host(`192.168.178.254`)" 364 | - "traefik.http.routers.gowatch.middlewares=test-auth" 365 | db: 366 | image: postgres:15 367 | environment: 368 | - POSTGRES_USER=gorm 369 | - POSTGRES_PASSWORD=gorm 370 | - POSTGRES_DB=gorm 371 | volumes: 372 | - /host/path/to/db:/var/lib/postgresql/data 373 | healthcheck: 374 | test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] 375 | interval: 10s 376 | timeout: 5s 377 | retries: 5 378 | depends_on: 379 | - proxy 380 | proxy: 381 | image: traefik:v2.9.6 382 | command: --providers.docker 383 | labels: 384 | - "traefik.http.middlewares.test-auth.basicauth.users=broodjeaap:$$2y$$10$$aUvoh7HNdt5tvf8PYMKaaOyCLD3Uel03JtEIPxFEBklJE62VX4rD6" 385 | ports: 386 | - "8080:80" 387 | volumes: 388 | - /var/run/docker.sock:/var/run/docker.sock 389 | ``` 390 | 391 | Change the `Host` label to the correct ip/hostname and generate a user/password string with [htpasswd](https://httpd.apache.org/docs/2.4/programs/htpasswd.html) for the `basicauth.users` label, note that the `$` character is escaped with `$$` 392 | 393 | # Filters 394 | 395 | GoWatch comes with many filters that cover most typical use cases. 396 | 397 | ## Schedule 398 | 399 | The `Schedule` filter is used to schedule when your watch will run. 400 | It uses the [cron](https://pkg.go.dev/github.com/robfig/cron/v3@v3.0.0#section-readme) package to schedule Go routines, some common examples would be: 401 | - `@every 15m`: will trigger every 15 minutes starting on server start. 402 | - `@hourly`: will trigger on the hour. 403 | - `30 * * * *`: will trigger every hour on the half hour. 404 | 405 | More detailed instructions can be found in its documentation. 406 | 407 | Optionally one or more 'jitter' duration strings can be added: 408 | - `@every 15m + 10m`: Will trigger every 15 to 25 minutes 409 | - `@every 15m + 5m + 5m`: Same as above, but more centered around 20 minutes 410 | 411 | ## Get URL 412 | 413 | Fetches the given URL and outputs the HTTP response. 414 | For more complicated requests, POSTing/headers/login, use the [HTTP functionality](https://github.com/vadv/gopher-lua-libs/tree/master/http#client-1) in the Lua filter (snippets for these requests are availble from the web UI). 415 | During editing, http requests are cached, so not to trigger any DOS protection on your sources. 416 | 417 | ## Get URLs 418 | 419 | Fetches every URL given as input and outputs every HTTP response. 420 | During editing, http requests are cached, so not to trigger any DOS protection on your sources. 421 | 422 | ## CSS 423 | 424 | Use a [CSS selector](https://www.w3schools.com/cssref/css_selectors.php) to filter your http responses. 425 | The [Cascadia](https://github.com/andybalholm/cascadia) package is used for this filter, check the docs to see what is and isn't supported. 426 | 427 | ## XPath 428 | 429 | Use an [XPath](https://www.w3schools.com/xml/xpath_intro.asp) to filter your http responses. 430 | The [XPath](https://github.com/antchfx/xpath) package is used for this filter, check the docs to see what is and isn't supported. 431 | 432 | ## JSON 433 | 434 | Use a this to filter your JSON responses, the [gjson](https://github.com/tidwall/gjson) package is used for this filter. 435 | Some common examples would be: 436 | - `product.price` 437 | - `items.3` 438 | - `products.#.price` 439 | 440 | ## Replace 441 | 442 | Simple replace filter, supports regular expressions. 443 | If the `With` value is empty, it will just remove matching text. 444 | 445 | ## Match 446 | 447 | Searches for the regex, outputs every match. 448 | 449 | ## Substring 450 | 451 | Substring allows for a [Python like](https://learnpython.com/blog/substring-of-string-python/) substring selection. 452 | For the input string 'Hello World!': 453 | - `:5`: Hello 454 | - `6:`: World! 455 | - `6,0,7`: WHo 456 | - `-6:`: World! 457 | - `-6:,:5`: World!Hello 458 | ## Subset 459 | 460 | Subset allows for a [Python like](https://learnpython.com/blog/substring-of-string-python/) subset selection of its inputs. 461 | For a filter with parents with these results: 462 | - First parent 463 | - `zero` 464 | - `one` 465 | - Second parent 466 | - `two` 467 | - Third parent 468 | - `three` 469 | - `four` 470 | Then: 471 | - `0`: `zero` 472 | - `-1`: `four` 473 | - `0,3`: `zero`, `three` 474 | - `2:4`: `two`, `three` 475 | - `-2:`: `three`, `four` 476 | - `:-2`: `zero`, `one`, `two`, `three` 477 | ## Contains 478 | 479 | Inputs pass if they contain the given regex. 480 | 481 | ## Store 482 | 483 | Stores each input value in the database under its own name. 484 | It's recommended to do this after reducing inputs to a single value (Minimum/Maximum/Average/etc). 485 | 486 | ## Expect 487 | 488 | Outputs a value when it has no inputs, useful to do something (notify) when something goes wrong with your Watch. 489 | Will only trigger once and can be set to wait multiple times before triggering. 490 | 491 | ## Disable Schedules 492 | 493 | Disables all schedules of a watch when it gets any inputs from its parents. 494 | Should be used with an [expect](#expect) filter, useful for disabling a Watch when it keeps failing. 495 | 496 | ## Notify 497 | 498 | Executes the given template and sends the resulting string as a message to the given notifier(s). 499 | It uses the [Golang templating language](https://pkg.go.dev/text/template), filters are available by their name, so for the filter named `Min` in the intro: 500 | - `{{ .Min }}` gets the results (Multiple values get joined by `, `) 501 | - `{{ .Min_Type }}` gets the type of the filter 502 | - `{{ .Min_Var1 }}` gets the first variable, useful for Get URL filters or Schedule filters 503 | - `{{ .Min_Var2 }}` gets the second variable 504 | 505 | To configure notifiers see the [notifiers](#notifiers) section. 506 | 507 | ## Math 508 | 509 | ### Sum 510 | 511 | Sums the inputs together, nonnumerical values are skipped. 512 | ### Minimum 513 | Outputs the lowest value of the inputs, nonnumerical values are skipped. 514 | ### Maximum 515 | Outputs the highest value of the inputs, nonnumerical values are skipped. 516 | 517 | ### Average 518 | Outputs the average of the inputs, nonnumerical values are skipped. 519 | 520 | ### Count 521 | Outputs the number of inputs. 522 | ### Round 523 | Outputs the inputs rounded to the given decimals, nonnumerical values are skipped. 524 | ## Condition 525 | 526 | ### Different Than Last 527 | 528 | Passes an input if it is different than the last stored value. 529 | 530 | ### Lower Than Last 531 | 532 | Passes an input if it is lower than the last stored value. 533 | 534 | ### Lowest 535 | 536 | Passes an input if it is lower than all previous stored values. 537 | 538 | ### Lower Than 539 | 540 | Passes an input if it is lower than a given value. 541 | 542 | ### Higher Than Last 543 | 544 | Passes an input if it is higher than the last stored value. 545 | 546 | ### Highest 547 | Passes an input if it is higher than all previous stored values. 548 | 549 | ### Higher Than 550 | 551 | Passes an input if it is higher than a given value. 552 | 553 | ## Browserless 554 | 555 | 556 | ### Browserless Get URL 557 | 558 | Fetches the given URL through [Browserless](#browserless) and outputs the HTTP response. 559 | Will log an error if no Browserless instance is configured. 560 | 561 | ### Browserless Get URLs 562 | 563 | Fetches every URL given as input through [Browserless](#browserless) and outputs every HTTP response. 564 | Will log an error if no Browserless instance is configured. 565 | 566 | ### Browserless Function 567 | 568 | Executes the given [Puppeteer](https://github.com/puppeteer/puppeteer) [function](https://www.browserless.io/docs/function) in a Browserless session. 569 | 570 | ### Browserless Function On Results 571 | Executes the given [Puppeteer](https://github.com/puppeteer/puppeteer) [function](https://www.browserless.io/docs/function) in a Browserless session for every result. 572 | 573 | ## Lua 574 | 575 | The Lua filter wraps [gopher-lua](https://github.com/yuin/gopher-lua), with [gopher-lua-libs](https://github.com/vadv/gopher-lua-libs) to greatly extend the capabilities of the Lua VM. 576 | A basic script that just passes all inputs to the output looks like this: 577 | ```lua 578 | for i,input in pairs(inputs) do 579 | table.insert(outputs, input) 580 | end 581 | ``` 582 | 583 | Both `inputs` and `outputs` are convenience tables provided by GoWatch to make Lua scripting a bit easier. 584 | There is also a `logs` table that can be used the same way as the `outputs` table (`table.insert(logs, 'this will be logged')`) to provide some basic logging. 585 | 586 | Much of the functionality that is provided through individual filters in GoWatch can also be done from Lua. 587 | The gopher-lua-libs provide an [http](https://github.com/vadv/gopher-lua-libs/tree/master/http) lib, whose output can be parsed with the [xmlpath](https://github.com/vadv/gopher-lua-libs/tree/master/xmlpath) or [json](https://github.com/vadv/gopher-lua-libs/tree/master/json) libs and then filtered with a [regular expression](https://github.com/vadv/gopher-lua-libs/tree/master/regexp) or some regular Lua scripting to then finally be turned into a ready to send notification through a [template](https://github.com/vadv/gopher-lua-libs/tree/master/template). 588 | 589 | # Notifiers 590 | 591 | ## Shoutrrr 592 | 593 | [Shoutrrr](https://containrrr.dev/shoutrrr/v0.5/) can be used to notify many different services, check their docs for a [list](https://containrrr.dev/shoutrrr/v0.5/services/overview/) of which ones. 594 | An example config for sending notifications through Shoutrrr: 595 | ```yaml 596 | notifiers: 597 | Shoutrrr-telegram-discord: 598 | type: "shoutrrr" 599 | urls: 600 | - telegram://@telegram?chats=, 601 | - discord://@ 602 | - etc... 603 | database: 604 | dsn: "watch.db" 605 | prune: "@every 1h" 606 | ``` 607 | 608 | ## Apprise 609 | 610 | [Apprise](https://github.com/caronc/apprise) is another option to send notifications, it supports many different services/protocols, but it requires access to an [Apprise API](https://github.com/caronc/apprise-api). 611 | Luckily there is a [docker image](https://hub.docker.com/r/caronc/apprise) available that we can add to our compose: 612 | ```yaml 613 | version: "3" 614 | 615 | services: 616 | app: 617 | image: ghcr.io/broodjeaap/go-watch:latest 618 | container_name: go-watch 619 | volumes: 620 | - /host/path/to/:/config 621 | ports: 622 | - "8080:8080" 623 | apprise: 624 | image: caronc/apprise:latest 625 | ``` 626 | 627 | And the notifier config: 628 | ```yaml 629 | notifiers: 630 | apprise: 631 | type: "apprise" 632 | url: "http://apprise:8000/notify" 633 | urls: 634 | - "tgram:////" 635 | - "discord:////" 636 | database: 637 | dsn: "watch.db" 638 | prune: "@every 1h" 639 | ``` 640 | 641 | ## File 642 | 643 | GoWatch can also simply append your notification text to a file: 644 | ```yaml 645 | notifiers: 646 | File: 647 | type: "file" 648 | path: /config/notifications.log 649 | ``` 650 | 651 | # Build/Development 652 | 653 | For local development, clone this repository: 654 | `git clone https://github.com/broodjeaap/go-watch` 655 | 656 | And build the binary: 657 | `go build -o ./gowatch` 658 | 659 | Or: 660 | `go run .` 661 | 662 | Or if you have [Air](https://github.com/cosmtrek/air) set up, just: 663 | `air` 664 | 665 | ## type script compilation 666 | 667 | `tsc --watch` 668 | 669 | # Dependencies 670 | 671 | The following libaries are used in Go-Watch: 672 | - [Gin](https://github.com/gin-gonic/gin) for HTTP server 673 | - [multitemplate](https://github.com/gin-contrib/multitemplate) for template inheritance 674 | - [Cascadia](https://pkg.go.dev/github.com/andybalholm/cascadia) for CSS selectors 675 | - [htmlquery](https://pkg.go.dev/github.com/antchfx/htmlquery) for XPath selectors 676 | - [validator](https://pkg.go.dev/github.com/go-playground/validator/v10@v10.11.0) for user user input validation 677 | - [Shoutrrr](https://github.com/containrrr/shoutrrr/) for built in notifiers 678 | - [cron](https://pkg.go.dev/github.com/robfig/cron/v3@v3.0.0) for job scheduling 679 | - [viper](https://pkg.go.dev/github.com/spf13/viper@v1.12.0) for config management 680 | - [gjson](https://pkg.go.dev/github.com/tidwall/gjson@v1.14.2) for JSON selectors 681 | - [gopher-lua](https://github.com/yuin/gopher-lua) for Lua scripting 682 | - [gopher-lua-libs](https://pkg.go.dev/github.com/vadv/gopher-lua-libs@v0.4.0) for expanding the Lua scripting functionality 683 | - [net](https://pkg.go.dev/golang.org/x/net) for http fetching 684 | - [gorm](https://pkg.go.dev/gorm.io/gorm@v1.23.8) for database abstraction 685 | - [sqlite](https://pkg.go.dev/gorm.io/driver/sqlite@v1.3.6) 686 | - [postgres](https://github.com/go-gorm/postgres) 687 | - [mysql](https://github.com/go-gorm/mysql) 688 | - [sqlserver](https://github.com/go-gorm/sqlserver) 689 | - [bootstrap](https://getbootstrap.com/) -------------------------------------------------------------------------------- /web/static/diagram.ts: -------------------------------------------------------------------------------- 1 | abstract class CanvasObject { 2 | x: number; 3 | y: number; 4 | width: number; 5 | height: number; 6 | 7 | hover: boolean 8 | 9 | constructor(x: number, y: number, width: number, height: number){ 10 | this.x = x; 11 | this.y = y; 12 | this.width = width; 13 | this.height = height; 14 | this.hover = false; 15 | } 16 | 17 | abstract update(ms: MouseState): void; 18 | abstract draw(ctx: CanvasRenderingContext2D, ms: MouseState): void; 19 | 20 | pointInObject(p: Point): boolean{ 21 | if (p.x < this.x){ 22 | return false; 23 | } 24 | if (p.y < this.y){ 25 | return false; 26 | } 27 | if (p.x > this.x + this.width) { 28 | return false; 29 | } 30 | if (p.y > this.y + this.height) { 31 | return false; 32 | } 33 | return true; 34 | } 35 | } 36 | 37 | class Button extends CanvasObject { 38 | label: string; 39 | labelWidth: number; 40 | labelHeight: number; 41 | 42 | callback: (node: DiagramNode) => void = function (){}; 43 | node: DiagramNode; 44 | 45 | constructor( 46 | x: number, 47 | y: number, 48 | label: string, 49 | ctx: CanvasRenderingContext2D, 50 | callback: (node: DiagramNode) => void, 51 | node: DiagramNode, 52 | ){ 53 | super(x, y, 0, 0); 54 | this.label = label; 55 | this.callback = callback; 56 | this.node = node; 57 | this.resize(ctx); 58 | } 59 | 60 | update(ms: MouseState){ 61 | this.hover = this.pointInObject(new Point(ms.world.x + ms.offset.x, ms.world.y + ms.offset.y)); 62 | if (ms.click && this.hover){ 63 | this.callback(this.node); 64 | ms.click = false; 65 | } 66 | } 67 | 68 | draw(ctx: CanvasRenderingContext2D, ms: MouseState){ 69 | ctx.fillStyle = this.hover ? "black" : "#6B6B6B"; 70 | ctx.font = "15px Helvetica"; 71 | ctx.fillText(this.label, this.x + 3, this.y + this.labelHeight + 3); 72 | } 73 | 74 | resize(ctx: CanvasRenderingContext2D){ 75 | ctx.font = "15px Helvetica"; 76 | let labelSize = ctx.measureText(this.label); 77 | this.labelWidth = labelSize.width; 78 | this.width = this.labelWidth + 6; 79 | this.labelHeight = labelSize.actualBoundingBoxAscent + labelSize.actualBoundingBoxDescent; 80 | this.height = this.labelHeight + 6; 81 | } 82 | } 83 | 84 | const circleTopRadians = Math.PI / 2; 85 | const circleRightRadians = (Math.PI * 3) / 2; 86 | const circleBottomRadians = Math.PI + (Math.PI * 3); 87 | const circleLeftRadians = Math.PI; 88 | class NodeIO extends CanvasObject { 89 | node: DiagramNode; 90 | input: boolean = false; 91 | radius: number = 15; 92 | 93 | constructor(node: DiagramNode, input: boolean){ 94 | super(0,0,0,0); 95 | this.input = input 96 | this.node = node; 97 | this.reposition(); 98 | } 99 | update(ms: MouseState): void { 100 | if (!ms.draggingConnection && !this.input && this.pointInObject(ms.world) && ms.leftDown){ 101 | ms.draggingConnection = true; 102 | _diagram.newConnection = new NewConnection(this.node); 103 | } 104 | } 105 | 106 | draw(ctx: CanvasRenderingContext2D, ms: MouseState): void { 107 | ctx.fillStyle = this.input ? "#ED575A" : "#66A7C5"; 108 | ctx.beginPath(); 109 | ctx.arc(ms.offset.x + this.x, ms.offset.y + this.y, this.radius, circleRightRadians, circleTopRadians, this.input); 110 | ctx.fill(); 111 | } 112 | 113 | reposition(){ 114 | if (this.input){ 115 | this.x = this.node.x; 116 | this.y = this.node.y + this.node.height / 2; 117 | } else { 118 | this.x = this.node.x + this.node.width; 119 | this.y = this.node.y + this.node.height / 2; 120 | } 121 | } 122 | 123 | pointInObject(p: Point): boolean { 124 | let inCircle = Math.pow(p.x - this.x, 2) + Math.pow(p.y - this.y, 2) <= this.radius * this.radius; 125 | if (!inCircle){ 126 | this.hover = false; 127 | } else { 128 | this.hover = this.input ? p.x < this.x : p.x > this.x; 129 | } 130 | return this.hover; 131 | } 132 | } 133 | 134 | class NodeConnection extends CanvasObject { 135 | output: DiagramNode; 136 | input: DiagramNode; 137 | 138 | controlPoints = { 139 | dX: 0, 140 | outputX: 0, 141 | outputY: 0, 142 | inputX: 0, 143 | inputY: 0, 144 | cp1x: 0, 145 | cp1y: 0, 146 | cp2x: 0, 147 | cp2y: 0, 148 | } 149 | 150 | halfWayPoint: Point = new Point(); 151 | 152 | constructor(output: DiagramNode, input: DiagramNode){ 153 | super(0, 0, 0, 0); 154 | this.output = output; 155 | this.input = input; 156 | } 157 | 158 | update(ms: MouseState): void { 159 | this.controlPoints.outputX = ms.offset.x + this.output.output.x; 160 | this.controlPoints.outputY = ms.offset.y + this.output.output.y; 161 | this.controlPoints.inputX = ms.offset.x + this.input.input.x; 162 | this.controlPoints.inputY = ms.offset.y + this.input.input.y; 163 | this.controlPoints.dX = Math.abs(this.controlPoints.outputX - this.controlPoints.inputX); 164 | 165 | this.controlPoints.cp1x = (this.controlPoints.outputX + this.controlPoints.dX); 166 | this.controlPoints.cp1y = this.controlPoints.outputY; 167 | this.controlPoints.cp2x = (this.controlPoints.inputX - this.controlPoints.dX); 168 | this.controlPoints.cp2y = this.controlPoints.inputY; 169 | 170 | this.halfWayPoint = getBezierXY( 171 | 0.5, 172 | this.controlPoints.outputX, 173 | this.controlPoints.outputY, 174 | this.controlPoints.cp1x, 175 | this.controlPoints.cp1y, 176 | this.controlPoints.cp2x, 177 | this.controlPoints.cp2y, 178 | this.controlPoints.inputX, 179 | this.controlPoints.inputY 180 | ); 181 | this.hover = Math.pow(this.halfWayPoint.x - ms.canvas.x, 2) + Math.pow(this.halfWayPoint.y - ms.canvas.y, 2) <= 15*15; 182 | if (this.hover && ms.click){ 183 | _diagram.removeConnection(this.output, this.input); 184 | ms.click = false; 185 | } 186 | } 187 | draw(ctx: CanvasRenderingContext2D, ms: MouseState): void { 188 | ctx.beginPath(); 189 | ctx.moveTo(this.controlPoints.outputX, this.controlPoints.outputY); 190 | ctx.strokeStyle = "#757575"; 191 | ctx.lineWidth = 5; 192 | ctx.bezierCurveTo( 193 | this.controlPoints.cp1x, 194 | this.controlPoints.cp1y, 195 | this.controlPoints.cp2x, 196 | this.controlPoints.cp2y, 197 | this.controlPoints.inputX, 198 | this.controlPoints.inputY 199 | ); 200 | ctx.stroke(); 201 | ctx.closePath(); 202 | 203 | ctx.beginPath(); 204 | ctx.strokeStyle = this.hover ? "red" : "rgba(200, 200, 200, 0.8)"; 205 | ctx.moveTo(this.halfWayPoint.x - 10, this.halfWayPoint.y - 10); 206 | ctx.lineTo(this.halfWayPoint.x + 10, this.halfWayPoint.y + 10); 207 | ctx.moveTo(this.halfWayPoint.x + 10, this.halfWayPoint.y - 10); 208 | ctx.lineTo(this.halfWayPoint.x - 10, this.halfWayPoint.y + 10); 209 | ctx.stroke(); 210 | ctx.closePath(); 211 | } 212 | } 213 | 214 | class NewConnection extends CanvasObject { 215 | output: DiagramNode; 216 | input: DiagramNode | null; 217 | 218 | controlPoints = { 219 | dX: 0, 220 | outputX: 0, 221 | outputY: 0, 222 | inputX: 0, 223 | inputY: 0, 224 | cp1x: 0, 225 | cp1y: 0, 226 | cp2x: 0, 227 | cp2y: 0, 228 | } 229 | constructor(output: DiagramNode){ 230 | super(0, 0, 0, 0); 231 | this.output = output; 232 | } 233 | 234 | update(ms: MouseState): void { 235 | this.input = null; 236 | for (let node of _diagram.nodes.values()){ 237 | if (this.output.id != node.id && node.pointNearNode(ms.world)){ 238 | this.input = node; 239 | } 240 | } 241 | 242 | if (this.input == null){ 243 | this.controlPoints.outputX = ms.offset.x + this.output.output.x; 244 | this.controlPoints.outputY = ms.offset.y + this.output.output.y; 245 | this.controlPoints.inputX = ms.offset.x + ms.world.x; 246 | this.controlPoints.inputY = ms.offset.y + ms.world.y; 247 | this.controlPoints.dX = Math.abs(this.controlPoints.outputX - this.controlPoints.inputX); 248 | } else { 249 | this.controlPoints.outputX = ms.offset.x + this.output.output.x; 250 | this.controlPoints.outputY = ms.offset.y + this.output.output.y; 251 | this.controlPoints.inputX = ms.offset.x + this.input.input.x; 252 | this.controlPoints.inputY = ms.offset.y + this.input.input.y; 253 | this.controlPoints.dX = Math.abs(this.controlPoints.outputX - this.controlPoints.inputX); 254 | } 255 | 256 | this.controlPoints.cp1x = (this.controlPoints.outputX + this.controlPoints.dX); 257 | this.controlPoints.cp1y = this.controlPoints.outputY; 258 | this.controlPoints.cp2x = (this.controlPoints.inputX - this.controlPoints.dX); 259 | this.controlPoints.cp2y = this.controlPoints.inputY; 260 | } 261 | draw(ctx: CanvasRenderingContext2D, ms: MouseState): void { 262 | ctx.beginPath(); 263 | ctx.moveTo(this.controlPoints.outputX, this.controlPoints.outputY); 264 | ctx.strokeStyle = "#7575A5"; 265 | ctx.lineWidth = 5; 266 | ctx.bezierCurveTo( 267 | this.controlPoints.cp1x, 268 | this.controlPoints.cp1y, 269 | this.controlPoints.cp2x, 270 | this.controlPoints.cp2y, 271 | this.controlPoints.inputX, 272 | this.controlPoints.inputY 273 | ); 274 | ctx.stroke(); 275 | ctx.closePath(); 276 | } 277 | } 278 | 279 | class DiagramNode extends CanvasObject { 280 | id: number; 281 | label: string; 282 | type: string; 283 | 284 | labelWidth: number; 285 | labelHeight: number; 286 | 287 | typeWidth: number; 288 | typeHeight: number; 289 | 290 | deleteButton: Button; 291 | editButton: Button; 292 | 293 | dragging: boolean = false; 294 | dragOrigin: Point = new Point(); 295 | 296 | input: NodeIO; 297 | output: NodeIO; 298 | 299 | parents: Array; 300 | children: Array; 301 | 302 | meta: Object = {}; 303 | results: Array; 304 | logs: Array; 305 | 306 | constructor( 307 | id: number, 308 | x: number, 309 | y: number, 310 | label: string, 311 | ctx: CanvasRenderingContext2D, 312 | meta: Object = {}, 313 | results: Array = new Array(), 314 | logs: Array = new Array(), 315 | ){ 316 | super(x, y, 0, 0) 317 | this.id = id; 318 | this.label = label; 319 | this.meta = meta; 320 | this.fixType(); 321 | this.resize(ctx); 322 | 323 | this.deleteButton = new Button(0, 0, "Del", ctx, _diagram.deleteNodeCallback, this); 324 | this.editButton = new Button(0, 0, "Edit", ctx, _diagram.editNodeCallback, this); 325 | 326 | this.input = new NodeIO(this, true); 327 | this.output = new NodeIO(this, false); 328 | this.results = results; 329 | this.logs = logs; 330 | } 331 | 332 | update(ms: MouseState) { 333 | if (this.pointNearNode(ms.world)){ 334 | this.input.update(ms); 335 | this.output.update(ms); 336 | } 337 | this.hover = (!ms.draggingNode || this.dragging) && super.pointInObject(ms.world); 338 | if (this.hover){ 339 | this.deleteButton.update(ms); 340 | this.editButton.update(ms); 341 | let onButtons = this.deleteButton.hover || this.editButton.hover; 342 | if (!this.dragging && ms.leftDown && !ms.draggingNode && !ms.draggingConnection && !onButtons){ 343 | this.dragging = true; 344 | ms.draggingNode = true; 345 | this.dragOrigin.x = this.x - ms.world.x; 346 | this.dragOrigin.y = this.y - ms.world.y; 347 | } 348 | } else { 349 | this.deleteButton.hover = false; 350 | this.editButton.hover = false; 351 | } 352 | 353 | if (!ms.leftDown){ 354 | this.dragging = false; 355 | ms.draggingNode = false; 356 | } 357 | if (this.dragging){ 358 | this.x = ms.world.x + this.dragOrigin.x; 359 | this.y = ms.world.y + this.dragOrigin.y; 360 | this.input.reposition(); 361 | this.output.reposition(); 362 | } 363 | this.input.update(ms); 364 | this.output.update(ms); 365 | } 366 | 367 | draw(ctx: CanvasRenderingContext2D, ms: MouseState){ 368 | ctx.fillStyle = this.hover ? "#DDDDDD" : "#BFBFBF"; 369 | ctx.fillRect(ms.offset.x + this.x, ms.offset.y + this.y, this.width, this.height); 370 | 371 | ctx.font = "20px Helvetica"; 372 | ctx.fillStyle = "black"; 373 | let labelX = ms.offset.x + this.x + this.width / 2 - this.labelWidth / 2; 374 | let labelY = ms.offset.y +this.y + 3 * 2 + this.labelHeight; 375 | ctx.fillText(this.label, labelX, labelY); 376 | 377 | ctx.font = "15px Helvetica"; 378 | ctx.fillStyle = "#898989"; 379 | let typeX = ms.offset.x + this.x + this.width / 2 - this.typeWidth / 2; 380 | let typeY = ms.offset.y + this.y + this.height - 3; 381 | ctx.fillText(this.type, typeX, typeY); 382 | 383 | let resultCount = `${this.results.length}` 384 | let resultCountSize = ctx.measureText(resultCount); 385 | let resultCountWidth = resultCountSize.width; 386 | let resultCountHeight = resultCountSize.actualBoundingBoxAscent + resultCountSize.actualBoundingBoxDescent; 387 | let resultCountX = ms.offset.x + this.x + this.width - resultCountWidth - 3 * 3; 388 | let resultCountY = ms.offset.y + this.y + resultCountHeight + 3 * 3; 389 | ctx.fillText(resultCount, resultCountX, resultCountY) 390 | 391 | this.deleteButton.x = ms.offset.x + this.x; 392 | this.deleteButton.y = ms.offset.y + this.y + this.height - this.deleteButton.height; 393 | this.deleteButton.draw(ctx, ms); 394 | 395 | this.editButton.x = ms.offset.x + this.x + this.width - this.editButton.width; 396 | this.editButton.y = ms.offset.y + this.y + this.height - this.editButton.height; 397 | this.editButton.draw(ctx, ms); 398 | 399 | this.input.draw(ctx, ms); 400 | this.output.draw(ctx, ms); 401 | 402 | if(this.logs.length > 0){ 403 | ctx.moveTo(ms.offset.x + this.x + 21, ms.offset.y + this.y + 6); 404 | ctx.fillStyle = "orange"; 405 | ctx.beginPath(); 406 | ctx.lineTo(ms.offset.x + this.x + 23, ms.offset.y + this.y + 21); 407 | ctx.lineTo(ms.offset.x + this.x + 6, ms.offset.y + this.y + 21); 408 | ctx.lineTo(ms.offset.x + this.x + 14, ms.offset.y + this.y + 6); 409 | ctx.fill(); 410 | } 411 | 412 | ctx.strokeStyle = "#8E8E8E"; 413 | ctx.lineWidth = 3; 414 | ctx.strokeRect(ms.offset.x + this.x, ms.offset.y + this.y, this.width, this.height); 415 | } 416 | 417 | fixType() { 418 | // @ts-ignore 419 | this.type = this.meta.type 420 | if (["math", "condition"].indexOf(this.type) >= 0 ){ 421 | // @ts-ignore 422 | this.type = this.meta.var1 423 | } 424 | } 425 | 426 | resize(ctx: CanvasRenderingContext2D){ 427 | ctx.font = "20px Helvetica"; 428 | let labelSize = ctx.measureText(this.label); 429 | this.labelWidth = labelSize.width; 430 | this.labelHeight = labelSize.actualBoundingBoxAscent + labelSize.actualBoundingBoxDescent; 431 | this.height = 70; 432 | 433 | ctx.font = "15px Helvetica"; 434 | let typeSize = ctx.measureText(this.type); 435 | this.typeWidth = typeSize.width; 436 | this.typeHeight = typeSize.actualBoundingBoxAscent + typeSize.actualBoundingBoxDescent; 437 | 438 | this.width = Math.max(130, this.labelWidth * 1.5, this.typeWidth * 1.2); 439 | } 440 | 441 | pointInObject(p: Point): boolean { 442 | return this.pointNearNode(p) && (super.pointInObject(p) || this.input.pointInObject(p) || this.output.pointInObject(p)); 443 | } 444 | 445 | 446 | pointNearNode(p: Point){ 447 | // including the input/output circles 448 | if (p.x < this.x - this.input.radius){ 449 | return false; 450 | } 451 | if (p.y < this.y){ 452 | return false; 453 | } 454 | if (p.x > this.x + this.width + this.output.radius){ 455 | return false; 456 | } 457 | if (p.y > this.y + this.height) { 458 | return false; 459 | } 460 | return true; 461 | } 462 | } 463 | 464 | let _diagram: Diagrams; 465 | function tick(){ 466 | _diagram.tick(); 467 | setTimeout(() => { 468 | tick(); 469 | }, 1000/60); 470 | } 471 | function diagramOnResize(){ 472 | _diagram.onresize(); 473 | } 474 | function diagramOnMouseDown(ev: MouseEvent){ 475 | _diagram.onmousedown(ev) 476 | } 477 | function diagramOnMouseUp(ev: MouseEvent){ 478 | _diagram.onmouseup(ev); 479 | } 480 | function diagramOnMouseMove(ev: MouseEvent){ 481 | _diagram.onmousemove(ev) 482 | } 483 | function diagramOnWheel(ev: WheelEvent){ 484 | _diagram.onwheel(ev); 485 | } 486 | function diagramOnContext(ev: MouseEvent){ 487 | ev.preventDefault(); 488 | } 489 | 490 | class Point { 491 | x: number = 0; 492 | y: number = 0; 493 | constructor(x: number = 0, y: number = 0){ 494 | this.x = x; 495 | this.y = y; 496 | } 497 | } 498 | class MouseState { 499 | canvas: Point = new Point(); 500 | absCanvas: Point = new Point(); 501 | world: Point = new Point(); 502 | offset: Point = new Point(); 503 | delta: Point = new Point(); 504 | leftDown: boolean = false; 505 | leftUp: boolean = false; 506 | panning: boolean = false; 507 | draggingNode: boolean = false; 508 | draggingConnection: boolean = false; 509 | click: boolean = true; 510 | } 511 | 512 | class Diagrams { 513 | canvas: HTMLCanvasElement; 514 | ctx: CanvasRenderingContext2D; 515 | shouldTick: boolean = true; 516 | 517 | nodes: Map = new Map(); 518 | 519 | connections: Array = new Array(); 520 | 521 | mouseState: MouseState = new MouseState(); 522 | 523 | 524 | panning: boolean = false; 525 | 526 | nodeDragging: DiagramNode | null = null; 527 | nodeHover: DiagramNode | null = null; 528 | 529 | newConnection: NewConnection | null = null; 530 | 531 | scale: number = 4; 532 | scales: number = 10; 533 | scalingFactor: number = 1; 534 | get inverseScalingFactor(): number {return 1 / this.scalingFactor}; 535 | 536 | editNodeCallback: (node: DiagramNode) => void = function (){}; 537 | deleteNodeCallback: (node: DiagramNode) => void = function (){}; 538 | 539 | constructor( 540 | canvasId: string, 541 | editNodeCallback: (node: DiagramNode) => void = function (){}, 542 | deleteNodeCallback: (node: DiagramNode) => void = function (){}, 543 | ){ 544 | this.canvas = document.getElementById(canvasId) as HTMLCanvasElement; 545 | if (this.canvas === null){ 546 | throw new Error(`Could not getElementById ${canvasId}`); 547 | } 548 | let ctx = this.canvas.getContext("2d"); 549 | if (ctx === null){ 550 | throw new Error(`Could not get 2d rendering context`) 551 | } 552 | _diagram = this; 553 | this.ctx = ctx; 554 | this.editNodeCallback = editNodeCallback; 555 | this.deleteNodeCallback = deleteNodeCallback; 556 | 557 | this.canvas.onmousemove = diagramOnMouseMove; 558 | this.canvas.onmousedown = diagramOnMouseDown; 559 | this.canvas.onmouseup = diagramOnMouseUp; 560 | this.canvas.onwheel = diagramOnWheel; 561 | window.onresize = diagramOnResize; 562 | tick(); 563 | } 564 | 565 | tick(){ 566 | this.drawBackground(); 567 | if (this.mouseState.leftUp && !this.mouseState.panning && !this.mouseState.draggingNode && !this.mouseState.draggingConnection){ 568 | this.mouseState.click = true; 569 | } 570 | for (let node of this.nodes.values()){ 571 | node.update(this.mouseState); 572 | } 573 | for (let connection of this.connections){ 574 | connection.update(this.mouseState); 575 | } 576 | if (this.newConnection != null){ 577 | this.newConnection.update(this.mouseState); 578 | } 579 | for (let connection of this.connections){ 580 | connection.draw(this.ctx, this.mouseState); 581 | } 582 | if (this.newConnection != null){ 583 | this.newConnection.draw(this.ctx, this.mouseState); 584 | } 585 | for (let node of this.nodes.values()){ 586 | node.draw(this.ctx, this.mouseState); 587 | } 588 | this.drawWarning(); 589 | this.mouseState.leftUp = false; 590 | this.mouseState.click = false; 591 | } 592 | 593 | onmousemove(ev: MouseEvent){ 594 | let canvasRect = this.canvas.getBoundingClientRect(); 595 | let scale = this.scalingFactor; 596 | this.mouseState.absCanvas.x = ev.x - canvasRect.left 597 | this.mouseState.absCanvas.y = ev.y - canvasRect.top; 598 | this.mouseState.canvas.x = this.mouseState.absCanvas.x / scale; 599 | this.mouseState.canvas.y = this.mouseState.absCanvas.y / scale; 600 | this.mouseState.delta.x = ev.movementX / scale; 601 | this.mouseState.delta.y = ev.movementY / scale; 602 | 603 | if (this.mouseState.panning){ 604 | this.mouseState.offset.x += this.mouseState.delta.x; 605 | this.mouseState.offset.y += this.mouseState.delta.y; 606 | 607 | let importOffsetInputX = document.getElementById("offset_x") as HTMLInputElement; 608 | importOffsetInputX.value = this.mouseState.offset.x.toString(); 609 | 610 | let importOffsetInputY = document.getElementById("offset_y") as HTMLInputElement; 611 | importOffsetInputY.value = this.mouseState.offset.y.toString(); 612 | } 613 | 614 | this.mouseState.world.x = this.mouseState.canvas.x - this.mouseState.offset.x; 615 | this.mouseState.world.y = this.mouseState.canvas.y - this.mouseState.offset.y; 616 | } 617 | 618 | onmousedown(ev: MouseEvent){ 619 | if (ev.button != 0){ 620 | return; 621 | } 622 | this.mouseState.leftDown = true; 623 | for (let object of this.nodes.values()){ 624 | if (object.pointInObject(this.mouseState.world)) { 625 | return; 626 | } 627 | } 628 | this.mouseState.panning = true; 629 | } 630 | 631 | onmouseup(ev: MouseEvent){ 632 | this.mouseState.leftDown = false; 633 | this.mouseState.panning = false; 634 | this.mouseState.leftUp = true; 635 | if (this.newConnection != null){ 636 | if (this.newConnection.input != null){ 637 | this.addConnection(this.newConnection.output, this.newConnection.input); 638 | } 639 | this.mouseState.draggingConnection = false; 640 | } 641 | this.newConnection = null; 642 | } 643 | 644 | onwheel(ev: WheelEvent) { 645 | ev.preventDefault(); 646 | let sign = Math.sign(ev.deltaY); 647 | let zoomOut = sign > 0; 648 | if (zoomOut && this.scale >= this.scales-1) { 649 | return; 650 | } 651 | let zoomIn = !zoomOut 652 | if (zoomIn && this.scale <= 0) { 653 | return; 654 | } 655 | 656 | this.scale += sign; 657 | let zoomOutFactor = 0.9; 658 | let zoomInFactor = 1 / zoomOutFactor 659 | let zoomFactor = zoomIn ? zoomInFactor : zoomOutFactor; 660 | this.ctx.scale(zoomFactor, zoomFactor); 661 | this.scalingFactor *= zoomFactor; 662 | 663 | let oldCanvasPos = new Point(this.mouseState.canvas.x,this.mouseState.canvas.y) 664 | this.mouseState.canvas.x /= zoomFactor; 665 | this.mouseState.canvas.y /= zoomFactor; 666 | 667 | let mouseDelta = new Point( 668 | (oldCanvasPos.x - this.mouseState.canvas.x), 669 | (oldCanvasPos.y - this.mouseState.canvas.y), 670 | ) 671 | this.mouseState.offset.x -= mouseDelta.x; 672 | this.mouseState.offset.y -= mouseDelta.y; 673 | } 674 | 675 | drawBackground(){ 676 | this.ctx.fillStyle = "#D8D8D8"; 677 | let scale = this.inverseScalingFactor; 678 | this.ctx.fillRect(0,0,this.canvas.width * scale, this.canvas.height * scale); 679 | this.ctx.strokeStyle = "#888"; 680 | this.ctx.lineWidth = 5 * scale; 681 | this.ctx.strokeRect(0, 0, this.canvas.width * scale, this.canvas.height * scale); 682 | } 683 | 684 | drawWarning(){ 685 | let nodeWithLogs: DiagramNode | null = null; 686 | for (let node of this.nodes.values()){ 687 | if (node.logs.length > 0) { 688 | nodeWithLogs = node; 689 | break; 690 | } 691 | } 692 | if (nodeWithLogs == null){ 693 | return 694 | } 695 | let warningString = `Check log of '${nodeWithLogs.label}' Filter!` 696 | this.ctx.font = "30px Helvetica"; 697 | let warningSize = this.ctx.measureText(warningString); 698 | 699 | this.ctx.fillStyle = "orange"; 700 | this.ctx.fillRect(this.canvas.width - warningSize.width - 30, 0, warningSize.width + 30, 50); 701 | this.ctx.fillStyle = "#000"; 702 | this.ctx.fillText(warningString, this.canvas.width - warningSize.width - 15, 35) 703 | 704 | } 705 | 706 | addNode( 707 | id: number, 708 | x: number, 709 | y: number, 710 | label: string, 711 | meta: Object = {}, 712 | results: Array = new Array(), 713 | logs: Array = new Array() 714 | ){ 715 | let node = new DiagramNode(id, x, y, label, this.ctx, meta, results, logs); 716 | this.nodes.set(id, node); 717 | } 718 | 719 | addConnection(A: DiagramNode, B: DiagramNode){ 720 | this.connections.push(new NodeConnection(A, B)); 721 | } 722 | addConnectionById(a: number, b: number){ 723 | let A = this.nodes.get(a); 724 | if (A === undefined){ 725 | console.error(`No node with ID: ${a}`); 726 | return; 727 | } 728 | let B = this.nodes.get(b); 729 | if (B === undefined){ 730 | console.error(`No node with ID: ${b}`); 731 | return; 732 | } 733 | this.connections.push(new NodeConnection(A, B)) 734 | } 735 | removeConnection(A: DiagramNode, B: DiagramNode){ 736 | let index = 0; 737 | for (let connection of this.connections){ 738 | let output = connection.output; 739 | let input = connection.input; 740 | if (output.id == A.id && input.id == B.id) { 741 | this.connections.splice(index, 1); 742 | } 743 | index++; 744 | } 745 | } 746 | 747 | onresize(){ 748 | this.fillParent(); 749 | } 750 | 751 | fillParent(){ 752 | this.canvas.width = this.canvas.clientWidth; 753 | this.canvas.height = this.canvas.clientHeight; 754 | } 755 | } 756 | 757 | // http://www.independent-software.com/determining-coordinates-on-a-html-canvas-bezier-curve.html 758 | function getBezierXY(t, sx, sy, cp1x, cp1y, cp2x, cp2y, ex, ey) { 759 | return new Point( 760 | Math.pow(1-t,3) * sx + 3 * t * Math.pow(1 - t, 2) * cp1x 761 | + 3 * t * t * (1 - t) * cp2x + t * t * t * ex, 762 | Math.pow(1-t,3) * sy + 3 * t * Math.pow(1 - t, 2) * cp1y 763 | + 3 * t * t * (1 - t) * cp2y + t * t * t * ey 764 | ); 765 | } --------------------------------------------------------------------------------