fix: drop sponsor, watermark and navigation blocks from chapter text

Three blocks the site injects inside the chapter body were being extracted
as prose: the sponsor block, a source watermark planted mid-chapter, and
the prev/next chapter links.

Anchors and images are now skipped, since inside a chapter body they are
always site chrome, and the sponsor wrapper and watermark are skipped by
class. Inline emphasis tags are left alone so italics in the prose survive.

The sponsor wrapper has a sibling that must NOT be skipped: on most
chapters the real text sits in a container hidden with display:none,
because the body is gated behind a click on the sponsor link and revealed
with JavaScript. Skipping hidden elements, or treating the two sibling
classes as one family, discards the entire chapter. A test pins the gated
body as kept while its sibling is dropped.

Removes 662 words of injected text across the sample novel and lets the
repeated chapter-number paragraph be recognised again, which the sponsor
block had been masking by displacing it from the top of the body.
This commit is contained in:
tiennm99 committed 2026-07-29 22:36:15 +07:00
1 parent 6bcdd742d6
commit 2855ce5e53
2 files changed
+108 -3

No files matched your search

+37 -3
View File
@@ -48,11 +48,45 @@ var blockTags = map[string]bool{
"li": true, "ul": true, "ol": true, "tr": true,
}
// skipTags never contribute text: scripts, styles and the ad/report widgets
// the site injects inside the content container.
// skipTags never contribute prose.
//
// Anchors are included because inside a chapter body they are always site
// chrome: the prev/next chapter navigation and the sponsor call-to-action are
// both links, while novel prose never needs one. Inline emphasis tags (b, i,
// em) are deliberately absent so italics in the prose survive; the site's icons
// use <i> but carry no text.
var skipTags = map[string]bool{
"script": true, "style": true, "noscript": true, "iframe": true,
"ins": true, "form": true, "select": true, "button": true, "textarea": true,
"a": true, "img": true, "svg": true,
}
// junkClasses marks containers the site injects into the chapter body. Their
// whole subtree is dropped.
//
// These are matched on class rather than position because the blocks move: the
// sponsor block opens the body on most chapters but is absent on others, and
// the watermark is planted at a different paragraph in every chapter. Only
// site-specific class names are listed; generic Bootstrap utilities such as
// "my-4" or "text-center" are not, since prose could legitimately carry them.
//
// Note the sibling class "actac" is NOT junk: it wraps the real chapter text and
// carries style="display:none", because the site gates the body behind a click
// on the sponsor link and reveals it with JavaScript. Skipping hidden elements,
// or skipping "act*" as a family, would therefore discard the whole chapter.
var junkClasses = map[string]bool{
"actcl": true, // sponsor block shown in place of the gated chapter body
"signature": true, // "[Truyện được đăng tải duy nhất tại ...]" source watermark
}
// hasJunkClass reports whether a node is an injected non-prose container.
func hasJunkClass(n *html.Node) bool {
for _, tok := range strings.Fields(attr(n, "class")) {
if junkClasses[tok] {
return true
}
}
return false
}
// ParseChapter extracts a chapter's paragraphs, restoring the words the site
@@ -93,7 +127,7 @@ func extractParagraphs(content *html.Node, words map[string]string) []string {
b.WriteString(n.Data)
return
case html.ElementNode:
if skipTags[n.Data] {
if skipTags[n.Data] || hasJunkClass(n) {
return
}
// These elements are empty in the markup; the CSS word replaces them.
@@ -108,6 +108,77 @@ func TestParseChapterDropsRepeatedTitle(t *testing.T) {
}
}
// gatedChapterFixture mirrors how most chapters are served: a visible sponsor
// block (div.actcl) stands in for the body, while the real text sits in a
// sibling div.actac hidden with display:none and revealed by the site's
// JavaScript. A source watermark and prev/next navigation bracket the prose.
const gatedChapterFixture = `<!DOCTYPE html><html><head>
<style>.t-aaa:before { content: "v\1ecb"; }</style></head><body>
<div class="content-container" id="chapter-content-render">
<div class="actcl">
<h4 class="text-center text-primary">Moi Quy doc gia CLICK vao lien ket</h4>
<p class="text-center">mo ung dung Shopee, sau do quay tro lai de doc!</p>
<a class="btn btn-primary px-3" href="https://s.shopee.vn/xxxx">
<img src="x.jpg"><span class="text-uppercase text-danger">CLICK</span></a>
<h4 class="text-center text-primary">MonkeyD va doi ngu Editor xin chan thanh cam on!</h4>
</div>
<div class="actac" style=" display:none; ">
<p>Doan van dau tien co <span class="t-aaa"></span> tri.</p>
<p>&nbsp;</p>
<p class="signature">[Truyen duoc dang tai duy nhat tai MonkeyDD.com - https://monkeydd.com/n/10.html.]</p>
<p>Doan van cuoi cung.</p>
</div>
<div class="my-4"><div class="d-flex justify-content-center">
<a class="btn btn-primary px-3 me-2" href="/n/9.html"><i class="bx bx-chevron-left"></i>Chương trước</a>
<a class="btn btn-primary px-3" href="/n/11.html">Chương sau<i class="bx bx-chevron-right"></i></a>
</div></div>
</div></body></html>`
// The gated body is the one thing that must survive: it is hidden with
// display:none, so any rule that drops hidden or "act*" containers silently
// discards the entire chapter.
func TestParseChapterKeepsGatedBody(t *testing.T) {
ch, err := ParseChapter([]byte(gatedChapterFixture), ChapterRef{Label: "10", URL: "http://x/10.html"})
if err != nil {
t.Fatalf("ParseChapter: %v", err)
}
want := []string{
"Doan van dau tien co vị tri.",
"Doan van cuoi cung.",
}
if len(ch.Paragraphs) != len(want) {
t.Fatalf("got %d paragraphs %q, want %d", len(ch.Paragraphs), ch.Paragraphs, len(want))
}
for i, w := range want {
if ch.Paragraphs[i] != w {
t.Errorf("paragraph %d = %q, want %q", i, ch.Paragraphs[i], w)
}
}
}
// Everything the site injects around the prose must be gone.
func TestParseChapterDropsInjectedBlocks(t *testing.T) {
ch, err := ParseChapter([]byte(gatedChapterFixture), ChapterRef{Label: "10", URL: "http://x/10.html"})
if err != nil {
t.Fatalf("ParseChapter: %v", err)
}
body := strings.Join(ch.Paragraphs, "\n")
for _, junk := range []string{
"Shopee", // sponsor copy
"CLICK", // sponsor call to action
"cam on", // sponsor sign-off
"MonkeyDD.com", // source watermark
"Chương trước", // navigation
"Chương sau", // navigation
} {
if strings.Contains(body, junk) {
t.Errorf("injected text %q survived extraction in %q", junk, body)
}
}
}
func TestParseChapterMissingContainer(t *testing.T) {
if _, err := ParseChapter([]byte(`<html><body><p>hi</p></body></html>`),
ChapterRef{URL: "http://x/1.html"}); err == nil {