From 2f5eac2f50c8d07a0fa199297f9da3e787939e05 Mon Sep 17 00:00:00 2001 From: jbergner Date: Mon, 3 Nov 2025 15:53:08 +0100 Subject: [PATCH] init --- go.mod | 3 + main.go | 341 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 go.mod create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ddddc3b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/snscanner + +go 1.24.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..0bb003f --- /dev/null +++ b/main.go @@ -0,0 +1,341 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "errors" + "flag" + "html/template" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" +) + +type record map[string]string + +var ( + addr = flag.String("addr", ":8080", "HTTP listen address") + csvPath = flag.String("csv", "data.csv", "path to CSV with header row") + keyCol = flag.String("key", "", "column name to search in (default: first column)") + valueCol = flag.String("value", "", "column name to return (default: second column)") + + records []record + headers []string + mu sync.RWMutex +) + +func main() { + flag.Parse() + + if err := loadCSV(*csvPath); err != nil { + log.Fatalf("CSV laden fehlgeschlagen: %v", err) + } + log.Printf("CSV geladen: %s (%d Zeilen, %d Spalten)", *csvPath, len(records), len(headers)) + + http.HandleFunc("/", handleIndex) + http.HandleFunc("/search", handleSearch) + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir())))) + + log.Printf("Starte Server auf %s …", *addr) + if err := http.ListenAndServe(*addr, nil); err != nil { + log.Fatal(err) + } +} + +func staticDir() string { + dir := "static" + if _, err := os.Stat(dir); err == nil { + return dir + } + return "." +} + +func loadCSV(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + r := csv.NewReader(f) + r.FieldsPerRecord = -1 + + rows, err := r.ReadAll() + if err != nil { + return err + } + if len(rows) == 0 { + return errors.New("leere CSV") + } + + headers = rows[0] + idxKey := 0 + idxVal := 1 + if len(headers) < 2 { + return errors.New("mindestens zwei Spalten benötigt") + } + + if *keyCol != "" { + idxKey = indexOf(headers, *keyCol) + if idxKey == -1 { + return errUnknownCol("key", *keyCol, headers) + } + } + if *valueCol != "" { + idxVal = indexOf(headers, *valueCol) + if idxVal == -1 { + return errUnknownCol("value", *valueCol, headers) + } + } + + if *keyCol == "" { + *keyCol = headers[idxKey] + } + if *valueCol == "" { + *valueCol = headers[idxVal] + } + + var recs []record + for i := 1; i < len(rows); i++ { + row := rows[i] + if len(row) < len(headers) { + tmp := make([]string, len(headers)) + copy(tmp, row) + row = tmp + } + m := make(record, len(headers)) + for c, h := range headers { + if c < len(row) { + m[h] = row[c] + } else { + m[h] = "" + } + } + recs = append(recs, m) + } + + mu.Lock() + records = recs + mu.Unlock() + return nil +} + +func errUnknownCol(kind, name string, headers []string) error { + return errors.New(kind + " column '" + name + "' nicht gefunden. Verfügbare Spalten: " + strings.Join(headers, ", ")) +} + +func indexOf(ss []string, s string) int { + for i, v := range ss { + if strings.EqualFold(strings.TrimSpace(v), strings.TrimSpace(s)) { + return i + } + } + return -1 +} + +type searchResp struct { + Query string `json:"query"` + ExactHit *item `json:"exact_hit,omitempty"` + TopCandidate *item `json:"top_candidate,omitempty"` + Candidates []item `json:"candidates,omitempty"` + Message string `json:"message,omitempty"` + Headers []string `json:"headers,omitempty"` +} + +type item struct { + Key string `json:"key"` + Value string `json:"value"` + FullRow record `json:"full_row,omitempty"` +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + tpl := template.Must(template.New("index").Parse(indexHTML)) + data := struct { + File string + KeyCol string + ValCol string + }{ + File: filepath.Base(*csvPath), + KeyCol: *keyCol, + ValCol: *valueCol, + } + _ = tpl.Execute(w, data) +} + +func handleSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + if q == "" { + _ = json.NewEncoder(w).Encode(searchResp{Query: q, Message: "Gib einen Suchbegriff ein.", Headers: headers}) + return + } + + mu.RLock() + defer mu.RUnlock() + + lq := strings.ToLower(q) + var exact *item + var partial []item + + for _, rec := range records { + k := rec[*keyCol] + if strings.EqualFold(k, q) { + exact = &item{ + Key: k, + Value: rec[*valueCol], + FullRow: rec, + } + break + } + if strings.Contains(strings.ToLower(k), lq) { + partial = append(partial, item{ + Key: k, + Value: rec[*valueCol], + FullRow: rec, + }) + } + } + + resp := searchResp{Query: q, Headers: headers} + if exact != nil { + resp.ExactHit = exact + } else if len(partial) > 0 { + resp.TopCandidate = &partial[0] + if len(partial) > 1 { + limit := len(partial) + if limit > 10 { + limit = 10 + } + resp.Candidates = partial[:limit] + } + } else { + resp.Message = "Kein Treffer." + } + _ = json.NewEncoder(w).Encode(resp) +} + +const indexHTML = ` + + + + + + CSV-Suche + + + +
+

CSV-Suche

+

+ Datei: {{ .File }} • Suche in {{ .KeyCol }} → zeige {{ .ValCol }} +

+ + + +
+
+ + + + +`