Skip to content

Plugins ​

A plugin teaches Ripperoni how to turn one site's HTML or wikitext into typed JSON. Each plugin is a dagonizer node — a ScalarNode subclass (from @studnicky/dagonizer) — that you register on the run's dispatcher. The dispatcher is the object that owns node and DAG registration, routes state through them, and wires outputs to downstream steps. A plugin exports a register function. The runner imports the file, calls register(dispatcher), and your nodes join the run. All wiring is explicit and scoped to the dispatcher the runner passes in.

Plugin contract ​

Every plugin's index.ts (or index.js) must export:

ts
export function register(dispatcher: RipperDagonizer<ScrapeState>): void

register calls dispatcher.registerNode(...) for each node instance. It does not call dispatcher.registerDAG — DAGs come from *.dag.jsonld files in the plugin directory, loaded automatically by PluginLoader at startup.

A missing register export aborts the run with an error at startup.

Plugin discovery ​

PluginLoader.registerPluginsFromEntry walks the orchestration DAG's EmbeddedDAGNode.dag and ScatterNode.body.dag references. For each reference, it derives the namespace (the prefix before :), then:

  1. Loads plugins/<namespace>/index.js and calls register(dispatcher).
  2. Loads all *.dag.jsonld files from plugins/<namespace>/ in registration order (leaves first, via PluginLoader.pluginDagsInRegistrationOrder) and calls dispatcher.registerDAG(dag) for each.

Built-in nodes and DAGs (those with BUILTIN_PREFIXES) are registered first by PluginLoader.registerBuiltinNodes before any plugin is loaded.

Node signature ​

Nodes are ScalarNode subclasses. The subclass declares name and outputs as readonly fields and implements executeOne(state, context), which returns NodeOutputBuilder.of('<port>'). Raw HTML or wikitext goes in via state; your node writes a structured record to state.output before returning.

ts
import { ScalarNode, NodeOutputBuilder } from '@studnicky/dagonizer';
import type { NodeContextType, NodeOutputType } from '@studnicky/dagonizer';
import type { ScrapeState }    from 'ripperoni/state/ScrapeState';
import type { RipperServices } from 'ripperoni/services/RipperServices';
import type { RipperDagonizer } from 'ripperoni/dispatcher/RipperDagonizer';

class MyParseNodeImpl extends ScalarNode<ScrapeState, 'success' | 'error', RipperServices> {
  public readonly name    = 'mysite:parse';
  public readonly outputs = ['success', 'error'] as const;

  protected override async executeOne(
    state:    ScrapeState,
    context:  NodeContextType<RipperServices>,
  ): Promise<NodeOutputType<'success' | 'error'>> {
    const html = state.page.html ?? '';
    if (html.length === 0) return NodeOutputBuilder.of('error');

    // extract...
    state.output = { url: state.page.url, name: 'Example' };
    return NodeOutputBuilder.of('success');
  }
}

export const myParseNode = new MyParseNodeImpl();

export function register(dispatcher: RipperDagonizer<ScrapeState>): void {
  dispatcher.registerNode(myParseNode);
}

Output ports ​

Declare every port the node can return in outputs. The dispatcher validates wiring at registration time and rejects DAGs with un-wired outputs.

Common conventions:

  • success — processed cleanly; downstream write node runs.
  • error — fetch failed, parse failed, or required data absent; item recorded in state.failed.
  • skipped — optional skip (e.g. json:write when state.output is null).
  • valid / invalid — used by validate:schema for schema-pass / schema-fail branching.

In the per-page DAG any non-success port typically routes to a failed terminal and ends processing for that item.

State shape ​

ts
state.page.url       // resolved URL (html targets)
state.page.html      // raw HTML string (populated by html:fetch)
state.page.title     // page title (wiki targets)
state.page.wikitext  // raw wikitext (populated by wiki:fetch)
state.output         // null until your plugin sets it; write nodes read this

state.getMetadata(key) / state.setMetadata(key, value) carries per-item data across nodes within the same fan-out item.

Services ​

context.services carries shared dependencies:

ts
context.services.log             // Logger
context.services.cache           // ScraperCache | null
context.services.htmlScraper     // HtmlScraper | undefined (html targets)
context.services.wikiScraper     // MediaWikiScraper | undefined (wiki targets)
context.services.outDir          // output base directory
context.services.pluginTaskName  // name of first non-built-in pipeline step (optional)
context.services.splitByTaskName // when false, output is a single JSONL (optional)
context.services.dispatcher      // DagonizerInterface for child DAG execution

Cancellation ​

Long-running IO should propagate context.signal:

ts
const res = await fetch(url, { signal: context.signal });

HTML plugin example ​

ts
import { load } from 'cheerio';

import { ScalarNode, NodeOutputBuilder, DAGBuilder } from '@studnicky/dagonizer';
import type { NodeContextType, NodeOutputType } from '@studnicky/dagonizer';
import type { ScrapeState }    from 'ripperoni/state/ScrapeState';
import type { RipperServices } from 'ripperoni/services/RipperServices';
import type { RipperDagonizer } from 'ripperoni/dispatcher/RipperDagonizer';

class MyParseNodeImpl extends ScalarNode<ScrapeState, 'success' | 'error', RipperServices> {
  public readonly name    = 'mysite:parse-impl';
  public readonly outputs = ['success', 'error'] as const;

  protected override async executeOne(
    state:    ScrapeState,
    _context: NodeContextType<RipperServices>,
  ): Promise<NodeOutputType<'success' | 'error'>> {
    const html = state.page.html;
    if (!html) return NodeOutputBuilder.of('error');

    const $ = load(html);
    state.output = {
      url:  state.page.url,
      name: $('h1.title').first().text().trim(),
    };
    return NodeOutputBuilder.of('success');
  }
}

export const myParseNode = new MyParseNodeImpl();

export function register(dispatcher: RipperDagonizer<ScrapeState>): void {
  dispatcher.registerNode(myParseNode);
}

The parse DAG for this node is authored separately with DAGBuilder, serialized, and committed as plugins/mysite/parse.dag.jsonld. The runner loads it from disk.

MediaWiki plugin example ​

ts
import wtf from 'wtf_wikipedia';

import { ScalarNode, NodeOutputBuilder } from '@studnicky/dagonizer';
import type { NodeContextType, NodeOutputType } from '@studnicky/dagonizer';
import type { ScrapeState }    from 'ripperoni/state/ScrapeState';
import type { RipperServices } from 'ripperoni/services/RipperServices';
import type { RipperDagonizer } from 'ripperoni/dispatcher/RipperDagonizer';

class MyWikiNodeImpl extends ScalarNode<ScrapeState, 'success', RipperServices> {
  public readonly name    = 'mywiki:parse-impl';
  public readonly outputs = ['success'] as const;

  protected override async executeOne(
    state:    ScrapeState,
    _context: NodeContextType<RipperServices>,
  ): Promise<NodeOutputType<'success'>> {
    const doc  = wtf(state.page.wikitext ?? '');
    const ibox = doc.infobox()?.json() as Record<string, string> ?? {};
    state.output = {
      name:  ibox['name'] ?? state.page.title,
      level: parseInt(ibox['level'] ?? '', 10) || null,
    };
    return NodeOutputBuilder.of('success');
  }
}

export const myWikiNode = new MyWikiNodeImpl();

export function register(dispatcher: RipperDagonizer<ScrapeState>): void {
  dispatcher.registerNode(myWikiNode);
}

Multi-node plugins ​

Plugins that need multiple nodes register all of them in register:

ts
export function register(dispatcher: RipperDagonizer<ScrapeState>): void {
  dispatcher.registerNode(parseNode);
  dispatcher.registerNode(finalizeNode);
  // DAGs are loaded from *.dag.jsonld files — do not registerDAG here
}

Testing a plugin in isolation ​

Register the node and load its DAG on a Dagonizer instance, then call dispatcher.execute('dag:name', state):

ts
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { Dagonizer } from '@studnicky/dagonizer';
import { ScrapeState } from 'ripperoni/state/ScrapeState';
import { myParseNode } from './parse.task.js';
import myParseDAG from './parse.dag.jsonld' with { type: 'json' };

describe('mysite:parse', () => {
  it('extracts name from h1.title', async () => {
    const dispatcher = new Dagonizer<ScrapeState, undefined>({ services: undefined });
    dispatcher.registerNode(myParseNode);
    dispatcher.registerDAG(myParseDAG);

    const state = new ScrapeState();
    state.page = {
      targetId: 'mysite',
      title: '',
      url: 'https://example.com/page',
      html: '<h1 class="title">Hello</h1>',
    };

    await dispatcher.execute('mysite:parse', state);

    assert.equal(state.output?.['name'], 'Hello');
  });
});

No network, no file system — just the extraction logic running through its node DAG.

Concept taxonomy compiler ​

Plugins that dispatch per-concept use the shared taxonomy compiler in src/taxonomy/. Call Taxonomy.compile(concepts, options) where options: { namespace, pathExtractor } — it returns per-concept capability chains, a routeUrl classifier, chainFor, allNodes, and buildDAG. The router nodes (makeTaxonomyRouter, makeConceptDispatch) live in TaxonomyRouterNodes.ts; extraction strategy interfaces (CommonStrategy, SourceRef, LinkRef, Section) in ExtractionStrategy.ts. Node names are namespaced: <namespace>:taxonomy-route, <namespace>:concept-dispatch, etc.

aonprd and dnd5e are the two current implementations. AONPRD classifies by URL (.aspx path segments encode the concept type). D&D 5e classifies by page content (dandwiki URLs carry no type signal). Both compile from the same module; neither duplicates the other's taxonomy infrastructure.

AONPRD plugin (built-in example) ​

The plugins/aonprd/ directory ships a full-featured example plugin that parses Archives of Nethys (2e.aonprd.com) HTML. It demonstrates URL-based concept dispatch via the taxonomy compiler, shared extraction helpers, per-concept structured output, and fixture-based unit tests. It is the reference implementation for a production-grade plugin.

The entry point plugins/aonprd/index.ts exports register(dispatcher), which iterates all taxonomy-compiled nodes via TAXONOMY.allNodes() and registers them. The aonprd:parse DAG is loaded from plugins/aonprd/parse.dag.jsonld by the runner.

See Architecture for the DAG topology and plugins/aonprd/index.ts for the reference implementation.

D&D 5e plugin ​

plugins/dnd5e/ parses dandwiki.com 5e SRD pages into typed JSON using the same shared taxonomy compiler with content-based classification. See the D&D 5e Scraper DAG for the full walkthrough, typed SpellOutput shape, and direct-call API.

Released under the MIT License.