feat(schema): v0.6.0 — PSA backfill migration + Yoast-integrated Drug/MedicalWebPage/FAQ schema
Piece 1 — Backfill migration 0004: copies PSA psa_molecular_formula/weight/aliases
into PR Core _pr_* meta. Idempotent + re-runnable. PubChem REST fallback for 4
posts missing PSA data (MAX_RETRIES=3, exp backoff). All PSA values UNTRUSTED.
PR_CORE_TARGET_SCHEMA_VERSION bumped 3 -> 4.
Piece 2 — Drug node enrichment + stable @id = {permalink}#drug (ratified contract).
Reads _pr_* schema-input meta. molecularWeight in g/mol. Empty-prop guard throughout.
Piece 3 — Yoast-integrated emission via wpseo_schema_graph_pieces (priority 11),
wpseo_schema_webpage_type (MedicalWebPage retype), wpseo_schema_graph (enrichment).
Standalone @graph fallback retained. No duplicate WebPage/BreadcrumbList.
FAQPage emitted only when _pr_faq_items populated.
Supporting: PR_Core_Schema_Sanitizers, PR_Core_Pubchem_Client, CONTEXT.md,
ARCHITECTURE.md, CONVENTIONS.md, CHANGELOG.md, uninstall.php purge.
Tests: test-migration-0004.php (20 assertions), test-jsonld.php (29 assertions).
Agent-Session: peptide-schema-backfill v0.6.0
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Migration 0004: Backfill peptide schema meta from PSA psa_* keys.
|
||||
*
|
||||
* What: Copies PSA-stored PubChem data (molecular formula, weight, aliases)
|
||||
* into PR Core's own _pr_molecular_formula, _pr_molecular_weight,
|
||||
* _pr_aliases meta keys on all published peptide posts.
|
||||
* For posts missing PSA data, attempts a PubChem REST lookup via
|
||||
* PR_Core_Pubchem_Client.
|
||||
*
|
||||
* Who calls it: PR_Core_Migration_Runner::run_pending().
|
||||
* Dependencies: WordPress get_post_meta(), update_post_meta(), get_posts().
|
||||
* PR_Core_Pubchem_Client for PubChem fallback.
|
||||
* PR_Core_Schema_Sanitizers for formula sanitization.
|
||||
* PR_Core_Peptide_CPT::POST_TYPE for query.
|
||||
*
|
||||
* Idempotent: skips posts where all three PR Core meta keys are already non-empty.
|
||||
* Re-runnable: safe to run multiple times.
|
||||
*
|
||||
* PSA source keys (all treated as UNTRUSTED — sanitized before writing):
|
||||
* psa_molecular_formula => _pr_molecular_formula (string)
|
||||
* psa_molecular_weight => _pr_molecular_weight (float string, strip "Da")
|
||||
* psa_aliases => _pr_aliases (JSON array string)
|
||||
*
|
||||
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
|
||||
* @see CONVENTIONS.md — How To: Add a New Migration.
|
||||
* @see class-pr-core-pubchem-client.php — HTTP client for PubChem REST.
|
||||
*/
|
||||
class PR_Core_Migration_0004_Backfill_Peptide_Meta {
|
||||
|
||||
/** @var string Meta key: molecular formula (PR Core namespace). */
|
||||
public const META_FORMULA = '_pr_molecular_formula';
|
||||
|
||||
/** @var string Meta key: molecular weight as float string (PR Core namespace). */
|
||||
public const META_WEIGHT = '_pr_molecular_weight';
|
||||
|
||||
/** @var string Meta key: aliases JSON array (PR Core namespace). */
|
||||
public const META_ALIASES = '_pr_aliases';
|
||||
|
||||
/** @var PR_Core_Pubchem_Client HTTP client for PubChem REST API. */
|
||||
private PR_Core_Pubchem_Client $pubchem;
|
||||
|
||||
/**
|
||||
* Construct migration with optional PubChem client (injectable for testing).
|
||||
*
|
||||
* @param PR_Core_Pubchem_Client|null $pubchem PubChem client; creates default if null.
|
||||
*/
|
||||
public function __construct( ?PR_Core_Pubchem_Client $pubchem = null ) {
|
||||
$this->pubchem = $pubchem ?? new PR_Core_Pubchem_Client();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the backfill migration.
|
||||
*
|
||||
* Side effects: reads post meta, writes post meta, logs via error_log.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void {
|
||||
$peptide_ids = $this->get_all_peptide_ids();
|
||||
|
||||
if ( empty( $peptide_ids ) ) {
|
||||
error_log( '[PR Core Migration 0004] No peptide posts found — nothing to backfill.' );
|
||||
return;
|
||||
}
|
||||
|
||||
$counts = [ 'skipped' => 0, 'copied_psa' => 0, 'pubchem_ok' => 0, 'pubchem_skip' => 0 ];
|
||||
|
||||
foreach ( $peptide_ids as $post_id ) {
|
||||
$result = $this->backfill_post( $post_id );
|
||||
$counts[ $result ] += 1;
|
||||
}
|
||||
|
||||
error_log( sprintf(
|
||||
'[PR Core Migration 0004] Complete. Skipped: %d, PSA-copied: %d, PubChem-ok: %d, PubChem-skip: %d',
|
||||
$counts['skipped'],
|
||||
$counts['copied_psa'],
|
||||
$counts['pubchem_ok'],
|
||||
$counts['pubchem_skip']
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill meta for a single peptide post.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return string Result: skipped|copied_psa|pubchem_ok|pubchem_skip.
|
||||
*/
|
||||
private function backfill_post( int $post_id ): string {
|
||||
$existing_formula = (string) get_post_meta( $post_id, self::META_FORMULA, true );
|
||||
$existing_weight = (string) get_post_meta( $post_id, self::META_WEIGHT, true );
|
||||
$existing_aliases = (string) get_post_meta( $post_id, self::META_ALIASES, true );
|
||||
|
||||
if ( '' !== $existing_formula && '' !== $existing_weight && '' !== $existing_aliases ) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
$psa_formula = (string) get_post_meta( $post_id, 'psa_molecular_formula', true );
|
||||
|
||||
if ( '' !== $psa_formula ) {
|
||||
$psa_weight = (string) get_post_meta( $post_id, 'psa_molecular_weight', true );
|
||||
$psa_aliases = (string) get_post_meta( $post_id, 'psa_aliases', true );
|
||||
$this->write_meta_from_psa( $post_id, $psa_formula, $psa_weight, $psa_aliases );
|
||||
return 'copied_psa';
|
||||
}
|
||||
|
||||
// No PSA data — attempt PubChem REST lookup.
|
||||
$psa_cid = (string) get_post_meta( $post_id, 'psa_pubchem_cid', true );
|
||||
$post = get_post( $post_id );
|
||||
$name = $post ? sanitize_text_field( $post->post_title ) : '';
|
||||
|
||||
$data = ( '' !== $psa_cid && ctype_digit( $psa_cid ) )
|
||||
? $this->pubchem->fetch_by_cid( $psa_cid )
|
||||
: null;
|
||||
|
||||
if ( null === $data && '' !== $name ) {
|
||||
$data = $this->pubchem->fetch_by_name( $name );
|
||||
}
|
||||
|
||||
if ( null === $data ) {
|
||||
error_log( sprintf(
|
||||
'[PR Core Migration 0004] Post ID %d (%s): PubChem returned no data — leaving meta empty.',
|
||||
$post_id,
|
||||
esc_html( $name )
|
||||
) );
|
||||
return 'pubchem_skip';
|
||||
}
|
||||
|
||||
$formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $data['formula'] );
|
||||
$weight = (float) $data['weight'];
|
||||
$aliases_json = $this->synonyms_to_json( $data['synonyms'] ?? [] );
|
||||
$this->write_pr_meta( $post_id, $formula, $weight, $aliases_json );
|
||||
|
||||
error_log( sprintf(
|
||||
'[PR Core Migration 0004] Post ID %d (%s): PubChem-sourced formula=%s weight=%s',
|
||||
$post_id,
|
||||
esc_html( $name ),
|
||||
$formula,
|
||||
(string) $weight
|
||||
) );
|
||||
|
||||
return 'pubchem_ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write PR Core meta from PSA source values.
|
||||
*
|
||||
* Input is UNTRUSTED — sanitizes before writing.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @param string $formula Raw psa_molecular_formula value.
|
||||
* @param string $weight Raw psa_molecular_weight value (may include " Da").
|
||||
* @param string $aliases Raw psa_aliases value (comma-separated).
|
||||
* @return void
|
||||
*/
|
||||
private function write_meta_from_psa( int $post_id, string $formula, string $weight, string $aliases ): void {
|
||||
$clean_formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $formula );
|
||||
$clean_weight = $this->parse_weight( $weight );
|
||||
$clean_aliases = $this->parse_aliases_string( $aliases );
|
||||
$this->write_pr_meta( $post_id, $clean_formula, $clean_weight, $clean_aliases );
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the three PR Core meta keys for a post.
|
||||
*
|
||||
* Only writes a field when it has a usable value to avoid overwriting
|
||||
* existing data with empty placeholders.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @param string $formula Sanitized formula (empty = skip).
|
||||
* @param float $weight Parsed weight in g/mol (0 = skip).
|
||||
* @param string $aliases JSON array string (empty array = skip).
|
||||
* @return void
|
||||
*/
|
||||
private function write_pr_meta( int $post_id, string $formula, float $weight, string $aliases ): void {
|
||||
if ( '' !== $formula ) {
|
||||
update_post_meta( $post_id, self::META_FORMULA, $formula );
|
||||
}
|
||||
|
||||
if ( $weight > 0.0 ) {
|
||||
update_post_meta( $post_id, self::META_WEIGHT, (string) $weight );
|
||||
} elseif ( '' !== $formula ) {
|
||||
error_log( '[PR Core Migration 0004] Post ID ' . $post_id . ': weight parsed to 0 — check source.' );
|
||||
}
|
||||
|
||||
$decoded = json_decode( $aliases, true );
|
||||
if ( is_array( $decoded ) && count( $decoded ) > 0 ) {
|
||||
update_post_meta( $post_id, self::META_ALIASES, $aliases );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a molecular weight string to a float.
|
||||
*
|
||||
* Strips trailing " Da" suffix (case-insensitive) and whitespace.
|
||||
* Returns 0.0 on parse failure.
|
||||
*
|
||||
* @param string $value Raw weight value (e.g., "1419.5 Da").
|
||||
* @return float Parsed weight in g/mol, 0.0 on failure.
|
||||
*/
|
||||
public function parse_weight( string $value ): float {
|
||||
$clean = trim( preg_replace( '/\s*[Dd][Aa]\s*$/', '', trim( $value ) ) ?? '' );
|
||||
return (float) $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma-separated alias string into a sanitized JSON array string.
|
||||
*
|
||||
* Input is UNTRUSTED — each alias is sanitized individually.
|
||||
*
|
||||
* @param string $value Comma-separated aliases.
|
||||
* @return string JSON array of sanitized strings, or '[]' if empty.
|
||||
*/
|
||||
public function parse_aliases_string( string $value ): string {
|
||||
if ( '' === trim( $value ) ) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ( explode( ',', $value ) as $part ) {
|
||||
$alias = sanitize_text_field( trim( $part ) );
|
||||
if ( '' !== $alias ) {
|
||||
$clean[] = $alias;
|
||||
}
|
||||
}
|
||||
|
||||
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw synonyms array from PubChem to a JSON string.
|
||||
*
|
||||
* Sanitizes each synonym. Returns '[]' on empty input.
|
||||
*
|
||||
* @param string[] $synonyms Raw synonym strings from PubChem.
|
||||
* @return string JSON array of sanitized strings.
|
||||
*/
|
||||
private function synonyms_to_json( array $synonyms ): string {
|
||||
$clean = [];
|
||||
foreach ( $synonyms as $syn ) {
|
||||
$alias = sanitize_text_field( (string) $syn );
|
||||
if ( '' !== $alias ) {
|
||||
$clean[] = $alias;
|
||||
}
|
||||
}
|
||||
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get IDs of all published peptide posts.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function get_all_peptide_ids(): array {
|
||||
$posts = get_posts( [
|
||||
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => -1,
|
||||
'fields' => 'ids',
|
||||
] );
|
||||
return array_map( 'intval', is_array( $posts ) ? $posts : [] );
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class PR_Core_Migration_Runner {
|
||||
'PR_Core_Migration_0001_Dosing_Rows',
|
||||
'PR_Core_Migration_0002_Legal_Cells',
|
||||
'PR_Core_Migration_0003_Candidate_Queue',
|
||||
'PR_Core_Migration_0004_Backfill_Peptide_Meta',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
208
includes/migrations/class-pr-core-pubchem-client.php
Normal file
208
includes/migrations/class-pr-core-pubchem-client.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Lightweight PubChem REST client for the backfill migration.
|
||||
*
|
||||
* What: Fetches molecular formula, molecular weight, and synonyms from the
|
||||
* PubChem REST API for a given compound CID or name. Used exclusively
|
||||
* by migration 0004 for the 4 peptide posts that lack PSA-sourced data.
|
||||
*
|
||||
* Who calls it: PR_Core_Migration_0004_Backfill_Peptide_Meta::fetch_pubchem().
|
||||
* Dependencies: WordPress wp_remote_get() when available; falls back to
|
||||
* file_get_contents with stream context (WP-CLI context).
|
||||
*
|
||||
* All values from PubChem are returned unsanitized — callers must sanitize
|
||||
* before writing to post_meta.
|
||||
*
|
||||
* @see migrations/class-pr-core-migration-0004-backfill-peptide-meta.php — Caller.
|
||||
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
|
||||
*/
|
||||
class PR_Core_Pubchem_Client {
|
||||
|
||||
/** @var string PubChem REST base URL. */
|
||||
private const BASE = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug';
|
||||
|
||||
/** @var int HTTP timeout per request (seconds). */
|
||||
private const TIMEOUT = 8;
|
||||
|
||||
/** @var int Maximum retry attempts with exponential backoff. */
|
||||
private const MAX_RETRIES = 3;
|
||||
|
||||
/** @var int Maximum synonyms to return (keeps post-meta size reasonable). */
|
||||
private const MAX_SYNONYMS = 10;
|
||||
|
||||
/**
|
||||
* Fetch properties and synonyms by PubChem CID.
|
||||
*
|
||||
* @param string $cid Numeric PubChem CID string.
|
||||
* @return array{formula: string, weight: float, synonyms: string[]}|null Null on failure.
|
||||
*/
|
||||
public function fetch_by_cid( string $cid ): ?array {
|
||||
if ( '' === $cid || ! ctype_digit( $cid ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$props = $this->fetch_properties( 'cid', $cid );
|
||||
if ( null === $props ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$props['synonyms'] = $this->fetch_synonyms( 'cid', $cid );
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch properties and synonyms by compound name.
|
||||
*
|
||||
* @param string $name Compound name (e.g., peptide post title).
|
||||
* @return array{formula: string, weight: float, synonyms: string[]}|null Null on failure.
|
||||
*/
|
||||
public function fetch_by_name( string $name ): ?array {
|
||||
if ( '' === trim( $name ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$props = $this->fetch_properties( 'name', $name );
|
||||
if ( null === $props ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If a CID was returned in the property response, use it for synonyms (more reliable).
|
||||
$cid = $props['cid'] ?? '';
|
||||
$type = ( '' !== $cid ) ? 'cid' : 'name';
|
||||
$ref = ( '' !== $cid ) ? $cid : $name;
|
||||
|
||||
$props['synonyms'] = $this->fetch_synonyms( $type, $ref );
|
||||
unset( $props['cid'] );
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch MolecularFormula and MolecularWeight from PubChem.
|
||||
*
|
||||
* @param string $lookup_type 'cid' or 'name'.
|
||||
* @param string $identifier CID number or compound name.
|
||||
* @return array{formula: string, weight: float, cid: string}|null
|
||||
*/
|
||||
private function fetch_properties( string $lookup_type, string $identifier ): ?array {
|
||||
$url = self::BASE . '/compound/' . $lookup_type . '/' . rawurlencode( $identifier )
|
||||
. '/property/MolecularFormula,MolecularWeight/JSON';
|
||||
$body = $this->get_with_retry( $url );
|
||||
|
||||
if ( null === $body ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode( $body, true );
|
||||
$props = $data['PropertyTable']['Properties'][0] ?? null;
|
||||
|
||||
if ( ! is_array( $props ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$formula = (string) ( $props['MolecularFormula'] ?? '' );
|
||||
$weight = (float) ( $props['MolecularWeight'] ?? 0 );
|
||||
$cid = (string) ( $props['CID'] ?? '' );
|
||||
|
||||
if ( '' === $formula || $weight <= 0.0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'formula' => $formula,
|
||||
'weight' => $weight,
|
||||
'cid' => $cid,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch synonyms for a compound, limited to MAX_SYNONYMS.
|
||||
*
|
||||
* Returns an empty array on failure — callers treat this gracefully.
|
||||
*
|
||||
* @param string $lookup_type 'cid' or 'name'.
|
||||
* @param string $identifier CID or name value.
|
||||
* @return string[] Raw synonym strings (unsanitized).
|
||||
*/
|
||||
private function fetch_synonyms( string $lookup_type, string $identifier ): array {
|
||||
$url = self::BASE . '/compound/' . $lookup_type . '/' . rawurlencode( $identifier ) . '/synonyms/JSON';
|
||||
$body = $this->get_with_retry( $url );
|
||||
|
||||
if ( null === $body ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode( $body, true );
|
||||
$synonyms = $data['InformationList']['Information'][0]['Synonym'] ?? [];
|
||||
|
||||
if ( ! is_array( $synonyms ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice( $synonyms, 0, self::MAX_SYNONYMS );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request with exponential-backoff retry (up to MAX_RETRIES).
|
||||
*
|
||||
* @param string $url URL to fetch.
|
||||
* @return string|null Response body on HTTP 200, null after all retries fail.
|
||||
*/
|
||||
private function get_with_retry( string $url ): ?string {
|
||||
for ( $attempt = 0; $attempt < self::MAX_RETRIES; $attempt++ ) {
|
||||
if ( $attempt > 0 ) {
|
||||
sleep( (int) pow( 2, $attempt - 1 ) ); // 1s, 2s.
|
||||
}
|
||||
|
||||
$body = $this->get_once( $url );
|
||||
if ( null !== $body ) {
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
|
||||
error_log( '[PR Core PubChem] Request failed after ' . self::MAX_RETRIES . ' attempts: ' . $url );
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single HTTP GET attempt.
|
||||
*
|
||||
* Uses wp_remote_get() when available; falls back to file_get_contents
|
||||
* for WP-CLI contexts where the HTTP API may not be bootstrapped.
|
||||
*
|
||||
* @param string $url URL to fetch.
|
||||
* @return string|null Body on HTTP 200, null on error or non-200.
|
||||
*/
|
||||
private function get_once( string $url ): ?string {
|
||||
if ( function_exists( 'wp_remote_get' ) ) {
|
||||
$response = wp_remote_get( $url, [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'headers' => [ 'Accept' => 'application/json' ],
|
||||
] );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
return is_string( $body ) ? $body : null;
|
||||
}
|
||||
|
||||
// WP-CLI / plain PHP fallback.
|
||||
$context = stream_context_create( [
|
||||
'http' => [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'header' => "Accept: application/json\r\n",
|
||||
'method' => 'GET',
|
||||
],
|
||||
] );
|
||||
|
||||
$body = @file_get_contents( $url, false, $context );
|
||||
return ( false === $body ) ? null : $body;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user