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}