mirror of
https://github.com/tiennm99/MTTools.git
synced 2026-10-20 00:15:39 +00:00
refactor: make crawler importable and add export pipeline
Move monkeyd and pdfout out of internal/ so other modules can import them, and add export.Export, which holds the crawl-to-PDF sequence the CLI used to inline. The CLI now parses flags and delegates, so an embedding program gets the same defaults and validation.
This commit is contained in:
1 parent
2855ce5e53
commit
3633e96cf5
15 files changed
+490
-128
No files matched your search
@@ -0,0 +1,189 @@
|
||||
package monkeyd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ChapterRef points at a single chapter listed on a novel page.
|
||||
type ChapterRef struct {
|
||||
Label string // as shown on the site, e.g. "14" or "Chương 12"
|
||||
URL string
|
||||
}
|
||||
|
||||
// Novel is a novel's landing page: its title and its chapters in reading order.
|
||||
type Novel struct {
|
||||
Title string
|
||||
Slug string
|
||||
URL string
|
||||
Chapters []ChapterRef
|
||||
}
|
||||
|
||||
// ParseNovelPage reads the title and chapter list from a novel landing page.
|
||||
//
|
||||
// Chapter URLs are always taken from the anchors on the page. Slugs are not
|
||||
// uniform across novels ("/14.html" on one, "/chuong-12.html" on another) and
|
||||
// numbering has gaps, so generating URLs from a chapter count would fetch 404s
|
||||
// and miss real chapters.
|
||||
func ParseNovelPage(page []byte, pageURL string) (*Novel, error) {
|
||||
doc, err := html.Parse(bytes.NewReader(page))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse novel page: %w", err)
|
||||
}
|
||||
base, err := url.Parse(pageURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse novel url: %w", err)
|
||||
}
|
||||
|
||||
novel := &Novel{
|
||||
Title: novelTitle(doc),
|
||||
Slug: slugFromNovelURL(base),
|
||||
URL: pageURL,
|
||||
Chapters: chapterRefsFromList(doc, base),
|
||||
}
|
||||
if novel.Title == "" {
|
||||
novel.Title = novel.Slug
|
||||
}
|
||||
if len(novel.Chapters) == 0 {
|
||||
return nil, fmt.Errorf("no chapters found on %s (page layout may have changed)", pageURL)
|
||||
}
|
||||
return novel, nil
|
||||
}
|
||||
|
||||
// novelTitle prefers the <h1> heading and falls back to the document title.
|
||||
func novelTitle(doc *html.Node) string {
|
||||
if h1 := elementByTag(doc, "h1"); h1 != nil {
|
||||
if t := nodeText(h1); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nodeText(elementByTag(doc, "title"))
|
||||
}
|
||||
|
||||
// slugFromNovelURL turns https://host/tro-lai-nam-thang-cu.html into
|
||||
// "tro-lai-nam-thang-cu".
|
||||
func slugFromNovelURL(u *url.URL) string {
|
||||
seg := strings.Trim(u.Path, "/")
|
||||
if i := strings.LastIndex(seg, "/"); i >= 0 {
|
||||
seg = seg[i+1:]
|
||||
}
|
||||
return strings.TrimSuffix(seg, ".html")
|
||||
}
|
||||
|
||||
// chapterRefsFromList reads the "list-chapters" block on the landing page.
|
||||
// The site lists newest first, so the result is reversed into reading order.
|
||||
func chapterRefsFromList(doc *html.Node, base *url.URL) []ChapterRef {
|
||||
list := findNode(doc, func(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && hasClass(n, "list-chapters")
|
||||
})
|
||||
if list == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
titles := findAllNodes(list, func(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && hasClass(n, "episode-title")
|
||||
})
|
||||
|
||||
var refs []ChapterRef
|
||||
for _, title := range titles {
|
||||
link := elementByTag(title, "a")
|
||||
if link == nil {
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(attr(link, "href"))
|
||||
if href == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := base.Parse(href)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, ChapterRef{Label: nodeText(link), URL: abs.String()})
|
||||
}
|
||||
return reverseRefs(refs)
|
||||
}
|
||||
|
||||
// ChapterRefsFromSelect reads the chapter dropdown embedded in every chapter
|
||||
// page, whose options hold "novel-slug,chapter-slug" pairs. This is a second,
|
||||
// independent view of the chapter list used to cross-check the landing page.
|
||||
func ChapterRefsFromSelect(page []byte, base *url.URL) ([]ChapterRef, error) {
|
||||
doc, err := html.Parse(bytes.NewReader(page))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse chapter page: %w", err)
|
||||
}
|
||||
sel := elementByID(doc, "selected_chapter")
|
||||
if sel == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var refs []ChapterRef
|
||||
for _, opt := range findAllNodes(sel, func(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && n.Data == "option"
|
||||
}) {
|
||||
novelSlug, chapterSlug, ok := strings.Cut(attr(opt, "value"), ",")
|
||||
if !ok || novelSlug == "" || chapterSlug == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := base.Parse("/" + novelSlug + "/" + chapterSlug + ".html")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, ChapterRef{Label: nodeText(opt), URL: abs.String()})
|
||||
}
|
||||
return reverseRefs(refs), nil
|
||||
}
|
||||
|
||||
// ReconcileChapterRefs picks the chapter list to crawl from the landing page
|
||||
// list and the in-chapter dropdown.
|
||||
//
|
||||
// Both views come from the same site ordering, so when the dropdown is a
|
||||
// superset it is preferred: that keeps the run correct even if the landing page
|
||||
// ever truncates or paginates its list. Anything the dropdown alone knows about
|
||||
// while disagreeing on order is returned as extra so the caller can warn rather
|
||||
// than silently export a short book.
|
||||
func ReconcileChapterRefs(fromList, fromSelect []ChapterRef) (final, extra []ChapterRef) {
|
||||
if len(fromSelect) == 0 {
|
||||
return fromList, nil
|
||||
}
|
||||
|
||||
inSelect := refURLSet(fromSelect)
|
||||
if listIsSubsetOf(fromList, inSelect) {
|
||||
return fromSelect, nil
|
||||
}
|
||||
|
||||
inList := refURLSet(fromList)
|
||||
for _, ref := range fromSelect {
|
||||
if !inList[ref.URL] {
|
||||
extra = append(extra, ref)
|
||||
}
|
||||
}
|
||||
return fromList, extra
|
||||
}
|
||||
|
||||
func refURLSet(refs []ChapterRef) map[string]bool {
|
||||
set := make(map[string]bool, len(refs))
|
||||
for _, r := range refs {
|
||||
set[r.URL] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func listIsSubsetOf(refs []ChapterRef, set map[string]bool) bool {
|
||||
for _, r := range refs {
|
||||
if !set[r.URL] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reverseRefs(refs []ChapterRef) []ChapterRef {
|
||||
for i, j := 0, len(refs)-1; i < j; i, j = i+1, j-1 {
|
||||
refs[i], refs[j] = refs[j], refs[i]
|
||||
}
|
||||
return refs
|
||||
}
|
||||
Reference in new issue
Block a user