mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-31 20:21:12 +00:00
/monkeyd_crawl <url> [font_size] sets the body text size in points, half points included, bounded to 6-24. Omitting it sends no size at all so the crawler's default applies, rather than defining a second default here that could drift. The document caption reports the size used. Also advance the submodule to the lower 10pt default: on the 90x160mm phone page that fits about 43 characters per line instead of 36.
169 lines
5.9 KiB
Go
169 lines
5.9 KiB
Go
package monkeyd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"time"
|
|
|
|
"github.com/go-telegram/bot"
|
|
"github.com/go-telegram/bot/models"
|
|
|
|
"github.com/tiennm99/monkeyd-crawler/export"
|
|
|
|
"github.com/tiennm99/miti99bot/internal/log"
|
|
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
|
)
|
|
|
|
const (
|
|
// crawlTimeout bounds one export. A long novel is roughly one request per
|
|
// 400ms plus retries, so even a thousand chapters fits well inside this;
|
|
// the ceiling exists to release the single export slot if the site stalls.
|
|
crawlTimeout = 30 * time.Minute
|
|
|
|
// uploadTimeout is separate from crawlTimeout so a slow crawl cannot eat
|
|
// the budget needed to actually deliver the finished PDF.
|
|
uploadTimeout = 5 * time.Minute
|
|
|
|
// statusTimeout bounds the short progress and failure replies.
|
|
statusTimeout = 30 * time.Second
|
|
|
|
// maxDocumentBytes is the Bot API's upload ceiling for sendDocument.
|
|
// Checked before opening the upload so an oversized book fails with an
|
|
// explanation instead of a Telegram API error.
|
|
maxDocumentBytes = 50 << 20
|
|
)
|
|
|
|
// cacheDirName is the shared page cache under the system temp directory.
|
|
// Keeping it outside the per-run directory is what makes a repeat export of the
|
|
// same novel cost no requests. It is not pruned; a container restart clears it.
|
|
const cacheDirName = "miti99bot-monkeyd-cache"
|
|
|
|
// export runs one crawl to completion and delivers the PDF. It is called on its
|
|
// own goroutine, detached from the Telegram handler context.
|
|
func (r *runner) export(b *bot.Bot, msg *models.Message, args crawlArgs) {
|
|
novelURL := args.NovelURL
|
|
defer r.end()
|
|
// A panic here would reach no handler recover and would take the whole
|
|
// process down with it, so contain it.
|
|
defer func() {
|
|
if p := recover(); p != nil {
|
|
log.Error("monkeyd export panicked",
|
|
"command", commandName, "url", novelURL, "panic", p, "stack", string(debug.Stack()))
|
|
r.reportFailure(b, msg, "The export crashed. Nothing was sent.")
|
|
}
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), crawlTimeout)
|
|
defer cancel()
|
|
|
|
// The PDF is a temporary artefact: it is uploaded and then dropped.
|
|
outDir, err := os.MkdirTemp("", "monkeyd-export-*")
|
|
if err != nil {
|
|
log.Error("monkeyd temp dir failed", "command", commandName, "err", err)
|
|
r.reportFailure(b, msg, "Could not create a working directory for the export.")
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := os.RemoveAll(outDir); err != nil {
|
|
log.Warn("monkeyd temp cleanup failed", "command", commandName, "dir", outDir, "err", err)
|
|
}
|
|
}()
|
|
|
|
result, err := r.exporter(ctx, export.Request{
|
|
NovelURL: novelURL,
|
|
OutDir: outDir,
|
|
// Zero means the caller gave no font size, which Export reads as
|
|
// "use the default" — exactly the intent.
|
|
FontSize: args.FontSize,
|
|
CacheDir: filepath.Join(os.TempDir(), cacheDirName),
|
|
// Per-chapter progress is one line per chapter — useful when
|
|
// diagnosing a stuck export, too noisy for the default level.
|
|
Log: func(format string, args ...any) {
|
|
log.Debug("monkeyd crawl: "+fmt.Sprintf(format, args...), "command", commandName, "url", novelURL)
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Error("monkeyd export failed", "command", commandName, "url", novelURL, "err", err)
|
|
r.reportFailure(b, msg, "Export failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
log.Info("monkeyd export done", "command", commandName, "url", novelURL,
|
|
"title", result.Title, "chapters", result.Chapters, "words", result.Words)
|
|
|
|
if err := sendPDF(b, msg, result, effectiveFontSize(args.FontSize)); err != nil {
|
|
log.Error("monkeyd delivery failed", "command", commandName, "url", novelURL, "err", err)
|
|
r.reportFailure(b, msg, "The novel was exported but the PDF could not be sent: "+err.Error())
|
|
}
|
|
}
|
|
|
|
// effectiveFontSize resolves what the renderer actually used, so the caption
|
|
// can report a real number rather than "default".
|
|
func effectiveFontSize(requested float64) float64 {
|
|
if requested == 0 {
|
|
return export.DefaultFontSize
|
|
}
|
|
return requested
|
|
}
|
|
|
|
// sendPDF uploads the finished book as a Telegram document.
|
|
func sendPDF(b *bot.Bot, msg *models.Message, result *export.Result, fontSize float64) error {
|
|
info, err := os.Stat(result.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("stat pdf: %w", err)
|
|
}
|
|
if info.Size() > maxDocumentBytes {
|
|
return fmt.Errorf("the PDF is %.1f MB, above Telegram's %d MB limit for bots",
|
|
float64(info.Size())/(1<<20), maxDocumentBytes>>20)
|
|
}
|
|
|
|
file, err := os.Open(result.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("open pdf: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), uploadTimeout)
|
|
defer cancel()
|
|
|
|
// MessageThreadID keeps the document in the forum topic the command came
|
|
// from, matching chathelper.Reply's behaviour.
|
|
_, err = b.SendDocument(ctx, &bot.SendDocumentParams{
|
|
ChatID: msg.Chat.ID,
|
|
MessageThreadID: msg.MessageThreadID,
|
|
Document: &models.InputFileUpload{
|
|
Filename: filepath.Base(result.Path),
|
|
Data: file,
|
|
},
|
|
Caption: caption(result, fontSize),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// captionLimit is the Bot API's maximum caption length in characters.
|
|
const captionLimit = 1024
|
|
|
|
// caption describes the book under the document. Plain text, so a title
|
|
// containing markup characters needs no escaping.
|
|
func caption(result *export.Result, fontSize float64) string {
|
|
text := fmt.Sprintf("%s\n%s page (%.0f x %.0f mm), %gpt\n%s",
|
|
result.Summary(), result.Page.Name, result.Page.W, result.Page.H, fontSize, result.SourceURL)
|
|
if runes := []rune(text); len(runes) > captionLimit {
|
|
text = string(runes[:captionLimit])
|
|
}
|
|
return text
|
|
}
|
|
|
|
// reportFailure tells the chat the export did not produce a document. Delivery
|
|
// failures here are only logged — there is nothing further to fall back to.
|
|
func (r *runner) reportFailure(b *bot.Bot, msg *models.Message, text string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), statusTimeout)
|
|
defer cancel()
|
|
if err := chathelper.Reply(ctx, b, msg, text); err != nil {
|
|
log.Error("monkeyd failure reply failed", "command", commandName, "err", err)
|
|
}
|
|
}
|