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 filterdXML, err := filterBySeries(series, xml, true)
258 if err != nil {
259 return err
260 }
261
262 _, err = w.Write(filterdXML)
263 if err != nil {
264 return err
265 }
266
267 return nil
268}
269
270func genSeries() error {
271 xml, err := fetchXML(context.Background())
272 if err != nil {
273 return err
274 }
275
276 doc := etree.NewDocument()
277 err = doc.ReadFromBytes(xml)
278 if err != nil {
279 return err
280 }
281
282 unique := make(map[string]any)
283 els := doc.FindElements("//channel/item")
284 for _, e := range els {
285 txt := e.FindElement("title").Text()
286 res := serieRegex.FindStringSubmatch(txt)
287 if len(res) > 1 {
288 unique[res[1]] = nil
289 }
290 }
291
292 for k := range unique {
293 fmt.Println(k)
294 }
295
296 return nil
297}
298
299func main() {
300 if len(os.Args) > 1 && os.Args[1] == "series" {
301 err := genSeries()
302 if err != nil {
303 panic(err.Error())
304 }
305 return
306 }
307
308 var (
309 addr = flag.String("addr", ":8080", "Server address")
310 )
311
312 flag.Parse()
313
314 mux := http.NewServeMux()
315 mux.Handle("/metrics", promhttp.Handler())
316 mux.HandleFunc("/titles", wrap(handleError(titles)))
317 mux.HandleFunc("/view", wrap(handleError(view)))
318 mux.HandleFunc("/", wrap(observe(handleError(podcast))))
319
320 server := http.Server{
321 Handler: mux,
322 Addr: *addr,
323 }
324
325 err := server.ListenAndServe()
326 if err != nil {
327 fmt.Printf("Server error: %s", err.Error())
328 }
329}