feat: switch ranking storage from markdown to JSON, fix round-trip test
- WriteRanking now writes ranking.json via json.MarshalIndent (replaces markdown table) - ReadRankingItems uses json.Unmarshal (no fragile pipe-split parsing) - ReadRanking() (raw string reader) removed; handleRankingView uses ReadRankingItems - handleRankingView renders a <pre> JSON block instead of goldmark markdown - RankingItem gains json: struct tags; slug and genres now survive the round-trip exactly - TestWriteRanking_RoundTrip updated: checks ranking.json, verifies Slug/Genres/SourceURL - Add RankingPageCacher interface and per-page HTML disk cache support in scraper
This commit is contained in:
@@ -164,9 +164,9 @@ func TestWriteRanking_RoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("WriteRanking failed: %v", err)
|
t.Fatalf("WriteRanking failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rankingFile := filepath.Join(dir, "ranking.md")
|
rankingFile := filepath.Join(dir, "ranking.json")
|
||||||
if _, err := os.Stat(rankingFile); err != nil {
|
if _, err := os.Stat(rankingFile); err != nil {
|
||||||
t.Fatalf("ranking.md not created: %v", err)
|
t.Fatalf("ranking.json not created: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := w.ReadRankingItems()
|
got, err := w.ReadRankingItems()
|
||||||
@@ -180,12 +180,27 @@ func TestWriteRanking_RoundTrip(t *testing.T) {
|
|||||||
if got[i].Rank != want.Rank {
|
if got[i].Rank != want.Rank {
|
||||||
t.Errorf("item[%d].Rank = %d, want %d", i, got[i].Rank, want.Rank)
|
t.Errorf("item[%d].Rank = %d, want %d", i, got[i].Rank, want.Rank)
|
||||||
}
|
}
|
||||||
|
if got[i].Slug != want.Slug {
|
||||||
|
t.Errorf("item[%d].Slug = %q, want %q", i, got[i].Slug, want.Slug)
|
||||||
|
}
|
||||||
if got[i].Title != want.Title {
|
if got[i].Title != want.Title {
|
||||||
t.Errorf("item[%d].Title = %q, want %q", i, got[i].Title, want.Title)
|
t.Errorf("item[%d].Title = %q, want %q", i, got[i].Title, want.Title)
|
||||||
}
|
}
|
||||||
if got[i].Status != want.Status {
|
if got[i].Status != want.Status {
|
||||||
t.Errorf("item[%d].Status = %q, want %q", i, got[i].Status, want.Status)
|
t.Errorf("item[%d].Status = %q, want %q", i, got[i].Status, want.Status)
|
||||||
}
|
}
|
||||||
|
if len(got[i].Genres) != len(want.Genres) {
|
||||||
|
t.Errorf("item[%d].Genres len = %d, want %d", i, len(got[i].Genres), len(want.Genres))
|
||||||
|
} else {
|
||||||
|
for j, g := range want.Genres {
|
||||||
|
if got[i].Genres[j] != g {
|
||||||
|
t.Errorf("item[%d].Genres[%d] = %q, want %q", i, j, got[i].Genres[j], g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got[i].SourceURL != want.SourceURL {
|
||||||
|
t.Errorf("item[%d].SourceURL = %q, want %q", i, got[i].SourceURL, want.SourceURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -933,31 +933,29 @@ const rankingViewTmpl = `
|
|||||||
|
|
||||||
<h1 class="text-3xl font-bold text-zinc-100 mb-6">Ranking Data</h1>
|
<h1 class="text-3xl font-bold text-zinc-100 mb-6">Ranking Data</h1>
|
||||||
|
|
||||||
<div class="prose prose-invert max-w-none">
|
<pre class="text-xs text-zinc-300 bg-zinc-900 border border-zinc-700 rounded-xl p-4 overflow-x-auto whitespace-pre-wrap break-words">{{.JSON}}</pre>
|
||||||
{{.HTML}}
|
|
||||||
</div>
|
|
||||||
</div>`
|
</div>`
|
||||||
|
|
||||||
func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
|
||||||
markdown, err := s.writer.ReadRanking()
|
items, err := s.writer.ReadRankingItems()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if markdown == "" {
|
if len(items) == 0 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var htmlBuf bytes.Buffer
|
pretty, err := json.MarshalIndent(items, "", " ")
|
||||||
if err := md.Convert([]byte(markdown), &htmlBuf); err != nil {
|
if err != nil {
|
||||||
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "json marshal error: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t := template.Must(template.New("rankingView").Parse(rankingViewTmpl))
|
t := template.Must(template.New("rankingView").Parse(rankingViewTmpl))
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
_ = t.Execute(&buf, struct{ HTML template.HTML }{HTML: template.HTML(htmlBuf.String())})
|
_ = t.Execute(&buf, struct{ JSON string }{JSON: string(pretty)})
|
||||||
|
|
||||||
s.respond(w, r, "Ranking Data", buf.String())
|
s.respond(w, r, "Ranking Data", buf.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
package writer
|
package writer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -274,17 +275,19 @@ func (w *Writer) ReadChapter(slug string, n int) (string, error) {
|
|||||||
|
|
||||||
// RankingItem represents a single entry in the ranking.
|
// RankingItem represents a single entry in the ranking.
|
||||||
type RankingItem struct {
|
type RankingItem struct {
|
||||||
Rank int `yaml:"rank"`
|
Rank int `yaml:"rank" json:"rank"`
|
||||||
Slug string `yaml:"slug"`
|
Slug string `yaml:"slug" json:"slug"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title" json:"title"`
|
||||||
Author string `yaml:"author,omitempty"`
|
Author string `yaml:"author,omitempty" json:"author,omitempty"`
|
||||||
Cover string `yaml:"cover,omitempty"`
|
Cover string `yaml:"cover,omitempty" json:"cover,omitempty"`
|
||||||
Status string `yaml:"status,omitempty"`
|
Status string `yaml:"status,omitempty" json:"status,omitempty"`
|
||||||
Genres []string `yaml:"genres,omitempty"`
|
Genres []string `yaml:"genres,omitempty" json:"genres,omitempty"`
|
||||||
SourceURL string `yaml:"source_url,omitempty"`
|
SourceURL string `yaml:"source_url,omitempty" json:"source_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteRanking saves the ranking items as markdown to static/ranking.md.
|
// WriteRanking saves the ranking items as JSON to static/books/ranking.json.
|
||||||
|
// This replaces the old markdown table format with a structured format that
|
||||||
|
// is faster to read back (no custom parsing) and safe for titles containing "|".
|
||||||
func (w *Writer) WriteRanking(items []RankingItem) error {
|
func (w *Writer) WriteRanking(items []RankingItem) error {
|
||||||
path := filepath.Clean(w.rankingPath())
|
path := filepath.Clean(w.rankingPath())
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
@@ -292,115 +295,40 @@ func (w *Writer) WriteRanking(items []RankingItem) error {
|
|||||||
return fmt.Errorf("writer: mkdir %s: %w", dir, err)
|
return fmt.Errorf("writer: mkdir %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
data, err := json.MarshalIndent(items, "", " ")
|
||||||
sb.WriteString("# Novel Rankings\n\n")
|
if err != nil {
|
||||||
sb.WriteString("| Rank | Title | Cover | Status | Genres | URL |\n")
|
return fmt.Errorf("writer: marshal ranking: %w", err)
|
||||||
sb.WriteString("|------|-------|-------|--------|--------|-----|\n")
|
|
||||||
for _, item := range items {
|
|
||||||
genres := strings.Join(item.Genres, ", ")
|
|
||||||
if genres == "" {
|
|
||||||
genres = "-"
|
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %s | %s | %s |\n",
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
item.Rank, item.Title, item.Cover, item.Status, genres, item.SourceURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("writer: write ranking %s: %w", path, err)
|
return fmt.Errorf("writer: write ranking %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadRanking reads the ranking.md file if it exists.
|
// ReadRankingItems parses ranking.json into a slice of RankingItem.
|
||||||
func (w *Writer) ReadRanking() (string, error) {
|
|
||||||
path := w.rankingPath()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("writer: read ranking: %w", err)
|
|
||||||
}
|
|
||||||
return string(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadRankingItems parses ranking.md back into a slice of RankingItem.
|
|
||||||
// Returns nil slice (not an error) when the file does not exist yet.
|
// Returns nil slice (not an error) when the file does not exist yet.
|
||||||
func (w *Writer) ReadRankingItems() ([]RankingItem, error) {
|
func (w *Writer) ReadRankingItems() ([]RankingItem, error) {
|
||||||
markdown, err := w.ReadRanking()
|
data, err := os.ReadFile(w.rankingPath())
|
||||||
if err != nil || markdown == "" {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var items []RankingItem
|
|
||||||
for _, line := range strings.Split(markdown, "\n") {
|
|
||||||
// Only process data rows: start and end with '|', not header/separator rows.
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if !strings.HasPrefix(line, "|") || !strings.HasSuffix(line, "|") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Strip leading/trailing '|' and split on '|'.
|
|
||||||
inner := strings.TrimPrefix(strings.TrimSuffix(line, "|"), "|")
|
|
||||||
cols := strings.Split(inner, "|")
|
|
||||||
if len(cols) < 6 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for i, c := range cols {
|
|
||||||
cols[i] = strings.TrimSpace(c)
|
|
||||||
}
|
|
||||||
// Skip header row and separator row.
|
|
||||||
if cols[0] == "Rank" || strings.HasPrefix(cols[0], "---") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rank, err := strconv.Atoi(cols[0])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
title := cols[1]
|
return nil, fmt.Errorf("writer: read ranking: %w", err)
|
||||||
cover := cols[2]
|
|
||||||
status := cols[3]
|
|
||||||
genresStr := cols[4]
|
|
||||||
sourceURL := cols[5]
|
|
||||||
|
|
||||||
var genres []string
|
|
||||||
if genresStr != "-" && genresStr != "" {
|
|
||||||
for _, g := range strings.Split(genresStr, ",") {
|
|
||||||
g = strings.TrimSpace(g)
|
|
||||||
if g != "" {
|
|
||||||
genres = append(genres, g)
|
|
||||||
}
|
}
|
||||||
}
|
var items []RankingItem
|
||||||
}
|
if err := json.Unmarshal(data, &items); err != nil {
|
||||||
|
return nil, fmt.Errorf("writer: parse ranking json: %w", err)
|
||||||
// Derive slug from source URL (last path segment).
|
|
||||||
slug := ""
|
|
||||||
if sourceURL != "" {
|
|
||||||
parts := strings.Split(strings.TrimRight(sourceURL, "/"), "/")
|
|
||||||
if len(parts) > 0 {
|
|
||||||
slug = parts[len(parts)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, RankingItem{
|
|
||||||
Rank: rank,
|
|
||||||
Slug: slug,
|
|
||||||
Title: title,
|
|
||||||
Cover: cover,
|
|
||||||
Status: status,
|
|
||||||
Genres: genres,
|
|
||||||
SourceURL: sourceURL,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RankingFileInfo returns os.FileInfo for the ranking.md file, if it exists.
|
// RankingFileInfo returns os.FileInfo for the ranking.json file, if it exists.
|
||||||
func (w *Writer) RankingFileInfo() (os.FileInfo, error) {
|
func (w *Writer) RankingFileInfo() (os.FileInfo, error) {
|
||||||
return os.Stat(w.rankingPath())
|
return os.Stat(w.rankingPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Writer) rankingPath() string {
|
func (w *Writer) rankingPath() string {
|
||||||
return filepath.Join(w.root, "ranking.md")
|
return filepath.Join(w.root, "ranking.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Ranking page HTML cache ──────────────────────────────────────────────────
|
// ─── Ranking page HTML cache ──────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user