refactor(monkeyd): port the crawler in-tree and drop the submodule

The monkeyd-crawler repository moved into tiennm99/mttools, so recursive
clones of the old submodule URL fail and block every deploy. The crawler,
PDF renderer, and export flow now live under internal/modules/monkeyd,
trimmed to the bot's use: a fixed phone page, the bundled font only, and
the font size as the single export option.
This commit is contained in:
tiennm99 committed 2026-10-03 11:23:45 +07:00
1 parent c1393f0ec8
commit d211124aae
26 files changed
+1818 -30

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
package pdf
import (
_ "embed"
)
// bundledTTF is the only font the bot renders with. A minimal container has no
// system fonts, and a single embedded font keeps the PDF identical on every
// host. See fonts/NOTICE.md for provenance and licensing.
//
// Vietnamese text needs the Latin Extended Additional block (ư, ạ, ế, ộ …);
// DejaVu Sans covers it, where a basic-Latin font would silently drop the
// diacritics.
//
//go:embed fonts/DejaVuSans.ttf
var bundledTTF []byte
// BundledFontName labels the embedded font in diagnostics. It is not a path;
// the font is compiled into the binary.
const BundledFontName = "DejaVu Sans (bundled)"
// Font is font data ready to embed in a PDF.
//
// The data is carried as bytes rather than as a path because fpdf joins a font
// path onto its own font directory, which it defaults to "." — turning an
// absolute path into a working-directory-relative one that resolves only when
// the process happens to run from the filesystem root.
type Font struct {
// Name identifies the font for diagnostics.
Name string
Data []byte
}
// BundledFont returns the font compiled into the binary.
func BundledFont() Font {
return Font{Name: BundledFontName, Data: bundledTTF}
}
+38
View File
@@ -0,0 +1,38 @@
package pdf
import (
"testing"
"golang.org/x/image/font/sfnt"
)
func TestBundledFontIsParseable(t *testing.T) {
font := BundledFont()
if font.Name != BundledFontName {
t.Errorf("Name = %q, want %q", font.Name, BundledFontName)
}
if _, err := sfnt.Parse(font.Data); err != nil {
t.Fatalf("bundled font does not parse as a TrueType font: %v", err)
}
}
// The bundled font exists to render Vietnamese. A replacement that lacks these
// glyphs would silently emit blanks, so check before trusting it.
func TestBundledFontCoversVietnamese(t *testing.T) {
parsed, err := sfnt.Parse(BundledFont().Data)
if err != nil {
t.Fatalf("parse bundled font: %v", err)
}
var buf sfnt.Buffer
for _, r := range []rune{'ư', 'ơ', 'đ', 'ạ', 'ế', 'ộ', 'ữ', 'ằ', 'ỷ', 'ỹ', 'Ọ', 'Ế', 'Ư', 'Đ'} {
index, err := parsed.GlyphIndex(&buf, r)
if err != nil {
t.Errorf("GlyphIndex(%q): %v", r, err)
continue
}
// Glyph 0 is .notdef — the character is absent from the font.
if index == 0 {
t.Errorf("bundled font has no glyph for %q (U+%04X)", r, r)
}
}
}
Binary file not shown.
@@ -0,0 +1,24 @@
# Bundled font
`DejaVuSans.ttf` is embedded into the binary and used when no font is supplied
and no suitable system font is found. It covers the Latin Extended Additional
block, which is what Vietnamese diacritics need — a font with only basic Latin
coverage silently drops them.
The following is recorded in the font file's own name table:
- Version: `Version 2.37`
- Copyright: `Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.`
`Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.`
`DejaVu changes are in public domain`
- License information: <http://dejavu.sourceforge.net/wiki/index.php/License>
The DejaVu fonts are free and redistributable, which is why they ship with most
Linux distributions. This copy came from Alpine's `font-dejavu` package, which
does not include the license text as a separate file. For a vendored copy of the
full license text, take `LICENSE` from the upstream DejaVu release and add it to
this directory.
To swap the bundled font, replace `DejaVuSans.ttf` and update this file. Verify
the replacement covers Vietnamese first — `TestBundledFontCoversVietnamese` in
`../font_test.go` checks a representative set of characters.
+132
View File
@@ -0,0 +1,132 @@
package pdf
import (
"fmt"
"strings"
"github.com/go-pdf/fpdf"
)
// pointsToMM converts typographic points to millimetres.
const pointsToMM = 25.4 / 72.0
// bodyFont is the internal family name registered with the PDF.
const bodyFont = "body"
// PageSize is a page in millimetres.
type PageSize struct {
Name string
W, H float64
}
// PhonePage is the page every export uses.
//
// Phone reading depends far more on page shape than on font size. A phone
// viewer scales a whole page to fit the screen, so a large font on an A4 page
// still ends up tiny: the page is ~3x wider than the screen and gets shrunk to
// match. A page cut to the phone's own aspect ratio fills the screen at 100%,
// which is why this is a small 9:16 page rather than A4 with big type.
var PhonePage = PageSize{Name: "phone", W: 90, H: 160}
// Chapter is a chapter ready to render.
type Chapter struct {
Heading string
Paragraphs []string
}
// Options controls the exported PDF.
type Options struct {
Page PageSize
Margin float64 // mm
Font Font // BundledFont()
FontSize float64 // pt
LineSpacing float64 // multiple of font size
Title string
SourceURL string
}
// footerReserve is the vertical space kept clear for the page number.
const footerReserve = 6.0
// Write renders the chapters to a PDF at path.
func Write(path string, opts Options, chapters []Chapter) error {
pdf := fpdf.NewCustom(&fpdf.InitType{
UnitStr: "mm",
Size: fpdf.SizeType{Wd: opts.Page.W, Ht: opts.Page.H},
})
pdf.SetMargins(opts.Margin, opts.Margin, opts.Margin)
pdf.SetAutoPageBreak(true, opts.Margin+footerReserve)
// Embeds a subset of the TrueType data, which is what makes the Vietnamese
// diacritics render instead of falling back to "?". The bytes are passed
// directly rather than by path: the path-taking variant joins the name onto
// fpdf's own font directory (default "."), which mangles an absolute path
// into a working-directory-relative one.
pdf.AddUTF8FontFromBytes(bodyFont, "", opts.Font.Data)
pdf.SetFont(bodyFont, "", opts.FontSize)
pdf.SetTitle(opts.Title, true)
lineHeight := opts.FontSize * opts.LineSpacing * pointsToMM
paragraphGap := lineHeight * 0.45
headingSize := opts.FontSize * 1.35
addFooter(pdf, opts)
writeTitlePage(pdf, opts, len(chapters))
for _, ch := range chapters {
pdf.AddPage()
pdf.SetFontSize(headingSize)
pdf.MultiCell(0, headingSize*1.3*pointsToMM, ch.Heading, "", "L", false)
pdf.Ln(paragraphGap * 1.6)
pdf.SetFontSize(opts.FontSize)
for _, p := range ch.Paragraphs {
// "J" justifies, which keeps the short measure of a phone page tidy.
pdf.MultiCell(0, lineHeight, p, "", "J", false)
pdf.Ln(paragraphGap)
}
}
if err := pdf.OutputFileAndClose(path); err != nil {
return fmt.Errorf("write pdf %s: %w", path, err)
}
return nil
}
// addFooter prints a centred page number, restoring the body font size so the
// footer callback cannot leak its own size into the following content.
func addFooter(pdf *fpdf.Fpdf, opts Options) {
pdf.SetFooterFunc(func() {
if pdf.PageNo() <= 1 {
return
}
pdf.SetY(-(opts.Margin + footerReserve*0.6))
pdf.SetFontSize(opts.FontSize * 0.75)
pdf.SetTextColor(120, 120, 120)
pdf.CellFormat(0, 4, fmt.Sprintf("%d", pdf.PageNo()-1), "", 0, "C", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetFontSize(opts.FontSize)
})
}
func writeTitlePage(pdf *fpdf.Fpdf, opts Options, chapterCount int) {
pdf.AddPage()
pdf.SetY(opts.Page.H * 0.30)
titleSize := opts.FontSize * 1.9
pdf.SetFontSize(titleSize)
pdf.MultiCell(0, titleSize*1.35*pointsToMM, strings.ToUpper(opts.Title), "", "C", false)
pdf.Ln(opts.FontSize * pointsToMM * 2)
pdf.SetFontSize(opts.FontSize * 0.85)
pdf.SetTextColor(90, 90, 90)
pdf.MultiCell(0, opts.FontSize*1.4*pointsToMM,
fmt.Sprintf("%d chương", chapterCount), "", "C", false)
if opts.SourceURL != "" {
pdf.MultiCell(0, opts.FontSize*1.4*pointsToMM, opts.SourceURL, "", "C", false)
}
pdf.SetTextColor(0, 0, 0)
pdf.SetFontSize(opts.FontSize)
}
+79
View File
@@ -0,0 +1,79 @@
package pdf
import (
"os"
"path/filepath"
"testing"
)
func testOptions(t *testing.T) Options {
t.Helper()
return Options{
Page: PhonePage,
Margin: 6,
Font: BundledFont(),
FontSize: 12,
LineSpacing: 1.55,
Title: "TRỞ LẠI NĂM THÁNG CŨ",
SourceURL: "https://example.test/n.html",
}
}
func TestWriteProducesReadablePDF(t *testing.T) {
path := filepath.Join(t.TempDir(), "out.pdf")
chapters := []Chapter{
{Heading: "Chương 1", Paragraphs: []string{
"Nghe vị trưởng tử nói chuyện với nàng.",
"Một đoạn văn khác để kiểm tra ngắt dòng tự động trên trang nhỏ.",
}},
{Heading: "Chương 2", Paragraphs: []string{"Đoạn cuối."}},
}
if err := Write(path, testOptions(t), chapters); err != nil {
t.Fatalf("Write: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if info.Size() == 0 {
t.Fatal("wrote an empty PDF")
}
header := make([]byte, 5)
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := f.Read(header); err != nil {
t.Fatal(err)
}
if string(header) != "%PDF-" {
t.Errorf("output does not start with a PDF header, got %q", header)
}
}
// A novel with many chapters must not overflow a page; auto page break plus the
// footer reserve handles that, so a long chapter should span several pages.
func TestWriteHandlesLongChapters(t *testing.T) {
path := filepath.Join(t.TempDir(), "long.pdf")
paragraphs := make([]string, 200)
for i := range paragraphs {
paragraphs[i] = "Một đoạn văn dài để buộc trình kết xuất phải sang trang mới nhiều lần."
}
if err := Write(path, testOptions(t), []Chapter{{Heading: "Chương 1", Paragraphs: paragraphs}}); err != nil {
t.Fatalf("Write: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Size() < 2000 {
t.Errorf("output suspiciously small (%d bytes) for 200 paragraphs", info.Size())
}
}