feat: structure and content fingerprints, lit/library-find-similar

Fingerprint::structure()/content() (E11) and lit/library-find-similar
(indirect, group Divi-Bibliothek): at most 200 posts per call (cursor),
editable entries only (own posts without edit_others_posts), contents
primed in chunks of 50, stops at 200 matches or 5000 compared nodes with
a note; library entries and Theme Builder layouts are never scanned.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 21:13:33 +02:00
co-authored by Claude Opus 5.5
parent 6be2e65432
commit f82964f617
7 changed files with 758 additions and 5 deletions
@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* lit/library-find-similar (spec §5.7, E11): nodes across the site built like a reference node (post_id + id) or a
* library entry (library_id) – "identical" (Fingerprint::content) or "structure" (Fingerprint::structure, other
* values). Scans at most MAX_POSTS entries per call in ID order (cursor), only of Divi-enabled managed types – never
* library entries or Theme Builder layouts, which have their own gates –, only entries the user may edit (query
* restricted to own posts without edit_others_posts, like content-query), no index or meta (F31). Contents come in
* chunks of CONTENT_CHUNK through one primed post cache each (no query per post, bounded memory, as GlobalUsages).
* A call stops after the post that reaches MAX_MATCHES matches or MAX_NODES fingerprinted nodes; `note` says so
* and next_cursor resumes behind it. Needs the library right (canList); a hidden library answers its entries like
* missing IDs (LibraryAccess::item, P9/P12). Only hashes and the matched nodes' labels are returned.
*/
final class LibraryFindSimilarAbility implements Ability
{
public const MAX_POSTS = 200;
public const DEFAULT_POSTS = 100;
public const MAX_MATCHES = 200;
/** At most this many nodes of the reference's type are fingerprinted per call. */
public const MAX_NODES = 5000;
/** Post contents read with one primed cache (bounded memory). */
public const CONTENT_CHUNK = 50;
/** Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them. */
private const NOT_SCANNED = [
DiviLibraryStore::POST_TYPE,
'et_theme_builder',
'et_template',
'et_header_layout',
'et_body_layout',
'et_footer_layout',
];
private const LIVING = ['publish', 'draft', 'pending', 'private', 'future'];
public function __construct(
private LibraryAccess $access,
private DiviPageLoader $loader,
private ContentSchema $schema,
private Parser $parser = new Parser()
) {
}
public function name(): string
{
return 'lit/library-find-similar';
}
public function label(): string
{
return 'Ähnliche Abschnitte finden';
}
public function description(): string
{
return 'Sucht seitenübergreifend Knoten, die wie ein gegebener Knoten (post_id und id aus '
. 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher '
. 'Inhalt (Admin-Labels ausgenommen), "structure" = gleicher Aufbau (Modultypen, Spalten, Presets) mit '
. 'anderen Inhalten. Nur Einträge, die dieses Konto bearbeiten darf; höchstens 200 Einträge je Aufruf '
. '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen ersetzt lit/library-make-global '
. '(library_id, targets).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Vergleichsknotens, z. B. "s1".'],
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Statt post_id und id.'],
'post_type' => ['type' => 'string', 'description' => 'Nur diesen Inhaltstyp durchsuchen.'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_POSTS],
'cursor' => ['type' => 'string'],
],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'reference' => ['type' => 'object'],
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
'scanned' => ['type' => 'integer'],
'next_cursor' => ['type' => ['string', 'null']],
'note' => ['type' => 'string'],
],
'required' => ['reference', 'matches', 'scanned', 'next_cursor'],
];
}
public function annotations(): array
{
return Annotations::read();
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return $this->access->canList() ?? true;
}
public function execute(array $input): mixed
{
$refusal = $this->access->canList();
if ($refusal !== null) {
return $refusal;
}
$reference = $this->reference($input);
if ($reference instanceof WP_Error) {
return $reference;
}
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$types = $this->postTypes($input['post_type'] ?? null);
if ($types instanceof WP_Error) {
return $types;
}
$limit = is_int($input['limit'] ?? null)
? max(1, min(self::MAX_POSTS, $input['limit']))
: self::DEFAULT_POSTS;
$args = [
'post_type' => $types,
'post_status' => self::LIVING,
'posts_per_page' => $limit + 1,
'offset' => $offset,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
'no_found_rows' => true,
'suppress_filters' => false,
];
foreach ($types as $type) {
$own = PostGuard::ownPostsOnly($type);
if ($own !== null) {
$args['author'] = $own;
break;
}
}
$ids = array_values(array_map('intval', (array) get_posts($args)));
$scan = array_slice($ids, 0, $limit);
$matches = [];
$nodes = 0;
$scanned = 0;
foreach (array_chunk($scan, self::CONTENT_CHUNK) as $chunk) {
$fresh = _get_non_cached_ids($chunk, 'posts');
_prime_post_caches($chunk, false, false);
foreach ($chunk as $id) {
if (self::full($matches, $nodes)) {
break;
}
++$scanned;
$post = current_user_can('edit_post', $id) ? get_post($id) : null;
if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::NOT_SCANNED, true)) {
$this->collect($post, $reference, $matches, $nodes);
}
}
foreach ($fresh as $id) {
wp_cache_delete((int) $id, 'posts');
}
if (self::full($matches, $nodes)) {
break;
}
}
$stopped = $scanned < count($scan);
$result = [
'reference' => $reference['info'],
'matches' => $matches,
'scanned' => $scanned,
'next_cursor' => $stopped || count($ids) > $limit ? Cursor::encode($offset + $scanned) : null,
];
if (self::full($matches, $nodes)) {
$result['note'] = sprintf(
'Suche nach %d Einträgen angehalten (höchstens %d Treffer bzw. %d verglichene Knoten je Aufruf); '
. 'der letzte Eintrag kann weitere Treffer enthalten. Weiter mit next_cursor, oder mit post_type '
. 'eingrenzen.',
$scanned,
self::MAX_MATCHES,
self::MAX_NODES
);
}
return $result;
}
/** @param list<array<string, mixed>> $matches */
private static function full(array $matches, int $nodes): bool
{
return count($matches) >= self::MAX_MATCHES || $nodes >= self::MAX_NODES;
}
/**
* @param array<string, mixed> $input
* @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string}|WP_Error
*/
private function reference(array $input): array|WP_Error
{
if (isset($input['library_id'])) {
$item = $this->access->item($input['library_id'], false);
if ($item === null) {
return PostGuard::notFound((int) PostGuard::id($input['library_id']));
}
if ($item instanceof WP_Error) {
return $item;
}
try {
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
} catch (MalformedMarkup) {
$roots = [];
}
if (count($roots) !== 1 || $roots[0]->isOpaque()) {
return Errors::invalidInput(
sprintf('Bibliothekseintrag %d hat nicht genau einen lesbaren Knoten.', (int) $item->ID),
['library_id' => 'Eintrag mit genau einem Abschnitt, einer Zeile oder einem Modul']
);
}
return self::describe($roots[0], null, null, (int) $item->ID);
}
if (! isset($input['post_id'])) {
return Errors::invalidInput('Vergleich fehlt: library_id oder post_id und id angeben.', [
'library_id' => 'oder post_id und id',
]);
}
$page = $this->loader->load($input['post_id'], false);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id);
if ($node === null) {
return DiviErrors::unknownNode($id, $page->post);
}
if ($node->isOpaque()) {
return Errors::invalidInput(
'Der Knoten ist ein globales Modul oder nicht lesbar; nichts wurde gesucht.',
['id' => 'lesbarer Knoten']
);
}
return self::describe($node, (int) $page->post->ID, $id, null);
}
/** @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} */
private static function describe(Block $block, ?int $postId, ?string $id, ?int $libraryId): array
{
$info = array_filter([
'type' => $block->name(),
'structure' => Fingerprint::structure($block),
'content' => Fingerprint::content($block),
'post_id' => $postId,
'id' => $id,
'library_id' => $libraryId,
], static fn (mixed $value): bool => $value !== null);
return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id];
}
/** @return list<string>|WP_Error */
private function postTypes(mixed $postType): array|WP_Error
{
if ($postType !== null) {
$type = in_array($postType, self::NOT_SCANNED, true)
? PostGuard::postType('', $this->schema, 'edit_posts')
: PostGuard::postType($postType, $this->schema, 'edit_posts');
return $type instanceof WP_Error ? $type : [(string) $postType];
}
$types = array_values(array_filter(
array_diff($this->schema->postTypeNames(), self::NOT_SCANNED),
fn (string $type): bool => $this->schema->diviEnabled($type)
));
return $types !== [] ? $types : Errors::notFound(
'Kein Inhaltstyp mit Divi-Builder verfügbar; nichts wurde gesucht.',
'Inhaltstypen mit lit/content-types (direkt: lit-content-types) prüfen.'
);
}
/**
* @param array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} $reference
* @param list<array<string, mixed>> $matches
*/
private function collect(WP_Post $post, array $reference, array &$matches, int &$nodes): void
{
$content = (string) $post->post_content;
if (! str_contains($content, '<!-- wp:divi/')) {
return;
}
try {
$index = NodeIndex::build($this->parser->parse($content));
} catch (MalformedMarkup) {
return;
}
$name = $reference['block']->name();
foreach ($index->entries() as $entry) {
$block = $entry['block'];
$self = (int) $post->ID === $reference['post_id'] && $entry['id'] === $reference['id'];
if ($self || $block->isOpaque() || $block->name() !== $name) {
continue;
}
if (++$nodes > self::MAX_NODES) {
return;
}
if (Fingerprint::structure($block) !== $reference['info']['structure']) {
continue;
}
$matches[] = [
'post_id' => (int) $post->ID,
'title' => (string) $post->post_title,
'status' => (string) $post->post_status,
'type' => (string) $post->post_type,
'id' => $entry['id'],
'label' => NodeSummary::label($block),
'tree_rev' => Rev::of($content),
'match' => Fingerprint::content($block) === $reference['info']['content'] ? 'identical' : 'structure',
];
if (count($matches) >= self::MAX_MATCHES) {
return;
}
}
}
}