Skip to content

Commit f32fd3f

Browse files
committed
implement CP-Algorithms article browser and cli commands
Fetches the navigation page at cp-algorithms.com, parses 163 articles with title and category, and exposes two commands: cpa list [--limit N] - list all articles cpa search <query> [--limit N] - filter by title or category Deduplicates the MkDocs double-render (sidebar + body). Outputs table, JSON, JSONL, CSV, TSV, URL, or raw. External test package covers parser, limit, search, case-insensitivity, User-Agent, and 503 retries using httptest; live test gated behind -short.
1 parent 3d0bcdb commit f32fd3f

9 files changed

Lines changed: 1048 additions & 62 deletions

File tree

cli/cmd_list.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package cli
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
func (a *App) listCmd() *cobra.Command {
8+
return &cobra.Command{
9+
Use: "list",
10+
Short: "List all CP-Algorithms articles",
11+
Long: `List every article from the CP-Algorithms navigation page.
12+
13+
Each record includes the article's rank, title, category, full URL, and
14+
site-relative path. Use --output to change the format.`,
15+
RunE: func(cmd *cobra.Command, _ []string) error {
16+
n := a.effectiveLimit(0)
17+
a.progressf("fetching article list...")
18+
articles, err := a.client.List(cmd.Context(), n)
19+
if err != nil {
20+
return mapFetchErr(err)
21+
}
22+
return a.renderOrEmpty(articles, len(articles))
23+
},
24+
}
25+
}

cli/cmd_search.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package cli
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
func (a *App) searchCmd() *cobra.Command {
8+
return &cobra.Command{
9+
Use: "search <query>",
10+
Short: "Search CP-Algorithms articles by title or category",
11+
Long: `Search CP-Algorithms articles whose title or category contains <query>
12+
(case-insensitive substring match).
13+
14+
Examples:
15+
cpa search sorting
16+
cpa search "shortest path"
17+
cpa search graph --output json`,
18+
Args: cobra.ExactArgs(1),
19+
RunE: func(cmd *cobra.Command, args []string) error {
20+
n := a.effectiveLimit(0)
21+
q := args[0]
22+
a.progressf("searching for %q...", q)
23+
hits, err := a.client.Search(cmd.Context(), q, n)
24+
if err != nil {
25+
return mapFetchErr(err)
26+
}
27+
return a.renderOrEmpty(hits, len(hits))
28+
},
29+
}
30+
}

cli/errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package cli
2+
3+
func isNotFound(_ error) bool {
4+
// cp-algorithms.com is a static site; 404s surface as http errors, not
5+
// typed sentinel values. Reserve this hook for future typed errors.
6+
return false
7+
}

cli/output.go

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
package cli
2+
3+
import (
4+
"encoding/csv"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"reflect"
9+
"strconv"
10+
"strings"
11+
"text/tabwriter"
12+
"text/template"
13+
"time"
14+
)
15+
16+
// Format is an output rendering format.
17+
type Format string
18+
19+
const (
20+
FormatTable Format = "table"
21+
FormatJSON Format = "json"
22+
FormatJSONL Format = "jsonl"
23+
FormatCSV Format = "csv"
24+
FormatTSV Format = "tsv"
25+
FormatURL Format = "url"
26+
FormatRaw Format = "raw"
27+
)
28+
29+
// Valid reports whether f is a supported format.
30+
func (f Format) Valid() bool {
31+
switch f {
32+
case FormatTable, FormatJSON, FormatJSONL, FormatCSV, FormatTSV, FormatURL, FormatRaw:
33+
return true
34+
}
35+
return false
36+
}
37+
38+
// Renderer turns slices of records into one of the supported output formats.
39+
type Renderer struct {
40+
Format Format
41+
Fields []string
42+
NoHeader bool
43+
Template string
44+
w io.Writer
45+
}
46+
47+
// NewRenderer builds a Renderer writing to w.
48+
func NewRenderer(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *Renderer {
49+
return &Renderer{Format: format, Fields: fields, NoHeader: noHeader, Template: tmpl, w: w}
50+
}
51+
52+
// Render writes records in the configured format.
53+
func (r *Renderer) Render(records any) error {
54+
rv := reflect.ValueOf(records)
55+
if rv.Kind() == reflect.Pointer {
56+
rv = rv.Elem()
57+
}
58+
if rv.Kind() != reflect.Slice {
59+
s := reflect.MakeSlice(reflect.SliceOf(rv.Type()), 1, 1)
60+
s.Index(0).Set(rv)
61+
rv = s
62+
}
63+
n := rv.Len()
64+
items := make([]any, n)
65+
for i := 0; i < n; i++ {
66+
items[i] = rv.Index(i).Interface()
67+
}
68+
if r.Template != "" {
69+
return r.renderTemplate(items)
70+
}
71+
switch r.Format {
72+
case FormatJSON:
73+
return r.renderJSON(items)
74+
case FormatJSONL:
75+
return r.renderJSONL(items)
76+
case FormatCSV:
77+
return r.renderDelimited(items, ',')
78+
case FormatTSV:
79+
return r.renderDelimited(items, '\t')
80+
case FormatURL:
81+
return r.renderURL(items)
82+
case FormatRaw:
83+
return r.renderRaw(items)
84+
default:
85+
return r.renderTable(items)
86+
}
87+
}
88+
89+
func (r *Renderer) renderJSON(items []any) error {
90+
enc := json.NewEncoder(r.w)
91+
enc.SetIndent("", " ")
92+
if len(items) == 1 {
93+
return enc.Encode(items[0])
94+
}
95+
return enc.Encode(items)
96+
}
97+
98+
func (r *Renderer) renderJSONL(items []any) error {
99+
enc := json.NewEncoder(r.w)
100+
for _, it := range items {
101+
if err := enc.Encode(it); err != nil {
102+
return err
103+
}
104+
}
105+
return nil
106+
}
107+
108+
func (r *Renderer) renderTemplate(items []any) error {
109+
t, err := template.New("row").Funcs(template.FuncMap{
110+
"join": func(sep string, ss []string) string { return strings.Join(ss, sep) },
111+
}).Parse(r.Template)
112+
if err != nil {
113+
return fmt.Errorf("parse --template: %w", err)
114+
}
115+
for _, it := range items {
116+
if err := t.Execute(r.w, it); err != nil {
117+
return err
118+
}
119+
_, _ = fmt.Fprintln(r.w)
120+
}
121+
return nil
122+
}
123+
124+
func (r *Renderer) renderURL(items []any) error {
125+
for _, it := range items {
126+
m := toMap(it)
127+
if u, ok := m["url"]; ok && u != "" {
128+
_, _ = fmt.Fprintln(r.w, u)
129+
}
130+
}
131+
return nil
132+
}
133+
134+
func (r *Renderer) renderRaw(items []any) error {
135+
cols := r.columns(items)
136+
for _, it := range items {
137+
m := toMap(it)
138+
vals := make([]string, 0, len(cols))
139+
for _, c := range cols {
140+
vals = append(vals, m[c])
141+
}
142+
_, _ = fmt.Fprintln(r.w, strings.Join(vals, " "))
143+
}
144+
return nil
145+
}
146+
147+
func (r *Renderer) renderTable(items []any) error {
148+
if len(items) == 0 {
149+
return nil
150+
}
151+
cols := r.columns(items)
152+
tw := tabwriter.NewWriter(r.w, 0, 4, 2, ' ', 0)
153+
if !r.NoHeader {
154+
_, _ = fmt.Fprintln(tw, strings.Join(upperAll(cols), "\t"))
155+
}
156+
for _, it := range items {
157+
m := toMap(it)
158+
cells := make([]string, len(cols))
159+
for i, c := range cols {
160+
cells[i] = truncate(m[c], 60)
161+
}
162+
_, _ = fmt.Fprintln(tw, strings.Join(cells, "\t"))
163+
}
164+
return tw.Flush()
165+
}
166+
167+
func (r *Renderer) renderDelimited(items []any, comma rune) error {
168+
if len(items) == 0 {
169+
return nil
170+
}
171+
cols := r.columns(items)
172+
cw := csv.NewWriter(r.w)
173+
cw.Comma = comma
174+
if !r.NoHeader {
175+
if err := cw.Write(cols); err != nil {
176+
return err
177+
}
178+
}
179+
for _, it := range items {
180+
m := toMap(it)
181+
row := make([]string, len(cols))
182+
for i, c := range cols {
183+
row[i] = m[c]
184+
}
185+
if err := cw.Write(row); err != nil {
186+
return err
187+
}
188+
}
189+
cw.Flush()
190+
return cw.Error()
191+
}
192+
193+
func (r *Renderer) columns(items []any) []string {
194+
if len(r.Fields) > 0 {
195+
return r.Fields
196+
}
197+
if len(items) == 0 {
198+
return nil
199+
}
200+
return structJSONKeys(items[0])
201+
}
202+
203+
func toMap(v any) map[string]string {
204+
out := map[string]string{}
205+
rv := reflect.ValueOf(v)
206+
if rv.Kind() == reflect.Pointer {
207+
rv = rv.Elem()
208+
}
209+
if rv.Kind() != reflect.Struct {
210+
return out
211+
}
212+
rt := rv.Type()
213+
for i := 0; i < rt.NumField(); i++ {
214+
f := rt.Field(i)
215+
if f.PkgPath != "" {
216+
continue
217+
}
218+
key := jsonKey(f)
219+
if key == "-" {
220+
continue
221+
}
222+
out[key] = formatValue(rv.Field(i))
223+
}
224+
return out
225+
}
226+
227+
func structJSONKeys(v any) []string {
228+
rv := reflect.ValueOf(v)
229+
if rv.Kind() == reflect.Pointer {
230+
rv = rv.Elem()
231+
}
232+
if rv.Kind() != reflect.Struct {
233+
return nil
234+
}
235+
rt := rv.Type()
236+
var keys []string
237+
for i := 0; i < rt.NumField(); i++ {
238+
f := rt.Field(i)
239+
if f.PkgPath != "" {
240+
continue
241+
}
242+
key := jsonKey(f)
243+
if key == "-" {
244+
continue
245+
}
246+
keys = append(keys, key)
247+
}
248+
return keys
249+
}
250+
251+
func jsonKey(f reflect.StructField) string {
252+
tag := f.Tag.Get("json")
253+
if tag == "" {
254+
return f.Name
255+
}
256+
name := strings.Split(tag, ",")[0]
257+
if name == "" {
258+
return f.Name
259+
}
260+
return name
261+
}
262+
263+
func formatValue(v reflect.Value) string {
264+
switch v.Kind() {
265+
case reflect.String:
266+
return v.String()
267+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
268+
return strconv.FormatInt(v.Int(), 10)
269+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
270+
return strconv.FormatUint(v.Uint(), 10)
271+
case reflect.Float32, reflect.Float64:
272+
return strconv.FormatFloat(v.Float(), 'g', -1, 64)
273+
case reflect.Bool:
274+
return strconv.FormatBool(v.Bool())
275+
case reflect.Slice:
276+
parts := make([]string, v.Len())
277+
for i := 0; i < v.Len(); i++ {
278+
parts[i] = formatValue(v.Index(i))
279+
}
280+
return strings.Join(parts, ";")
281+
case reflect.Struct:
282+
if t, ok := v.Interface().(time.Time); ok {
283+
if t.IsZero() {
284+
return ""
285+
}
286+
return t.Format(time.RFC3339)
287+
}
288+
}
289+
return fmt.Sprintf("%v", v.Interface())
290+
}
291+
292+
func upperAll(ss []string) []string {
293+
out := make([]string, len(ss))
294+
for i, s := range ss {
295+
out[i] = strings.ToUpper(s)
296+
}
297+
return out
298+
}
299+
300+
func truncate(s string, n int) string {
301+
s = strings.ReplaceAll(s, "\n", " ")
302+
if len([]rune(s)) <= n {
303+
return s
304+
}
305+
rs := []rune(s)
306+
return string(rs[:n-1]) + "..."
307+
}

0 commit comments

Comments
 (0)