jnfilter @ master

  1package main
  2
  3import (
  4	"context"
  5	"embed"
  6	"errors"
  7	"flag"
  8	"fmt"
  9	"io"
 10	"log/slog"
 11	"net/http"
 12	"os"
 13	"regexp"
 14	"strconv"
 15	"strings"
 16	"time"
 17
 18	"github.com/beevik/etree"
 19	"github.com/prometheus/client_golang/prometheus"
 20	"github.com/prometheus/client_golang/prometheus/promauto"
 21	"github.com/prometheus/client_golang/prometheus/promhttp"
 22)
 23
 24const (
 25	feedUrl = "https://api.jovemnerd.com.br/feed-nerdcast/"
 26)
 27
 28type (
 29	errorRequestHandler func(w http.ResponseWriter, r *http.Request) error
 30)
 31
 32var (
 33	//go:embed static/*
 34	assets      embed.FS
 35	serieRegex  = regexp.MustCompile(`(?P<serie>.+) (?P<number>[0-9abc]+) \- (?P<title>.+)`)
 36	errNotFound = errors.New("not found")
 37)
 38
 39var (
 40	regexCollection = map[string]string{
 41		"nerdcast":     "NerdCast [0-9]+[a-c]*",
 42		"empreendedor": "Empreendedor [0-9]+",
 43		"mamicas":      "Caneca de Mamicas [0-9]+",
 44		"english":      "Speak English [0-9]+",
 45		"nerdcash":     "NerdCash [0-9]+",
 46		"bunker":       "Lá do Bunker( LDB especial Oscar|) [0-9]+",
 47		"tech":         "NerdTech [0-9]+",
 48		"genera":       "Generacast [0-9]+",
 49		"rpg":          "NerdCast RPG [0-9]+[a-c]*",
 50		"catar":        "Vai te Catar [0-9]+",
 51		"cloud":        "Nerd na Cloud [0-9]+",
 52		"contar":       "Vou (T|t)e Contar [0-9]+",
 53		"parceiro":     "Papo de Parceiro [0-9]+",
 54	}
 55
 56	feedRequest = promauto.NewHistogramVec(prometheus.HistogramOpts{
 57		Name:    "feed_request",
 58		Help:    "How long jovemnerd takes to answer",
 59		Buckets: []float64{.01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
 60	}, []string{"status_code"})
 61
 62	httpRequest = promauto.NewHistogramVec(prometheus.HistogramOpts{
 63		Name:    "http_request",
 64		Help:    "How long the application takes to complete the request",
 65		Buckets: []float64{.01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
 66	}, []string{"status_code", "user_agent"})
 67
 68	seriesCount = promauto.NewCounterVec(prometheus.CounterOpts{
 69		Name: "serie_count",
 70		Help: "How often a serie is called",
 71	}, []string{"serie"})
 72)
 73
 74func getSeries(r *http.Request) []string {
 75	query := r.URL.Query().Get("q")
 76
 77	var series []string
 78
 79	for _, q := range strings.Split(query, ",") {
 80		if _, ok := regexCollection[q]; ok {
 81			series = append(series, q)
 82		}
 83	}
 84
 85	if len(series) > 0 {
 86		return series
 87	}
 88
 89	return []string{"nerdcast"}
 90}
 91
 92func match(title string, series []string) bool {
 93	for _, s := range series {
 94		if ok, err := regexp.MatchString(regexCollection[s], title); err == nil && ok {
 95			return true
 96		}
 97	}
 98
 99	return false
100}
101
102func fetchXML(_ context.Context) ([]byte, error) {
103	t := time.Now()
104	c := http.StatusInternalServerError
105
106	defer func() {
107		since := time.Since(t).Seconds()
108		code := strconv.Itoa(c)
109		feedRequest.WithLabelValues(code).Observe(since)
110	}()
111
112	res, err := http.Get(feedUrl)
113	if err != nil {
114		return nil, err
115	}
116	defer res.Body.Close()
117
118	c = res.StatusCode
119
120	if c == http.StatusOK {
121		return io.ReadAll(res.Body)
122	}
123
124	return nil, errors.New("Invalid http code")
125}
126
127func appendTag(tag *etree.Element, ap string) {
128	text := tag.Text()
129	tag.SetText(text + ap)
130}
131
132func filterBySeries(series []string, xml []byte, temper bool) ([]byte, error) {
133	doc := etree.NewDocument()
134	err := doc.ReadFromBytes(xml)
135	if err != nil {
136		return nil, err
137	}
138
139	channel := doc.FindElement("//channel")
140
141	if temper {
142		tmp := strings.ToUpper(strings.Join(series, ","))
143		tmp = fmt.Sprintf(" [%s]", tmp)
144		appendTag(channel.FindElement("title"), tmp)
145		appendTag(channel.FindElement("description"), tmp)
146		appendTag(channel.FindElement("link"), "?"+tmp)
147		appendTag(channel.FindElement("author[namespace-prefix()='itunes']"), tmp)
148		appendTag(channel.FindElement("subtitle[namespace-prefix()='itunes']"), tmp)
149		appendTag(channel.FindElement("summary[namespace-prefix()='itunes']"), tmp)
150		appendTag(channel.FindElement("author[namespace-prefix()='googleplay']"), tmp)
151
152	}
153
154	for _, tag := range channel.FindElements("item") {
155		title := tag.FindElement("title").Text()
156		if !match(title, series) {
157			channel.RemoveChild(tag)
158		}
159	}
160
161	return doc.WriteToBytes()
162}
163
164func handleError(next errorRequestHandler) http.HandlerFunc {
165	return func(w http.ResponseWriter, r *http.Request) {
166		if err := next(w, r); err != nil {
167			slog.ErrorContext(r.Context(), "Error", "error", err.Error())
168
169			if errors.Is(err, errNotFound) {
170				w.WriteHeader(http.StatusNotFound)
171			} else {
172				w.WriteHeader(http.StatusInternalServerError)
173			}
174		}
175	}
176}
177
178func observe(next http.HandlerFunc) http.HandlerFunc {
179	return func(w http.ResponseWriter, r *http.Request) {
180		t := time.Now()
181
182		next(w, r)
183
184		rw := w.(*responseWriter)
185		since := time.Since(t).Seconds()
186		code := strconv.Itoa(rw.Status())
187		userAgent := r.Header.Get("user-agent")
188		httpRequest.WithLabelValues(code, userAgent).Observe(float64(since))
189
190		for _, s := range getSeries(r) {
191			seriesCount.WithLabelValues(s).Inc()
192		}
193	}
194}
195
196func wrap(next http.HandlerFunc) http.HandlerFunc {
197	return func(w http.ResponseWriter, r *http.Request) {
198		next(NewResponseWriter(w), r)
199	}
200}
201
202func titles(w http.ResponseWriter, r *http.Request) error {
203	xml, err := fetchXML(r.Context())
204	if err != nil {
205		return err
206	}
207
208	doc := etree.NewDocument()
209	err = doc.ReadFromBytes(xml)
210	if err != nil {
211		return err
212	}
213
214	series := getSeries(r)
215
216	els := doc.FindElements("//channel/item")
217	for _, e := range els {
218		txt := e.FindElement("title").Text() + "\n"
219		if match(txt, series) {
220			_, err = w.Write([]byte(txt))
221			if err != nil {
222				return err
223			}
224		}
225	}
226
227	return nil
228}
229
230func view(w http.ResponseWriter, r *http.Request) error {
231	data, err := assets.ReadFile("static/index.html")
232	if err != nil {
233		return err
234	}
235
236	_, err = w.Write(data)
237	if err != nil {
238		return err
239	}
240
241	return nil
242}
243
244func podcast(w http.ResponseWriter, r *http.Request) error {
245
246	if r.URL.Path != "/" {
247		return errNotFound
248
249	}
250
251	xml, err := fetchXML(r.Context())
252	if err != nil {
253		return err
254	}
255
256	series := getSeries(r)
257	temper := r.URL.Query().Get("tag") == "true"
258	filterdXML, err := filterBySeries(series, xml, temper)
259	if err != nil {
260		return err
261	}
262
263	_, err = w.Write(filterdXML)
264	if err != nil {
265		return err
266	}
267
268	return nil
269}
270
271func genSeries() error {
272	xml, err := fetchXML(context.Background())
273	if err != nil {
274		return err
275	}
276
277	doc := etree.NewDocument()
278	err = doc.ReadFromBytes(xml)
279	if err != nil {
280		return err
281	}
282
283	unique := make(map[string]any)
284	els := doc.FindElements("//channel/item")
285	for _, e := range els {
286		txt := e.FindElement("title").Text()
287		res := serieRegex.FindStringSubmatch(txt)
288		if len(res) > 1 {
289			unique[res[1]] = nil
290		}
291	}
292
293	for k := range unique {
294		fmt.Println(k)
295	}
296
297	return nil
298}
299
300func main() {
301	if len(os.Args) > 1 && os.Args[1] == "series" {
302		err := genSeries()
303		if err != nil {
304			panic(err.Error())
305		}
306		return
307	}
308
309	var (
310		addr = flag.String("addr", ":8080", "Server address")
311	)
312
313	flag.Parse()
314
315	mux := http.NewServeMux()
316	mux.Handle("/metrics", promhttp.Handler())
317	mux.HandleFunc("/titles", wrap(handleError(titles)))
318	mux.HandleFunc("/view", wrap(handleError(view)))
319	mux.HandleFunc("/", wrap(observe(handleError(podcast))))
320
321	server := http.Server{
322		Handler: mux,
323		Addr:    *addr,
324	}
325
326	err := server.ListenAndServe()
327	if err != nil {
328		fmt.Printf("Server error: %s", err.Error())
329	}
330}