Files
peptide-news-plugin/includes/class-peptide-news-pubmed-extractor.php
opencode 7d2f795f99 feat: v2.6.0 3-step AI pipeline, VPS worker, REST worker routes
- 3-step OpenRouter pipeline: Gatekeeper scoring/classification, PubMed+Google URL extraction, Markdown summary generation
- REST worker routes /wp-json/peptide-news/v1/worker/* with X-Peptide-News-Token auth
- Standalone Coolify worker (tools/vps-worker) with Dockerfile + docker-compose
- CLI commands, content extractor, pubmed extractor, google url resolver
- 89 PHPUnit tests passing
2026-08-03 19:20:47 +08:00

132 lines
4.1 KiB
PHP

<?php
declare( strict_types=1 );
/**
* Extracts structured scientific metadata and abstracts from PubMed URLs
* using the official NCBI E-utilities API (efetch.fcgi).
*
* Enforces a mandatory 350ms sleep throttle to comply with NCBI IP rate limits
* (< 3 req/sec without API key, < 10 req/sec with API key).
*
* @since 2.6.0
*/
class Peptide_News_PubMed_Extractor {
/**
* Extract PMID from a PubMed/NCBI URL.
*
* @param string $url The article URL.
* @return string|null The PMID or null if not a PubMed URL.
*/
public static function get_pmid( string $url ): ?string {
if ( preg_match( '/(?:pubmed\.ncbi\.nlm\.nih\.gov|ncbi\.nlm\.nih\.gov\/pubmed)\/(\d+)/i', $url, $matches ) ) {
return $matches[1];
}
return null;
}
/**
* Fetch article metadata and structured abstract via NCBI E-utilities API.
*
* @param string $pmid PubMed article ID.
* @return array<string, string> Extracted data array with 'title', 'abstract', 'authors', 'journal'. Empty array on failure.
*/
public static function fetch_by_pmid( string $pmid ): array {
if ( empty( $pmid ) ) {
return array();
}
// Enforce mandatory 350ms sleep throttle (< 3 req/sec per IP).
usleep( 350000 );
$args = array(
'db' => 'pubmed',
'id' => absint( $pmid ),
'retmode' => 'xml',
'tool' => 'peptide-news-plugin',
'email' => get_option( 'admin_email', 'admin@example.com' ),
);
$api_key = get_option( 'peptide_news_ncbi_api_key', '' );
if ( ! empty( $api_key ) && is_string( $api_key ) ) {
$args['api_key'] = trim( $api_key );
}
$api_url = add_query_arg( $args, 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi' );
$response = wp_remote_get(
$api_url,
array(
'timeout' => 15,
'user-agent' => 'PeptideNewsBot/2.6.0 (+https://peptiderepo.com)',
)
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
Peptide_News_Logger::warning( 'NCBI E-utilities fetch failed for PMID ' . $pmid, 'fetcher' );
return array();
}
$xml_body = wp_remote_retrieve_body( $response );
return self::parse_efetch_xml( (string) $xml_body );
}
/**
* Parse NCBI E-utilities PubMed XML response into structured article data.
*
* @param string $xml_body
* @return array
*/
public static function parse_efetch_xml( string $xml_body ): array {
if ( strlen( $xml_body ) > 512000 ) {
$xml_body = function_exists( 'mb_strcut' ) ? mb_strcut( $xml_body, 0, 512000, 'UTF-8' ) : substr( $xml_body, 0, 512000 );
}
$libxml_prev = libxml_use_internal_errors( true );
$xml = simplexml_load_string( (string) $xml_body, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOERROR );
libxml_clear_errors();
libxml_use_internal_errors( $libxml_prev );
if ( ! $xml || empty( $xml->PubmedArticle ) ) {
return array();
}
$article = $xml->PubmedArticle[0]->MedlineCitation->Article;
$title = (string) $article->ArticleTitle;
$abstract_parts = array();
if ( ! empty( $article->Abstract->AbstractText ) ) {
foreach ( $article->Abstract->AbstractText as $text_node ) {
$label = (string) $text_node['Label'];
$text = trim( (string) $text_node );
if ( ! empty( $label ) && 'UNLABELLED' !== strtoupper( $label ) ) {
$abstract_parts[] = '<strong>' . esc_html( ucfirst( strtolower( $label ) ) ) . ':</strong> ' . esc_html( $text );
} else {
$abstract_parts[] = esc_html( $text );
}
}
}
$abstract = implode( "\n\n", $abstract_parts );
$authors = array();
if ( ! empty( $article->AuthorList->Author ) ) {
foreach ( $article->AuthorList->Author as $author ) {
$name = trim( ((string) $author->ForeName) . ' ' . ((string) $author->LastName) );
if ( ! empty( $name ) ) {
$authors[] = $name;
}
}
}
$journal = '';
if ( ! empty( $article->Journal->Title ) ) {
$journal = (string) $article->Journal->Title;
}
return array(
'title' => sanitize_text_field( $title ),
'abstract' => wp_kses_post( $abstract ),
'authors' => sanitize_text_field( implode( ', ', $authors ) ),
'journal' => sanitize_text_field( $journal ),
);
}
}