-
+
- + Minh Họa 01 +01/01/2024
+
diff --git a/hako-crawler/.gitignore b/hako-crawler/.gitignore index 089a213..c0167aa 100644 --- a/hako-crawler/.gitignore +++ b/hako-crawler/.gitignore @@ -1,10 +1,12 @@ -.idea +# Build output +/hako-crawler +/hako-crawler.exe +# Cached pages and exported books +/.cache/ +/data/ +*.pdf - -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# # Binaries for programs and plugins *.exe *.exe~ @@ -21,9 +23,6 @@ coverage.* *.coverprofile profile.cov -# Dependency directories (remove the comment below to include it) -# vendor/ - # Go workspace file go.work go.work.sum @@ -32,5 +31,4 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea diff --git a/hako-crawler/data/.gitignore b/hako-crawler/data/.gitignore deleted file mode 100644 index c96a04f..0000000 --- a/hako-crawler/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/hako-crawler/go.mod b/hako-crawler/go.mod index 95bba89..af2d9a7 100644 --- a/hako-crawler/go.mod +++ b/hako-crawler/go.mod @@ -1,12 +1,14 @@ -module github.com/tiennm99/atnvc-crawler +module github.com/tiennm99/hako-crawler -go 1.24.0 +go 1.25.0 -toolchain go1.24.10 - -require github.com/PuerkitoBio/goquery v1.11.0 +require ( + github.com/PuerkitoBio/goquery v1.12.0 + github.com/go-pdf/fpdf v0.9.0 + golang.org/x/sync v0.22.0 +) require ( github.com/andybalholm/cascadia v1.3.3 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.52.0 // indirect ) diff --git a/hako-crawler/go.sum b/hako-crawler/go.sum index 806bd54..6042897 100644 --- a/hako-crawler/go.sum +++ b/hako-crawler/go.sum @@ -1,7 +1,9 @@ -github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= -github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -24,8 +26,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -33,6 +35,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/hako-crawler/hako/chapter.go b/hako-crawler/hako/chapter.go new file mode 100644 index 0000000..6846ad0 --- /dev/null +++ b/hako-crawler/hako/chapter.go @@ -0,0 +1,149 @@ +package hako + +import ( + "bytes" + "fmt" + "regexp" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +// contentID is the container holding a chapter's body. +const contentID = "chapter-content" + +// Chapter is one fetched chapter reduced to plain paragraphs. +type Chapter struct { + Label string + Volume string + URL string + Paragraphs []string +} + +// Heading is the chapter title to print. A bare number reads poorly as a +// heading, so those get the Vietnamese word for "chapter". +func (c *Chapter) Heading() string { + label := strings.TrimSpace(c.Label) + if label == "" { + return "Chương" + } + if isDigits(label) { + return "Chương " + label + } + return label +} + +// WordCount is a rough word count, used to sanity-check extraction. +func (c *Chapter) WordCount() int { + n := 0 + for _, p := range c.Paragraphs { + n += len(strings.Fields(p)) + } + return n +} + +func isDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// noteMarker matches the footnote placeholders left in the body text. The site +// swaps each one for a tooltip icon whose text lives elsewhere on the page, so +// in a plain-text export the marker itself is noise. +var noteMarker = regexp.MustCompile(`(?i)\[note\d+\]`) + +// ParseChapter extracts a chapter's paragraphs. +// +// The body is normally delivered as an encoded payload rather than as markup +// (see protected.go); when it is, the payload is decoded first and the +// paragraphs are read out of the result. A chapter still served as plain markup +// is read directly, so both layouts work. +func ParseChapter(page []byte, ref ChapterRef) (*Chapter, error) { + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(page)) + if err != nil { + return nil, fmt.Errorf("parse chapter %s: %w", ref.URL, err) + } + content := doc.Find("#" + contentID).First() + if content.Length() == 0 { + return nil, fmt.Errorf("chapter %s: no #%s container (page layout may have changed)", + ref.URL, contentID) + } + + body, err := chapterBody(content, ref) + if err != nil { + return nil, err + } + + return &Chapter{ + Label: ref.Label, + Volume: ref.Volume, + URL: ref.URL, + Paragraphs: dropRepeatedTitle(extractParagraphs(body), ref.Label), + }, nil +} + +// chapterBody returns the selection actually holding the prose: the decoded +// payload when the body is encoded, otherwise the container itself. +func chapterBody(content *goquery.Selection, ref ChapterRef) (*goquery.Selection, error) { + protected := content.Find("#" + protectedID).First() + if protected.Length() == 0 { + return content, nil + } + + load, err := parsePayload( + protected.AttrOr("data-s", ""), + protected.AttrOr("data-k", ""), + protected.AttrOr("data-c", "[]"), + ) + if err != nil { + return nil, fmt.Errorf("chapter %s: %w", ref.URL, err) + } + html, err := load.decode() + if err != nil { + return nil, fmt.Errorf("chapter %s: %w", ref.URL, err) + } + + // The payload is a fragment of the chapter body only, with none of the + // page's own furniture around it. + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, fmt.Errorf("chapter %s: parse decoded body: %w", ref.URL, err) + } + return doc.Selection, nil +} + +// extractParagraphs reads the paragraph text out of a body selection. +func extractParagraphs(body *goquery.Selection) []string { + var paragraphs []string + body.Find("p").Each(func(_ int, p *goquery.Selection) { + // The page plants a hidden copy of the chapter title next to the body. + if strings.Contains(strings.ReplaceAll(p.AttrOr("style", ""), " ", ""), "display:none") { + return + } + text := collapseSpaces(noteMarker.ReplaceAllString(p.Text(), "")) + if text == "" { + return + } + paragraphs = append(paragraphs, text) + }) + return paragraphs +} + +// dropRepeatedTitle removes a leading paragraph that only repeats the chapter +// label, since the export prints its own heading. +func dropRepeatedTitle(paragraphs []string, label string) []string { + if len(paragraphs) < 2 { + return paragraphs + } + if strings.EqualFold(strings.TrimSpace(paragraphs[0]), strings.TrimSpace(label)) { + return paragraphs[1:] + } + return paragraphs +} diff --git a/hako-crawler/hako/chapter_test.go b/hako-crawler/hako/chapter_test.go new file mode 100644 index 0000000..c510a81 --- /dev/null +++ b/hako-crawler/hako/chapter_test.go @@ -0,0 +1,116 @@ +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 `
Chương 01
` + + `Trời hôm nay đẹp lắm.
` + + `Người anh trai mỉm cười.[note1]
` + + `` + + 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(`
Một buổi sáng yên tĩnh.
`)), 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, `
nope
`), 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(``) + _, 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) + } + } +} diff --git a/hako-crawler/hako/client.go b/hako-crawler/hako/client.go new file mode 100644 index 0000000..2dce6ae --- /dev/null +++ b/hako-crawler/hako/client.go @@ -0,0 +1,141 @@ +package hako + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// The site serves a trimmed page to clients without a browser-like User-Agent. +const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + +// maxPageSize caps how much of a response is buffered. Chapter pages carry the +// body twice over — once as the encoded blob — and run to ~170 KB. +const maxPageSize = 8 << 20 + +// statusError carries an unexpected HTTP status so Get can decide whether a +// retry is worth spending. +type statusError struct { + code int + status string +} + +func (e *statusError) Error() string { return "unexpected status " + e.status } + +// retryable is true only for failures that a later attempt might survive. +// Hako answers a burst with 429, which is the case worth waiting out; a 404 or +// 403 will not fix itself and must not burn the retry budget. +func (e *statusError) retryable() bool { + return e.code == http.StatusTooManyRequests || e.code >= 500 +} + +// Client fetches pages from ln.hako.vn. +// +// Requests are spaced by a fixed delay across every caller, so raising the +// worker count raises concurrency without raising the request rate — which is +// what keeps a whole-novel crawl from tripping the 429 the old single-threaded +// script kept hitting. +type Client struct { + http *http.Client + ua string + delay time.Duration + retries int + + mu sync.Mutex + nextSlot time.Time +} + +// NewClient returns a client that spaces requests by delay and retries a +// transient failure up to retries times. +func NewClient(delay time.Duration, retries int) *Client { + return &Client{ + http: &http.Client{Timeout: 45 * time.Second}, + ua: defaultUserAgent, + delay: delay, + retries: retries, + } +} + +// reserve claims the next request slot and waits for it, holding the global +// rate at one request per delay no matter how many goroutines are fetching. +func (c *Client) reserve(ctx context.Context) error { + c.mu.Lock() + slot := c.nextSlot + if now := time.Now(); slot.Before(now) { + slot = now + } + c.nextSlot = slot.Add(c.delay) + c.mu.Unlock() + + wait := time.Until(slot) + if wait <= 0 { + return nil + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// Get fetches url, retrying transient failures with exponential backoff. +func (c *Client) Get(ctx context.Context, url string) ([]byte, error) { + var lastErr error + for attempt := 0; attempt <= c.retries; attempt++ { + if attempt > 0 { + backoff := time.Duration(1<Trời hôm nay đẹp lắm.
Người anh trai mỉm cười.
` +) + +func TestDecodeXORShuffle(t *testing.T) { + for _, chunkLen := range []int{7, 16, 31, 4096} { + load, err := parsePayload(schemeXORShuffle, testKey, + jsonArray(encodeXORShuffle(t, testBody, testKey, chunkLen))) + if err != nil { + t.Fatalf("chunk length %d: parse payload: %v", chunkLen, err) + } + got, err := load.decode() + if err != nil { + t.Fatalf("chunk length %d: decode: %v", chunkLen, err) + } + if got != testBody { + t.Errorf("chunk length %d: body round-trip failed\n got: %q\nwant: %q", chunkLen, got, testBody) + } + } +} + +func TestDecodeRejectsWrongKey(t *testing.T) { + load, err := parsePayload(schemeXORShuffle, "0000000000000000", + jsonArray(encodeXORShuffle(t, testBody, testKey, 16))) + if err != nil { + t.Fatalf("parse payload: %v", err) + } + // A wrong key yields bytes that are not valid UTF-8; the decoder must say + // so rather than hand back a chapter of mojibake. + if _, err := load.decode(); err == nil { + t.Fatal("expected an error for a wrong key, got none") + } +} + +func TestDecodeRejectsUnknownScheme(t *testing.T) { + // When the site rotates its encoding, failing loudly here is what tells the + // user why the export stopped working. + load, err := parsePayload("aes_gcm_2027", testKey, `["0000AAAA"]`) + if err != nil { + t.Fatalf("parse payload: %v", err) + } + if _, err := load.decode(); err == nil { + t.Fatal("expected an error for an unknown scheme, got none") + } +} + +func TestDecodeRejectsMissingKey(t *testing.T) { + load, err := parsePayload(schemeXORShuffle, "", `["0000AAAA"]`) + if err != nil { + t.Fatalf("parse payload: %v", err) + } + if _, err := load.decode(); err == nil { + t.Fatal("expected an error when the key is absent, got none") + } +} + +func TestDecodeRejectsEmptyPayload(t *testing.T) { + load, err := parsePayload(schemeXORShuffle, testKey, `[]`) + if err != nil { + t.Fatalf("parse payload: %v", err) + } + if _, err := load.decode(); err == nil { + t.Fatal("expected an error for an empty chunk list, got none") + } +} + +// base64 and base64_reverse are the site's simpler schemes; the decoder still +// carries them so a chapter served either way keeps working. +func TestDecodePlainBase64Schemes(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte(testBody)) + + for _, tc := range []struct { + scheme string + chunk string + }{ + {schemeBase64, encoded}, + {schemeBase64Reverse, reverseASCII(encoded)}, + } { + load, err := parsePayload(tc.scheme, "", jsonArray([]string{"0000" + tc.chunk})) + if err != nil { + t.Fatalf("%s: parse payload: %v", tc.scheme, err) + } + got, err := load.decode() + if err != nil { + t.Fatalf("%s: decode: %v", tc.scheme, err) + } + if got != testBody { + t.Errorf("%s: got %q, want %q", tc.scheme, got, testBody) + } + } +} + +func TestSortChunksRejectsBadPrefix(t *testing.T) { + if _, err := sortChunks([]string{"abcdAAAA"}); err == nil { + t.Fatal("expected an error for a non-numeric position prefix, got none") + } + if _, err := sortChunks([]string{"01"}); err == nil { + t.Fatal("expected an error for a chunk shorter than its prefix, got none") + } +} diff --git a/hako-crawler/main.go b/hako-crawler/main.go deleted file mode 100644 index 8f8173c..0000000 --- a/hako-crawler/main.go +++ /dev/null @@ -1,145 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/PuerkitoBio/goquery" -) - -const DEBUG = false - -var sanitizeRe = regexp.MustCompile(`[\\\/:*?"<>|]`) - -func isDigit(id string) bool { - if id == "" { - return false - } - for _, r := range id { - if r < '0' || r > '9' { - return false - } - } - return true -} - -func getFromURL(url string) (string, error) { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", err - } - req.Header.Set("User-Agent", "Mozilla/5.0") - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("HTTP error: %s", resp.Status) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - return string(body), nil -} - -func writeTextToFile(text, filename string) error { - filename = sanitizeRe.ReplaceAllString(filename, "_") - dir := "data" - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - path := filepath.Join(dir, filename) - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - _, err = f.WriteString(text) - return err -} - -func readTextFromFile(filename string) (string, error) { - path := filepath.Join("data", filename) - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - return string(data), nil -} - -func main() { - var html string - var err error - if DEBUG { - html, err = readTextFromFile("_.txt") - } else { - html, err = getFromURL("https://ln.hako.vn/sang-tac/8476-kiep-nay-la-anh-trai-cua-nhan-vat-chinh") - // writeTextToFile(html, "_.txt") - } - if err != nil { - fmt.Printf("Error: %v\n", err) - return - } - - doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) - if err != nil { - fmt.Printf("Parse error: %v\n", err) - return - } - - chapters := doc.Find("div.chapter-name") - /* - you may get `HTTP Error 429: Too Many Requests` - you can try again later, and skip downloaded chapters - example skip 50 first chapters like: chapters = chapters.Slice(50, chapters.Length()) - */ - // chapters = chapters.Slice(100, chapters.Length()) - for i := 0; i < chapters.Length(); i++ { - chapterSel := chapters.Eq(i) - children := chapterSel.ChildrenFiltered("a") - if children.Length() == 0 { - continue - } - linkSel := children.First() - title, hasTitle := linkSel.Attr("title") - if !hasTitle { - continue - } - href, hasHref := linkSel.Attr("href") - if !hasHref { - continue - } - chapterURL := "https://ln.hako.vn" + href - chapterHTML, err := getFromURL(chapterURL) - if err != nil { - fmt.Printf("Error fetching %s: %v\n", chapterURL, err) - continue - } - chapterDoc, err := goquery.NewDocumentFromReader(strings.NewReader(chapterHTML)) - if err != nil { - fmt.Printf("Parse chapter %s: %v\n", title, err) - continue - } - content := chapterDoc.Find("#chapter-content") - var chapterData strings.Builder - content.Find("p").Each(func(_ int, s *goquery.Selection) { - id, exists := s.Attr("id") - if exists && isDigit(id) { - chapterData.WriteString(s.Text()) - chapterData.WriteString("\n") - } - }) - filename := title + ".txt" - if err := writeTextToFile(chapterData.String(), filename); err != nil { - fmt.Printf("Error writing %s: %v\n", filename, err) - } - } -}