aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2026-01-17 20:07:17 +0100
committerMartin Fischer <martin@push-f.com>2026-01-22 17:56:20 +0100
commitb5e9076c6e1683e4d2384ac76e85ad1503c5336a (patch)
treead7cb44bb28c2266597f6dfc2e9dac7687f49aa3
initial commitHEADmaster
-rw-r--r--.gitignore1
-rw-r--r--LICENSE19
-rw-r--r--README.md19
-rw-r--r--default.nix8
-rw-r--r--go.mod31
-rw-r--r--go.sum83
-rw-r--r--hashes.nix3
-rw-r--r--main.go144
-rw-r--r--main_test.go90
-rw-r--r--service.nix35
10 files changed, 433 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c4a847d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/result
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b8b8894
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 Martin Fischer <martin@push-f.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6611111
--- /dev/null
+++ b/README.md
@@ -0,0 +1,19 @@
+# web-feed-exporter
+
+Prometheus metrics for the most recently published timestamp of web feeds
+(RSS, Atom or JSON), so your Grafana dashboard can remind you to post
+to your blog or social media.
+
+## Provided metrics
+
+The following metrics are provided. All of them have a `feed` label containing the feed name.
+
+* `webfeed_items`, how many items the feed contains
+* `webfeed_newest_published_timestamp_seconds`, Unix timestamp of the newest published item
+* `webfeed_oldest_published_timestamp_seconds`, Unix timestamp of the oldest published item (that is still in the feed)
+
+## Limitations
+
+Feeds often contain only a limited number of the most recent items.
+This exporter does not store anything so it also provides no metrics
+for how many items were published in total or in specific time frames.
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..61416cd
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,8 @@
+{ pkgs ? import <nixpkgs> {} }:
+
+pkgs.buildGoModule {
+ pname = "web-feed-exporter";
+ version = "0+git";
+ src = pkgs.lib.cleanSource ./.;
+ vendorHash = (import ./hashes.nix).vendor;
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..c3d92e9
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,31 @@
+module push-f.com/web-feed-exporter
+
+go 1.25.4
+
+require (
+ github.com/alecthomas/kong v1.13.0
+ github.com/google/go-cmp v0.7.0
+ github.com/mmcdole/gofeed v1.3.0
+ github.com/prometheus/client_golang v1.23.2
+ github.com/prometheus/common v0.66.1
+)
+
+require (
+ github.com/PuerkitoBio/goquery v1.8.0 // indirect
+ github.com/andybalholm/cascadia v1.3.1 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/net v0.43.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/text v0.28.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e568b51
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,83 @@
+github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
+github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA=
+github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
+github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
+github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
+github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
+github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
+golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/hashes.nix b/hashes.nix
new file mode 100644
index 0000000..3ecd26a
--- /dev/null
+++ b/hashes.nix
@@ -0,0 +1,3 @@
+{
+ vendor = "sha256-xNwlFPf8yg3Rbe9MWH05XNapyA1CFhhzy4jEB1VxGGQ=";
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..94b040d
--- /dev/null
+++ b/main.go
@@ -0,0 +1,144 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "math"
+ "net/http"
+ "net/url"
+ "os"
+
+ "github.com/alecthomas/kong"
+ "github.com/mmcdole/gofeed"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+type Args struct {
+ Port int `help:"Port to listen on" short:"p" default:"8888" env:"PORT"`
+ Feeds StringMap `placeholder:"{\"name\": \"url\", ...}" required:"" help:"Feeds to fetch" env:"FEEDS"`
+}
+
+type StringMap map[string]string
+
+func (p *StringMap) UnmarshalJSON(data []byte) error {
+ stringMap := (*map[string]string)(p)
+ return json.Unmarshal(data, &stringMap)
+}
+
+func main() {
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+ Level: slog.LevelDebug,
+ }))
+ slog.SetDefault(logger)
+
+ var args Args
+ ctx := kong.Parse(&args, kong.Description(
+ "Prometheus metrics for the most/least recently published timestamp of web feeds (RSS, Atom or JSON).",
+ ))
+
+ te, err := newExporter(args.Feeds, prometheus.DefaultRegisterer)
+ ctx.FatalIfErrorf(err)
+
+ prometheus.MustRegister(te)
+ http.Handle("/metrics", promhttp.Handler())
+
+ slog.Info("starting", "urls", args.Feeds)
+ slog.Info(fmt.Sprintf("serving metrics at http://localhost:%d/metrics", args.Port))
+ http.ListenAndServe(fmt.Sprintf(":%d", args.Port), nil)
+}
+
+type Exporter struct {
+ urls map[string]string
+ itemCount *prometheus.Desc
+ newestPublishedTimestampSeconds *prometheus.Desc
+ oldestPublishedTimestampSeconds *prometheus.Desc
+}
+
+func newExporter(feeds map[string]string, reg prometheus.Registerer) (*Exporter, error) {
+ if len(feeds) == 0 {
+ return nil, fmt.Errorf("provided feeds map is empty")
+ }
+
+ for _, urlStr := range feeds {
+ url, err := url.Parse(urlStr)
+ if err != nil {
+ return nil, err
+ }
+ if url.Scheme != "http" && url.Scheme != "https" {
+ return nil, fmt.Errorf("unsupported protocol scheme %q (only http and https are supported)", url.Scheme)
+ }
+ }
+
+ return &Exporter{
+ urls: feeds,
+ itemCount: prometheus.NewDesc(
+ "webfeed_items",
+ "How many items the feed contains",
+ []string{"feed"},
+ nil,
+ ),
+ newestPublishedTimestampSeconds: prometheus.NewDesc(
+ "webfeed_newest_published_timestamp_seconds",
+ "Unix timestamp of the newest published item",
+ []string{"feed"},
+ nil,
+ ),
+ oldestPublishedTimestampSeconds: prometheus.NewDesc(
+ "webfeed_oldest_published_timestamp_seconds",
+ "Unix timestamp of the oldest published item (that is still in the feed)",
+ []string{"feed"},
+ nil,
+ ),
+ }, nil
+}
+
+func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
+ ch <- e.itemCount
+ ch <- e.newestPublishedTimestampSeconds
+ ch <- e.oldestPublishedTimestampSeconds
+}
+
+func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
+ for name, url := range e.urls {
+ e.collectFeed(name, url, ch)
+ }
+}
+
+func (e *Exporter) collectFeed(name, url string, ch chan<- prometheus.Metric) {
+ feed, err := fetchFeed(url)
+ if err != nil {
+ slog.Error("failed to fetch feed", "url", url, "error", err)
+ return
+ }
+
+ ch <- prometheus.MustNewConstMetric(e.itemCount, prometheus.GaugeValue, float64(len(feed.Items)), name)
+
+ if len(feed.Items) == 0 {
+ return
+ }
+
+ newestPublished := int64(-1)
+ oldestPublished := int64(math.MaxInt64)
+
+ for _, item := range feed.Items {
+ if item.PublishedParsed != nil {
+ if item.PublishedParsed.Unix() > int64(newestPublished) {
+ newestPublished = item.PublishedParsed.Unix()
+ }
+ if item.PublishedParsed.Unix() < int64(oldestPublished) {
+ oldestPublished = item.PublishedParsed.Unix()
+ }
+ }
+ }
+
+ ch <- prometheus.MustNewConstMetric(e.newestPublishedTimestampSeconds, prometheus.GaugeValue, float64(newestPublished), name)
+ ch <- prometheus.MustNewConstMetric(e.oldestPublishedTimestampSeconds, prometheus.GaugeValue, float64(oldestPublished), name)
+}
+
+var fetchFeed = func(url string) (*gofeed.Feed, error) {
+ fp := gofeed.NewParser()
+ fp.UserAgent = "web-feed-exporter"
+ return fp.ParseURL(url)
+}
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..112798e
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/mmcdole/gofeed"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/common/expfmt"
+)
+
+func TestGrepRepo(t *testing.T) {
+ reposDir := t.TempDir()
+ err := os.Mkdir(path.Join(reposDir, "project1"), 0o755)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ feedUrl1 := "http://example.com/feed1.xml"
+ feedUrl2 := "http://example.com/feed2.xml"
+
+ urls := map[string]string{"feed1": feedUrl1, "feed2": feedUrl2}
+ reg := prometheus.NewRegistry()
+ te, err := newExporter(urls, reg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg.MustRegister(te)
+
+ latestTimes := map[string][]int64{
+ feedUrl1: {1700000000, 1900000000, 1300000000},
+ feedUrl2: {1500000000},
+ }
+
+ fetchFeed = func(url string) (*gofeed.Feed, error) {
+ items := []*gofeed.Item{}
+ for _, sec := range latestTimes[url] {
+ time := time.Unix(sec, 0)
+ items = append(items, &gofeed.Item{
+ PublishedParsed: &time,
+ })
+ }
+ return &gofeed.Feed{
+ Items: items,
+ }, nil
+ }
+
+ want := "# HELP webfeed_items How many items the feed contains\n" +
+ "# TYPE webfeed_items gauge\n" +
+ fmt.Sprintf("webfeed_items{feed=\"feed1\"} %d\n", len(latestTimes[feedUrl1])) +
+ fmt.Sprintf("webfeed_items{feed=\"feed2\"} %d\n", len(latestTimes[feedUrl2])) +
+ "# HELP webfeed_newest_published_timestamp_seconds Unix timestamp of the newest published item\n" +
+ "# TYPE webfeed_newest_published_timestamp_seconds gauge\n" +
+ fmt.Sprintf("webfeed_newest_published_timestamp_seconds{feed=\"feed1\"} %.1e\n", float64(slices.Max(latestTimes[feedUrl1]))) +
+ fmt.Sprintf("webfeed_newest_published_timestamp_seconds{feed=\"feed2\"} %.1e\n", float64(slices.Max(latestTimes[feedUrl2]))) +
+ "# HELP webfeed_oldest_published_timestamp_seconds Unix timestamp of the oldest published item (that is still in the feed)\n" +
+ "# TYPE webfeed_oldest_published_timestamp_seconds gauge\n" +
+ fmt.Sprintf("webfeed_oldest_published_timestamp_seconds{feed=\"feed1\"} %.1e\n", float64(slices.Min(latestTimes[feedUrl1]))) +
+ fmt.Sprintf("webfeed_oldest_published_timestamp_seconds{feed=\"feed2\"} %.1e\n", float64(slices.Min(latestTimes[feedUrl2])))
+
+ if diff := cmp.Diff(collectMetrics(reg), want); diff != "" {
+ t.Errorf("unexpected metrics: %v", diff)
+ }
+}
+
+func collectMetrics(reg *prometheus.Registry) string {
+ mfs, done, err := prometheus.ToTransactionalGatherer(reg).Gather()
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer done()
+
+ var buf strings.Builder
+ enc := expfmt.NewEncoder(&buf, expfmt.NewFormat(expfmt.TypeTextPlain))
+
+ for _, mf := range mfs {
+ err := enc.Encode(mf)
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+ return buf.String()
+}
diff --git a/service.nix b/service.nix
new file mode 100644
index 0000000..1942107
--- /dev/null
+++ b/service.nix
@@ -0,0 +1,35 @@
+{ config, lib, pkgs, ... }:
+
+let
+ web_feed_exporter = pkgs.callPackage ./default.nix {};
+ cfg = config.services.webFeedExporter;
+in
+{
+ options.services.webFeedExporter = {
+ enable = lib.mkEnableOption "web-feed-exporter";
+
+ port = lib.mkOption {
+ type = lib.types.int;
+ };
+
+ feeds = lib.mkOption {
+ type = lib.types.attrsOf lib.types.str;
+ description = "Map of feed names to feed URLs.";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ systemd.services.web-feed-exporter = {
+ serviceConfig = {
+ DynamicUser = true;
+ ExecStart = "${web_feed_exporter}/bin/web-feed-exporter";
+ };
+ environment = {
+ PORT = toString cfg.port;
+ FEEDS = builtins.toJSON cfg.feeds;
+ };
+
+ wantedBy = ["multi-user.target"];
+ };
+ };
+}