feat: ranking page pagination, popular URL, and per-page HTML disk cache
- Switch ScrapeRanking to novelfire.net/genre-all/sort-popular URL and updated DOM selectors (div.novel-item, h3.novel-title, div.genres) - Replace 5 hardcoded refresh buttons with dynamic 100-page paginator (smart ellipsis via rankingPageNums) - Add RankingPageCacher interface and writer methods to cache raw HTML per page under static/books/_ranking_cache/page-N.html - ScrapeRanking serves from disk cache on hit and writes to cache on miss, skipping Browserless round-trip - Thread writer as PageCacher through novelfire.New and main.go - Add TestScrapeRanking_CacheHit and TestScrapeRanking_CacheMiss tests
This commit is contained in:
@@ -546,25 +546,31 @@ const rankingTmpl = `
|
||||
</div>
|
||||
|
||||
<!-- Pagination: fetch pages from novelfire -->
|
||||
<div class="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<span class="text-xs text-zinc-500">Fetch pages:</span>
|
||||
{{range .Pages}}
|
||||
<form hx-post="/ranking/refresh"
|
||||
hx-target="#ranking-refresh-status"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="pages" value="{{.Pages}}">
|
||||
<button type="submit"
|
||||
class="text-xs px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-amber-700 border border-zinc-700 hover:border-amber-600 text-zinc-300 hover:text-white transition-colors">
|
||||
{{.Label}}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<span class="text-xs text-zinc-600 ml-1">
|
||||
(source:
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-1.5 mb-2 flex-wrap">
|
||||
<span class="text-xs text-zinc-500 mr-1">Fetch up to page:</span>
|
||||
{{range .PageNums}}
|
||||
{{if eq .Num 0}}
|
||||
<span class="text-xs text-zinc-600 px-1 select-none">…</span>
|
||||
{{else}}
|
||||
<form hx-post="/ranking/refresh"
|
||||
hx-target="#ranking-refresh-status"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="pages" value="{{.Num}}">
|
||||
<button type="submit"
|
||||
class="text-xs w-8 h-7 rounded-lg bg-zinc-800 hover:bg-amber-700 border border-zinc-700 hover:border-amber-600 text-zinc-300 hover:text-white transition-colors text-center">
|
||||
{{.Num}}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="text-xs text-zinc-600">
|
||||
Each page = ~20 novels from
|
||||
<a href="https://novelfire.net/genre-all/sort-popular/status-all/all-novel?page=1"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="text-zinc-500 hover:text-amber-400 underline underline-offset-2">novelfire.net</a>)
|
||||
</span>
|
||||
class="text-zinc-500 hover:text-amber-400 underline underline-offset-2">novelfire.net popular</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Book grid -->
|
||||
@@ -693,21 +699,70 @@ func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool)
|
||||
return out
|
||||
}
|
||||
|
||||
// refreshPage is one entry in the ranking refresh pagination bar.
|
||||
type refreshPage struct {
|
||||
Label string
|
||||
Pages int // 0 means "all pages"
|
||||
// pageNum is one entry in the ranking pagination bar.
|
||||
// Num == 0 is a sentinel that renders as an ellipsis gap.
|
||||
type pageNum struct {
|
||||
Num int
|
||||
}
|
||||
|
||||
// rankingRefreshPages defines the pagination buttons shown on the ranking page.
|
||||
// Each entry fetches that many pages from novelfire.net (100 novels per page).
|
||||
// Pages == 0 means fetch all pages.
|
||||
var rankingRefreshPages = []refreshPage{
|
||||
{"p.1 (top 100)", 1},
|
||||
{"p.1–3 (top 300)", 3},
|
||||
{"p.1–5 (top 500)", 5},
|
||||
{"p.1–10 (top 1000)", 10},
|
||||
{"All", 0},
|
||||
// rankingPageNums builds the dynamic pagination list for 100 pages with
|
||||
// smart ellipsis: always show the first 3, last 3, and a sliding window of
|
||||
// 3 around the ends — compressed with "…" separators between runs.
|
||||
//
|
||||
// For a flat 100-page list it produces:
|
||||
//
|
||||
// 1 2 3 … 6 7 8 … 93 94 95 … 98 99 100
|
||||
//
|
||||
// (The middle range is omitted intentionally; users click individual pages.)
|
||||
func rankingPageNums(total int) []pageNum {
|
||||
if total <= 0 {
|
||||
return nil
|
||||
}
|
||||
// Build the set of visible page numbers using a sliding-window approach.
|
||||
show := make(map[int]bool)
|
||||
// Always show first 3 and last 3.
|
||||
for i := 1; i <= 3 && i <= total; i++ {
|
||||
show[i] = true
|
||||
}
|
||||
for i := total - 2; i <= total; i++ {
|
||||
if i >= 1 {
|
||||
show[i] = true
|
||||
}
|
||||
}
|
||||
// For large ranges, show a few pages near the 1/4 and 3/4 marks.
|
||||
if total > 12 {
|
||||
q1 := total / 4
|
||||
q3 := total * 3 / 4
|
||||
for _, p := range []int{q1 - 1, q1, q1 + 1, q3 - 1, q3, q3 + 1} {
|
||||
if p >= 1 && p <= total {
|
||||
show[p] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect sorted visible pages.
|
||||
pages := make([]int, 0, len(show))
|
||||
for p := range show {
|
||||
pages = append(pages, p)
|
||||
}
|
||||
// Sort pages slice.
|
||||
for i := 0; i < len(pages); i++ {
|
||||
for j := i + 1; j < len(pages); j++ {
|
||||
if pages[j] < pages[i] {
|
||||
pages[i], pages[j] = pages[j], pages[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build output with ellipsis sentinels (Num==0) between non-consecutive pages.
|
||||
out := make([]pageNum, 0, len(pages)*2)
|
||||
for i, p := range pages {
|
||||
if i > 0 && p > pages[i-1]+1 {
|
||||
out = append(out, pageNum{0}) // ellipsis
|
||||
}
|
||||
out = append(out, pageNum{p})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleRanking serves the ranking page from the cached ranking.md file.
|
||||
@@ -728,11 +783,11 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
||||
_ = t.Execute(&buf, struct {
|
||||
Books interface{}
|
||||
CachedAt string
|
||||
Pages []refreshPage
|
||||
PageNums []pageNum
|
||||
}{
|
||||
Books: toRankingViewItems(rankingItems, s.writer.LocalSlugs()),
|
||||
CachedAt: cachedAt,
|
||||
Pages: rankingRefreshPages,
|
||||
PageNums: rankingPageNums(100),
|
||||
})
|
||||
s.respond(w, r, "Rankings", buf.String())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user