Files
tiennm99 ad8d2649c1 refactor!: generalise single-novel script into a hako library
The crawler took one hardcoded novel URL, wrote per-chapter text files
into ./data, and fetched serially until the site answered 429.

Rename the module to hako-crawler and split fetching, page parsing and
crawl orchestration into a hako package that takes any novel URL.

The chapter body is no longer served as markup: #chapter-content now
carries a shuffled, XOR-obfuscated payload that the site's own script
expands in the browser, so the previous p[id=<digits>] extractor
returned nothing at all. Decode it, keeping the plain-markup path as a
fallback. The scheme is read from the page and an unrecognised one is a
hard error, because the alternative is exporting a book of mojibake.

Space requests globally instead of per-goroutine, so concurrency no
longer raises the request rate, and retry only what a retry can fix.
Cache raw pages so re-runs cost no requests.

BREAKING CHANGE: module path is now github.com/tiennm99/hako-crawler,
and the novel URL is a required argument rather than a constant.
2026-08-24 20:39:50 +07:00

117 lines
4.0 KiB
Go

package hako
import (
"strings"
"testing"
)
// chapterPage wraps a body in the page furniture the parser has to see past:
// the container, and the hidden copy of the chapter title the site plants
// beside it.
func chapterPage(body string) string {
return `<html><body><div id="chapter-content" class="long-text text-justify">
<p style="display: none">Chương 01</p>` + body + `</div></body></html>`
}
// protectedBody renders the encoded container the site now serves.
func protectedBody(t *testing.T, html, key string) string {
t.Helper()
chunks := encodeXORShuffle(t, html, key, 24)
quoted := make([]string, len(chunks))
for i, c := range chunks {
// The attribute is HTML-escaped on the page; the parser decodes it.
quoted[i] = "&quot;" + c + "&quot;"
}
return `<div id="` + protectedID + `" data-s="` + schemeXORShuffle +
`" data-k="` + key + `" data-c="[` + strings.Join(quoted, ",") + `]" aria-hidden="true"></div>`
}
var testRef = ChapterRef{Label: "Chương 01", Volume: "Tập 01", URL: "https://ln.hako.vn/sang-tac/1-x/c1-chuong-01"}
func TestParseChapterProtected(t *testing.T) {
body := `<p id="1">Chương 01</p>` +
`<p id="2">Trời hôm nay đẹp lắm.</p>` +
`<p id="3">Người anh trai <em>mỉm cười</em>.[note1]</p>` +
`<p id="4"> </p>`
ch, err := ParseChapter([]byte(chapterPage(protectedBody(t, body, testKey))), testRef)
if err != nil {
t.Fatalf("parse: %v", err)
}
// The leading paragraph only repeats the label, the blank one carries no
// text, and the footnote marker is page furniture rather than prose.
want := []string{"Trời hôm nay đẹp lắm.", "Người anh trai mỉm cười."}
if len(ch.Paragraphs) != len(want) {
t.Fatalf("paragraphs = %q, want %q", ch.Paragraphs, want)
}
for i, w := range want {
if ch.Paragraphs[i] != w {
t.Errorf("paragraph %d = %q, want %q", i, ch.Paragraphs[i], w)
}
}
if ch.Volume != testRef.Volume {
t.Errorf("volume = %q, want %q", ch.Volume, testRef.Volume)
}
if ch.WordCount() != 10 {
t.Errorf("word count = %d, want 10", ch.WordCount())
}
}
// A chapter still served as plain markup must keep working, and the hidden
// title beside the body must not become a paragraph.
func TestParseChapterPlainMarkup(t *testing.T) {
ch, err := ParseChapter([]byte(chapterPage(`<p id="1">Một buổi sáng yên tĩnh.</p>`)), testRef)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(ch.Paragraphs) != 1 || ch.Paragraphs[0] != "Một buổi sáng yên tĩnh." {
t.Fatalf("paragraphs = %q", ch.Paragraphs)
}
}
// Illustration chapters are real chapters that carry pictures and little or no
// text. They must not fail the run.
func TestParseChapterWithoutProse(t *testing.T) {
ch, err := ParseChapter([]byte(chapterPage(protectedBody(t, `<p id="1"><img src="/a.png"></p>`, testKey))),
ChapterRef{Label: "Minh Họa 01", URL: testRef.URL})
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(ch.Paragraphs) != 0 {
t.Errorf("paragraphs = %q, want none", ch.Paragraphs)
}
}
func TestParseChapterMissingContainer(t *testing.T) {
if _, err := ParseChapter([]byte(`<html><body><p>nope</p></body></html>`), testRef); err == nil {
t.Fatal("expected an error when the content container is absent, got none")
}
}
// A rotated scheme must surface as an error naming the chapter, not as an empty
// or garbled one.
func TestParseChapterUnknownScheme(t *testing.T) {
page := chapterPage(`<div id="` + protectedID + `" data-s="aes_gcm_2027" data-k="k" data-c="[&quot;0000AAAA&quot;]"></div>`)
_, err := ParseChapter([]byte(page), testRef)
if err == nil {
t.Fatal("expected an error for an unknown scheme, got none")
}
if !strings.Contains(err.Error(), testRef.URL) {
t.Errorf("error should name the chapter URL, got: %v", err)
}
}
func TestHeading(t *testing.T) {
for _, tc := range []struct{ label, want string }{
{"Chương 01", "Chương 01"},
{"12", "Chương 12"},
{"", "Chương"},
{"Minh Họa 01", "Minh Họa 01"},
} {
if got := (&Chapter{Label: tc.label}).Heading(); got != tc.want {
t.Errorf("Heading(%q) = %q, want %q", tc.label, got, tc.want)
}
}
}