Skip to main content

Topic e Target nello schema.org: DefinedTerm e Audience nel grafo di Yoast

Ogni pagina che pubblichi dice qualcosa, ma raramente dichiara di cosa parla e a chi si rivolge. Se il tuo sito classifica già i contenuti per Topic e per Target, quel dato può diventare due proprietà schema.org, about e audience, che puntano a nodi veri del grafo invece che a stringhe. In questo tutorial le aggiungiamo al grafo di Yoast senza sostituirlo: un filtro, sei funzioni, nessun nodo duplicato.

In questo articolo parlo di:Dati strutturati & AEO, Bebop Content Model
Questo articolo è scritto pensando a:SEO Specialist, Developers

Scegli una formula per gli @id

Step

Prima del codice, una decisione: come si chiama ogni nodo. Nel grafo un’entità esiste una volta sola e tutti gli altri nodi la richiamano per @id. Se lo stesso Topic compare su cento articoli, deve avere lo stesso @id su tutti e cento, altrimenti per chi legge il grafo sono cento entità diverse con lo stesso nome.

La formula che uso è questa: l’URL della pillar del termine, se esiste ed è pubblicata, altrimenti la home, più un frammento con tassonomia e slug. L’@id così è stabile, leggibile e ancorato alla pagina che di quel termine parla davvero.

Crea un file bcm-schema.php in wp-content/mu-plugins/ e parti da qui:

<?php
/**
 * Plugin Name: BCM Schema – Topic e Target nel grafo di Yoast
 * Description: Aggiunge i Topic come DefinedTerm (about) e i Target come Audience (audience).
 */

if (!defined('ABSPATH')) {
  exit;
}

function bcm_pillar_url(WP_Term $term): ?string {
  $pillar_id = (int) get_term_meta($term->term_id, 'bcm_pillar_page', true);

  if ($pillar_id && get_post_status($pillar_id) === 'publish') {
    return get_permalink($pillar_id);
  }

  return null;
}

function bcm_term_node_id(WP_Term $term): string {
  $base = bcm_pillar_url($term) ?? home_url('/');

  return $base . '#bcm-' . $term->taxonomy . '-' . $term->slug;
}

Un @id non è un URL da visitare: è un nome. Per questo il frammento non deve per forza esistere come ancora nella pagina.

Trasforma un Topic in un nodo DefinedTerm

Step

Aggiungi una funzione che legge un meta di termine con un valore per riga:

function bcm_meta_lines(WP_Term $term, string $key): array {
  $raw = (string) get_term_meta($term->term_id, $key, true);
  $lines = array_map('trim', explode("\n", $raw));

  return array_values(array_filter($lines));
}
Step

Costruisci il nodo DefinedTerm: nome, pillar come url, descrizione in testo semplice, nomi alternativi e sameAs solo se ci sono:

function bcm_defined_term_node(WP_Term $term): array {
  $node = [
    '@type' => 'DefinedTerm',
    '@id' => bcm_term_node_id($term),
    'name' => $term->name,
  ];

  $url = bcm_pillar_url($term);
  if ($url) {
    $node['url'] = $url;
  }

  $description = trim(wp_strip_all_tags($term->description));
  if ($description !== '') {
    $node['description'] = $description;
  }

  $alternate_names = bcm_meta_lines($term, 'bcm_alternate_names');
  if ($alternate_names) {
    $node['alternateName'] = $alternate_names;
  }

  $same_as = array_values(array_filter(array_map('esc_url_raw', bcm_meta_lines($term, 'bcm_same_as'))));
  if ($same_as) {
    $node['sameAs'] = $same_as;
  }

  return $node;
}
Tip

DefinedTerm è il tipo pensato per i termini di una classificazione: per schema.org è “una parola, un nome, una frase con una definizione formale”, usato spesso per classificare per categoria o soggetto. È esattamente quello che fa un Topic.

Tip

Usa sameAs solo con URL che hai verificato uno per uno (Wikipedia, Wikidata). Un sameAs sbagliato dichiara che il tuo Topic è un’altra cosa.

Trasforma un Target in un nodo Audience

Step

Il Target diventa un nodo Audience. Stessa formula per l’@id, un audienceType testuale (se il termine non ne ha uno, il nome) e la descrizione.

function bcm_audience_node(WP_Term $term): array {
  $audience_type = trim((string) get_term_meta($term->term_id, 'bcm_audience_type', true));

  $node = [
    '@type' => 'Audience',
    '@id' => bcm_term_node_id($term),
    'name' => $term->name,
    'audienceType' => $audience_type !== '' ? $audience_type : $term->name,
  ];

  $description = trim(wp_strip_all_tags($term->description));
  if ($description !== '') {
    $node['description'] = $description;
  }

  return $node;
}

Perché Audience e non un sottotipo più ricco come PeopleAudience o BusinessAudience? Perché i miei Target sono ruoli professionali, non segmenti demografici. Un PeopleAudience chiede età e genere, un BusinessAudience dipendenti e fatturato: compilarli avrebbe significato inventare dati. Il tipo più onesto è quello generico.

Raccogli i termini e i loro antenati

Step

Un contenuto classificato su un Cluster parla anche del suo Topic; uno scritto per un End User è scritto anche per la sua Persona. Per questo, oltre ai termini assegnati, raccogli anche i loro genitori, senza duplicati:

function bcm_post_terms(int $post_id, string $taxonomy): array {
  $terms = get_the_terms($post_id, $taxonomy);
  if (!is_array($terms)) {
    return [];
  }

  $all = [];
  foreach ($terms as $term) {
    $all[$term->term_id] = $term;

    foreach (get_ancestors($term->term_id, $taxonomy, 'taxonomy') as $ancestor_id) {
      $ancestor = get_term($ancestor_id, $taxonomy);
      if ($ancestor instanceof WP_Term) {
        $all[$ancestor->term_id] = $ancestor;
      }
    }
  }

  return array_values($all);
}

get_the_terms() restituisce false o un WP_Error quando non c’è niente: il controllo is_array() copre entrambi i casi.

Trova il nodo giusto su cui scrivere

Step

about e audience vanno sul nodo che rappresenta il contenuto (l’Article, l’HowTo, l’Event), non sulla pagina che lo contiene. Solo se il contenuto non ha un nodo suo, si ripiega sulla WebPage. Attenzione al fatto che @type può essere una stringa o un array:

function bcm_content_node_key(array $graph) {
  $preferred = ['Article', 'BlogPosting', 'NewsArticle', 'TechArticle', 'HowTo', 'Event'];
  $fallback = null;

  foreach ($graph as $key => $node) {
    $types = (array) ($node['@type'] ?? []);

    if (array_intersect($types, $preferred)) {
      return $key;
    }

    if ($fallback === null && in_array('WebPage', $types, true)) {
      $fallback = $key;
    }
  }

  return $fallback;
}

L’elenco non è casuale: sono tipi che su schema.org ammettono sia about sia audience. Li ho verificati sulle pagine delle due proprietà, non a memoria (vedi gli errori comuni).

Poi una funzione che aggiunge i riferimenti a una proprietà senza cancellare quelli che c’erano già e senza ripeterli:

function bcm_merge_refs(array $node, string $property, array $refs): array {
  $current = $node[$property] ?? [];
  if (isset($current['@id'])) {
    $current = [$current];
  }

  $ids = array_column((array) $current, '@id');
  foreach ($refs as $ref) {
    if (!in_array($ref['@id'], $ids, true)) {
      $current[] = $ref;
      $ids[] = $ref['@id'];
    }
  }

  $node[$property] = $current;

  return $node;
}

Aggancia il grafo di Yoast

Step

Registra la funzione sul filtro wpseo_schema_graph, che riceve l’intero grafo già assemblato e il contesto della pagina. È il punto che la documentazione di Yoast indica per le modifiche che toccano più nodi insieme.

Step

Nella funzione: esci se la pagina non è un contenuto singolo, costruisci i nodi, aggiungili al grafo solo se non ci sono già, e scrivi su about e audience solo riferimenti {"@id": …}:

add_filter('wpseo_schema_graph', 'bcm_add_coordinates_to_graph', 30, 2);

function bcm_add_coordinates_to_graph(array $graph, $context): array {
  if (!isset($context->indexable) || $context->indexable->object_type !== 'post') {
    return $graph;
  }

  $post_id = (int) $context->indexable->object_id;
  $groups = [
    'about' => array_map('bcm_defined_term_node', bcm_post_terms($post_id, 'bcm_topic')),
    'audience' => array_map('bcm_audience_node', bcm_post_terms($post_id, 'bcm_target')),
  ];

  if (!$groups['about'] && !$groups['audience']) {
    return $graph;
  }

  $key = bcm_content_node_key($graph);
  if ($key === null) {
    return $graph;
  }

  $existing = array_column($graph, '@id');

  foreach ($groups as $property => $nodes) {
    $refs = [];

    foreach ($nodes as $node) {
      $refs[] = ['@id' => $node['@id']];

      if (!in_array($node['@id'], $existing, true)) {
        $graph[] = $node;
        $existing[] = $node['@id'];
      }
    }

    if ($refs) {
      $graph[$key] = bcm_merge_refs($graph[$key], $property, $refs);
    }
  }

  return $graph;
}
Tip

La priorità è 30, non 10, ed è voluto. Se il nodo del contenuto (un HowTo, un Event) lo aggiunge un altro plugin sullo stesso filtro, a parità di priorità vince l’ordine di registrazione: se giri prima tu, quel nodo non esiste ancora e about finisce sulla WebPage. Una priorità successiva rende l’ordine una scelta, non una fortuna.

Tip

Il grafo di Yoast si estende, non si sostituisce. Niente JSON-LD parallelo in un secondo <script>: un solo grafo, nodi collegati per @id.

Verifica

  1. Leggi il grafo in chiaro. Durante i test attiva la modalità sviluppo di Yoast, che stampa lo schema indentato: add_filter('yoast_seo_development_mode', '__return_true');. Toglila in produzione, come raccomanda la documentazione.
  2. Cerca i nodi. Nel JSON-LD di un contenuto classificato devi trovare un DefinedTerm per ogni Topic (e per i suoi antenati), un Audience per ogni Target, e sul nodo del contenuto about e audience fatti solo di @id.
  3. Controlla che ogni riferimento abbia un nodo. Ogni @id in about e audience deve comparire come @id di un nodo nel grafo. Un riferimento senza nodo è una freccia che non punta a niente.
  4. Passalo al Schema Markup Validator. È lo strumento giusto per tipi come DefinedTerm e Audience, che non generano risultati arricchiti su Google: il validatore li legge e ti segnala proprietà non ammesse sul tipo.

Così appare su questo sito, in un articolo della serie (estratto; qui il prefisso degli @id è quello del mio plugin, ccm):

{
  "@type": "Article",
  "about": [
    { "@id": "https://bebopcreative.it/bebop-content-model/#ccm-ccm_topic-bebop-content-model" },
    { "@id": "https://bebopcreative.it/dore-angelo/full-stack-developer/#ccm-ccm_topic-sviluppo-web" }
  ],
  "audience": [
    { "@id": "https://bebopcreative.it/#ccm-ccm_target-professionisti-del-digitale-developers" },
    { "@id": "https://bebopcreative.it/#ccm-ccm_target-professionisti-del-digitale" }
  ]
}
{
  "@type": "DefinedTerm",
  "@id": "https://bebopcreative.it/dore-angelo/full-stack-developer/#ccm-ccm_topic-sviluppo-web",
  "name": "Sviluppo Web",
  "url": "https://bebopcreative.it/dore-angelo/full-stack-developer/",
  "alternateName": ["Sviluppo Web", "Web Development", "WordPress Development"],
  "sameAs": ["https://it.wikipedia.org/wiki/Programmazione_web"]
}

Varianti ed errori comuni

Varianti

  • Sulla home: invece di un contenuto, è l’intero sito a parlare di qualcosa. Raccogli i Topic radice in un DefinedTermSet e metti about e audience sul nodo WebSite: è l’impronta tematica e di pubblico del dominio.
  • Su Person e Organization: i tipi non ammettono about né audience. L’equivalente giusto è knowsAbout, con gli stessi @id dei Topic.
  • Un Cluster senza pillar: con questa formula il suo @id si appoggia alla home. Se preferisci, risali al genitore e usa la sua pillar.

Errori comuni

  • Nodi inline invece di riferimenti. Scrivere in about un DefinedTerm completo ({"@type": "DefinedTerm", "name": …}) invece di {"@id": …} duplica l’entità: il grafo in quel punto si spezza, perché lo stesso Topic diventa due cose. Su questo sito è successo con knowsAbout, e l’ho visto leggendo il JSON-LD della home.
  • about finito sulla WebPage. È il problema della priorità del passo 6: il nodo del contenuto non esisteva ancora quando il filtro è girato.
  • Proprietà su tipi che non le ammettono. Su schema.org V30.1 (verificato il 01/10/2026) audience è ammessa su CreativeWork, Event, LodgingBusiness, PlayAction, Product e Service; about su Certification, CommunicateAction, CreativeWork, DefinedTerm, DefinedTermSet ed Event. Quindi niente about su Service, Person, Organization, JobPosting. Controlla sempre le pagine delle proprietà: il vocabolario cambia.
  • Dati inventati per riempire un sottotipo. PeopleAudience con età e genere “stimati” non è più preciso: è falso.
  • Un @id che punta a un URL vecchio. Se la pillar cambia URL, cambiano anche gli @id dei suoi termini, e Yoast può conservare il vecchio permalink nella sua cache degli indexable. Dopo un cambio di URL, ricontrolla il grafo.

Cosa mi porto via

Il grafo di Yoast mi ha insegnato a lavorare per aggiunta. Non serve riscrivere lo schema di un sito per farlo parlare meglio: basta dare un nome stabile alle cose di cui parla e farle richiamare da tutti i nodi che le riguardano.

E mi ha insegnato a non fidarmi della memoria. Le volte in cui il grafo si è rotto, su questo sito, me ne sono accorto leggendo il JSON-LD, non scrivendo il codice: un nodo che non era la main entity della sua pagina, un Topic duplicato invece che richiamato. Quali proprietà ammette un tipo lo decide schema.org, non quello che ricordo.

E tu, l’ultima volta che hai aggiunto dello schema, l’hai letto dopo averlo pubblicato?

Nel prossimo pezzo della serie la stessa coordinata esce dal grafo ed entra nel dataLayer: Topic, Target e Stage come parametri di ogni evento. Intanto, se vuoi il quadro della SEO tecnica come la intendo io, parti dalla pagina SEO Tecnica. Se il tuo PM ti chiede perché tutto questo conviene al progetto, giragli questo articolo.

© 2026 Angelo Giovanni Dore • Contenuti rilasciati con licenza CC BY 4.0
I contenuti hanno finalità divulgative e riflettono esperienze e opinioni personali.
Pur cercando di mantenerli accurati e aggiornati, non costituiscono consulenza professionale.