mirror of
https://github.com/tiennm99/MTTools.git
synced 2026-10-14 18:19:03 +00:00
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.
150 lines
4.1 KiB
Go
150 lines
4.1 KiB
Go
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
|
|
}
|