mirror of
https://codeberg.org/readeck/readeck.git
synced 2026-06-18 11:04:36 +00:00
321 lines
7.6 KiB
Go
321 lines
7.6 KiB
Go
// SPDX-FileCopyrightText: © 2023 Olivier Meunier <olivier@neokraft.net>
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
// Package contentscripts provides a JavaScript engine that runs
|
|
// builtin, or user defined, scripts during the extraction process.
|
|
package contentscripts
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/net/html"
|
|
|
|
"github.com/dop251/goja"
|
|
"github.com/dop251/goja/ast"
|
|
"github.com/dop251/goja_nodejs/require"
|
|
nodeurl "github.com/dop251/goja_nodejs/url"
|
|
|
|
"codeberg.org/readeck/readeck/pkg/ctxr"
|
|
"codeberg.org/readeck/readeck/pkg/extract"
|
|
)
|
|
|
|
type (
|
|
configCtxKey struct{}
|
|
loggerCtxKey struct{}
|
|
messageCtxKey struct{}
|
|
nextPageCtxKey struct{}
|
|
runtimeCtxKey struct{}
|
|
)
|
|
|
|
var (
|
|
withConfig, checkConfig = ctxr.WithChecker[*SiteConfig](configCtxKey{})
|
|
withLogger, checkLogger = ctxr.WithChecker[*slog.Logger](loggerCtxKey{})
|
|
withMessage, checkMessage = ctxr.WithChecker[*extract.ProcessMessage](messageCtxKey{})
|
|
withNextPage, checkNextPage = ctxr.WithChecker[*url.URL](nextPageCtxKey{})
|
|
withRuntime, getRuntime = ctxr.WithGetter[*Runtime](runtimeCtxKey{})
|
|
)
|
|
|
|
// Program is a wrapper around goja.Program, with a script name.
|
|
type Program struct {
|
|
*goja.Program
|
|
Name string
|
|
Priority int
|
|
ModTime time.Time
|
|
}
|
|
|
|
// Runtime contains a collection of content scripts.
|
|
type Runtime struct {
|
|
*goja.Runtime
|
|
programs []*Program
|
|
ctx context.Context
|
|
}
|
|
|
|
type execFunc func() error
|
|
|
|
var registry = new(require.Registry)
|
|
|
|
// NewProgram wraps a script into an anonymous function call
|
|
// exposing the "exports" object and returns a Program instance.
|
|
func NewProgram(name string, r io.Reader) (*Program, error) {
|
|
b := new(strings.Builder)
|
|
b.WriteString("(function(exports) {\n")
|
|
if _, err := io.Copy(b, r); err != nil {
|
|
return nil, err
|
|
}
|
|
b.WriteString("\n})(exports)")
|
|
|
|
prg, err := goja.Parse(name, b.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p, err := goja.CompileAST(prg, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Program{
|
|
Name: name,
|
|
Program: p,
|
|
Priority: getPriority(prg),
|
|
}, nil
|
|
}
|
|
|
|
// getPriority look into a (wrapped) program for the integer value
|
|
// of exports.priority.
|
|
func getPriority(prg *ast.Program) (res int) {
|
|
// The first element is the wrapping function call
|
|
body := prg.Body[0].(*ast.ExpressionStatement).Expression.(*ast.CallExpression).Callee.(*ast.FunctionLiteral)
|
|
|
|
// We loop in reversed order because we want the last
|
|
// defined "exports.priority" and return fast
|
|
for i := len(body.Body.List) - 1; i >= 0; i-- {
|
|
item := body.Body.List[i]
|
|
|
|
xps, ok := item.(*ast.ExpressionStatement)
|
|
if !ok {
|
|
continue
|
|
}
|
|
assign, ok := xps.Expression.(*ast.AssignExpression)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if assign.Operator.String() != "=" {
|
|
continue
|
|
}
|
|
right, ok := assign.Right.(*ast.NumberLiteral)
|
|
if !ok {
|
|
continue
|
|
}
|
|
left, ok := assign.Left.(*ast.DotExpression)
|
|
if !ok || left.Identifier.Name != "priority" {
|
|
continue
|
|
}
|
|
idt, ok := left.Left.(*ast.Identifier)
|
|
if !ok || idt.Name != "exports" {
|
|
continue
|
|
}
|
|
|
|
value, ok := right.Value.(int64)
|
|
if ok {
|
|
return int(value)
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// New creates a new ContentScript instance.
|
|
func New(programs ...*Program) (*Runtime, error) {
|
|
scripts := slices.SortedStableFunc(slices.Values(programs), func(a, b *Program) int {
|
|
if a.Priority != b.Priority {
|
|
return cmp.Compare(a.Priority, b.Priority)
|
|
}
|
|
|
|
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
|
})
|
|
|
|
r := &Runtime{
|
|
Runtime: goja.New(),
|
|
programs: scripts,
|
|
ctx: context.Background(),
|
|
}
|
|
|
|
r.SetFieldNameMapper(goja.TagFieldNameMapper("js", true))
|
|
if err := r.startConsole(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Register utils
|
|
registry.Enable(r.Runtime)
|
|
nodeurl.Enable(r.Runtime)
|
|
|
|
// Register global variables and functions
|
|
if err := registerExported(r); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
func (vm *Runtime) getExports(name string) goja.Value {
|
|
return vm.Get("exports").ToObject(vm.Runtime).Get(name)
|
|
}
|
|
|
|
// AddScript wraps a script into an anonymous function call exposing the
|
|
// "exports" object and adds it to the script list.
|
|
func (vm *Runtime) AddScript(name string, r io.Reader) error {
|
|
p, err := NewProgram(name, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vm.programs = append(vm.programs, p)
|
|
return nil
|
|
}
|
|
|
|
// RunProgram runs a Program instance in the VM and returns its result.
|
|
func (vm *Runtime) RunProgram(p *Program) (goja.Value, error) {
|
|
timer := time.AfterFunc(10*time.Second, func() {
|
|
vm.Interrupt("script runtime timeout")
|
|
vm.ClearInterrupt()
|
|
})
|
|
defer timer.Stop()
|
|
return vm.Runtime.RunProgram(p.Program)
|
|
}
|
|
|
|
func (vm *Runtime) exec(p *Program, fn execFunc) error {
|
|
if err := vm.Set("__name__", p.Name); err != nil {
|
|
return err
|
|
}
|
|
if err := vm.Set("exports", map[string]any{}); err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() {
|
|
vm.GlobalObject().Delete("__name__") //nolint:errcheck
|
|
vm.GlobalObject().Delete("exports") //nolint:errcheck
|
|
}()
|
|
|
|
_, err := vm.RunProgram(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if the current script is active
|
|
if ok, err := vm.isActive(); err != nil {
|
|
return err
|
|
} else if ok {
|
|
if vm.getProcessMessage() != nil {
|
|
c, err := NewHTTPClient(vm, vm.getProcessMessage().Extractor.Client())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = vm.Set("requests", c); err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
vm.GlobalObject().Delete("requests") //nolint:errcheck
|
|
}()
|
|
}
|
|
return fn()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (vm *Runtime) execEach(fn execFunc) error {
|
|
errList := []error{}
|
|
|
|
for _, p := range vm.programs {
|
|
if err := vm.exec(p, fn); err != nil {
|
|
// A failing script only logs its error
|
|
// and let the other scripts carry on.
|
|
errList = append(errList, err)
|
|
vm.GetLogger().Error("content script error", slog.Any("err", err))
|
|
}
|
|
}
|
|
|
|
return errors.Join(errList...)
|
|
}
|
|
|
|
func (vm *Runtime) isActive() (res bool, err error) {
|
|
f := vm.getExports("isActive")
|
|
if f == nil {
|
|
return false, nil
|
|
}
|
|
|
|
var fn isActive
|
|
if err = vm.ExportTo(f, &fn); err != nil {
|
|
return false, err
|
|
}
|
|
return fn()
|
|
}
|
|
|
|
type isActive func() (bool, error)
|
|
|
|
// SetConfig runs every script and calls their respective
|
|
// "setConfig" exported function when it exists.
|
|
// The initial configuration is passed to each function as
|
|
// a pointer and can be modified in place.
|
|
func (vm *Runtime) SetConfig(cf *SiteConfig) error {
|
|
return vm.execEach(func() error {
|
|
f := vm.getExports("setConfig")
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
var fn setConfig
|
|
if err := vm.ExportTo(f, &fn); err != nil {
|
|
return err
|
|
}
|
|
vm.GetLogger().Debug("content script", slog.String("function", "setConfig"))
|
|
return fn(cf)
|
|
})
|
|
}
|
|
|
|
type setConfig func(*SiteConfig) error
|
|
|
|
// ProcessMeta runs every script and calls their respective
|
|
// "processMeta" exported function when it exists.
|
|
func (vm *Runtime) ProcessMeta() error {
|
|
return vm.execEach(func() error {
|
|
f := vm.getExports("processMeta")
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
var fn processMeta
|
|
if err := vm.ExportTo(f, &fn); err != nil {
|
|
return err
|
|
}
|
|
vm.GetLogger().Debug("content script", slog.String("function", "processMeta"))
|
|
return fn()
|
|
})
|
|
}
|
|
|
|
// ProcessDom runs every script and calls the given exported funcName
|
|
// with the [html.Node] as its only argument.
|
|
func (vm *Runtime) ProcessDom(funcName string, node *html.Node) error {
|
|
m := newDomModule(vm.Runtime)
|
|
|
|
return vm.execEach(func() error {
|
|
f := vm.getExports(funcName)
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
var fn func(goja.Value) error
|
|
if err := vm.ExportTo(f, &fn); err != nil {
|
|
return err
|
|
}
|
|
vm.GetLogger().Debug("content script", slog.String("function", funcName))
|
|
return fn(m.newNodeValue(node))
|
|
})
|
|
}
|
|
|
|
type processMeta func() error
|