From 2ebc72342e3a84758213aa3799c89307899f81bc Mon Sep 17 00:00:00 2001 From: Rudrakshi Gupta <109859631+rudrakshi-gupta@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:32:33 +0530 Subject: [PATCH] feat(seo): add key moments to video schema from chapters (#1597) Output each GoDAM video chapter as a schema.org Clip in the VideoObject's hasPart, so Google can show "key moments" (jump-to links) for the video in search results. - Add Seo::get_key_moment_clips(): reads the chapters from the attachment's rtgodam_meta at output time, so editing chapters updates the schema without re-saving the page. - Each Clip has name, startOffset, endOffset and a deep link of the form ?t=#godam-video-, which the GoDAM player already uses to seek on page load. - A clip ends at the chapter's own endTime, else the next chapter's start, else the video duration (clamped to the duration when known). - Skip chapters with no title, an unreadable start, a start at or past the end of the video, or zero length; drop duplicate start times; strip HTML from chapter names. - Parse chapter times as plain seconds, mm:ss or hh:mm:ss(.ms), falling back to originalTime when startTime is missing. - Build clip URLs without add_query_arg(): use & on plain permalinks and drop any existing fragment from the page URL. - Only add hasPart when the video has an attachment and a host page (render-time output on archives/404 has nothing to deep-link to). - Add filters godam_video_seo_key_moments_enabled and godam_video_seo_key_moments. - Move ISO 8601 duration parsing to Seo::iso8601_to_seconds(); Video_Sitemap::iso8601_duration_to_seconds() now delegates to it. - Add VideoSeoKeyMomentsTest (27 tests) and a get_permalink() test stub. --- inc/classes/class-seo.php | 243 +++++++++++++++++++ inc/classes/class-video-sitemap.php | 11 +- tests/php/VideoSeoKeyMomentsTest.php | 351 +++++++++++++++++++++++++++ tests/stubs/video-seo-functions.php | 14 ++ 4 files changed, 609 insertions(+), 10 deletions(-) create mode 100644 tests/php/VideoSeoKeyMomentsTest.php diff --git a/inc/classes/class-seo.php b/inc/classes/class-seo.php index 0f8e50f37..5bde929c2 100644 --- a/inc/classes/class-seo.php +++ b/inc/classes/class-seo.php @@ -979,6 +979,15 @@ public function build_video_object_schema( $video, $post_id ) { $schema['duration'] = sanitize_text_field( $video['duration'] ); } + // Key moments: the video's chapters as Clip entries, so Google can show + // "jump to" links in search. Needs the host page to build each clip's URL. + if ( $attachment_id > 0 && $post_id > 0 ) { + $clips = $this->get_key_moment_clips( $attachment_id, $post_id, $schema['duration'] ?? '' ); + if ( ! empty( $clips ) ) { + $schema['hasPart'] = $clips; + } + } + /** * Filter an individual video SEO schema entry before output. * @@ -997,6 +1006,240 @@ public function build_video_object_schema( $video, $post_id ) { return apply_filters( 'godam_video_seo_schema', $schema, $video, $post_id ); } + /** + * Build Google "key moments" for a video from its GoDAM chapters. + * + * Each chapter becomes a schema.org `Clip` with a deep link that seeks the + * player on the host page (`?t=#godam-video-`, handled by the + * GoDAM player). Read at output time from the attachment's `rtgodam_meta`, + * so editing chapters updates the schema without re-saving the page. + * + * @see https://developers.google.com/search/docs/appearance/structured-data/video#clip + * + * @since 2.4.0 + * + * @param int $attachment_id Attachment ID. + * @param int $post_id Host post ID (the page the video is on). + * @param string $duration Video duration (ISO 8601), used to end the last clip. + * @return array[] Clip entries for `hasPart`, or an empty array. + */ + public function get_key_moment_clips( $attachment_id, $post_id, $duration = '' ) { + $attachment_id = absint( $attachment_id ); + $post_id = absint( $post_id ); + + if ( ! $attachment_id || ! $post_id ) { + return array(); + } + + /** + * Filters whether chapters are output as key moments (`hasPart` Clips). + * + * @since 2.4.0 + * + * @param bool $enabled Default true. + * @param int $attachment_id Attachment ID. + * @param int $post_id Host post ID. + */ + if ( ! apply_filters( 'godam_video_seo_key_moments_enabled', true, $attachment_id, $post_id ) ) { + return array(); + } + + /** + * Fires before reading this attachment's chapters (rtgodam_meta), so + * integrations that centralize media on another site can switch + * context first. + * + * @since 2.4.0 + */ + do_action( 'rtgodam_before_attachment_lookup' ); + $meta = get_post_meta( $attachment_id, 'rtgodam_meta', true ); + do_action( 'rtgodam_after_attachment_lookup' ); + + $chapters = ( is_array( $meta ) && ! empty( $meta['chapters'] ) && is_array( $meta['chapters'] ) ) ? $meta['chapters'] : array(); + if ( empty( $chapters ) ) { + return array(); + } + + $page_url = (string) get_permalink( $post_id ); // godam-coverage-ignore -- get_key_moment_clips(): $post_id is the host page, never an attachment. + if ( '' === $page_url ) { + return array(); + } + + $clips = self::chapters_to_clips( $chapters, $page_url, $attachment_id, self::iso8601_to_seconds( $duration ) ); + + /** + * Filters the key moment Clip entries output in a video's `hasPart`. + * + * @since 2.4.0 + * + * @param array[] $clips Clip entries. + * @param int $attachment_id Attachment ID. + * @param int $post_id Host post ID. + * @param array $chapters Raw chapters from rtgodam_meta. + */ + $clips = apply_filters( 'godam_video_seo_key_moments', $clips, $attachment_id, $post_id, $chapters ); + + return is_array( $clips ) ? array_values( $clips ) : array(); + } + + /** + * Convert GoDAM chapters into schema.org Clip entries. + * + * Chapters are sorted by start time. A chapter's end is its own `endTime` + * when set, otherwise the next chapter's start, otherwise the video + * duration. Chapters without a title, with an unreadable start, starting + * at or after the end of the video, or with no length are skipped, as are + * duplicate start times. + * + * Pure: no WordPress calls. + * + * @since 2.4.0 + * + * @param array $chapters Chapters (`text`, `startTime`, optional `endTime`, `originalTime`). + * @param string $page_url Host page URL. + * @param int $attachment_id Attachment ID, for the `#godam-video-` anchor. + * @param int $duration_seconds Video length in seconds, or 0 when unknown. + * @return array[] Clip entries. + */ + public static function chapters_to_clips( array $chapters, $page_url, $attachment_id, $duration_seconds = 0 ) { + $rows = array(); + + foreach ( $chapters as $chapter ) { + $chapter = (array) $chapter; + $name = trim( wp_strip_all_tags( (string) ( $chapter['text'] ?? '' ) ) ); + + $start_raw = ( isset( $chapter['startTime'] ) && '' !== (string) $chapter['startTime'] ) + ? $chapter['startTime'] + : ( $chapter['originalTime'] ?? null ); + $start = self::parse_time_to_seconds( $start_raw ); + + if ( '' === $name || null === $start || $start < 0 ) { + continue; + } + + $end = ( isset( $chapter['endTime'] ) && '' !== (string) $chapter['endTime'] ) + ? self::parse_time_to_seconds( $chapter['endTime'] ) + : null; + + $rows[] = array( + 'name' => $name, + 'start' => $start, + 'end' => $end, + ); + } + + usort( + $rows, + function ( $a, $b ) { + return $a['start'] <=> $b['start']; + } + ); + + // Strip any fragment from the page URL; the clip adds its own. + $base = (string) preg_replace( '/#.*$/', '', (string) $page_url ); + $sep = ( false === strpos( $base, '?' ) ) ? '?' : '&'; + + $clips = array(); + $seen_starts = array(); + $count = count( $rows ); + + for ( $i = 0; $i < $count; $i++ ) { + $start_offset = (int) floor( $rows[ $i ]['start'] ); + + if ( isset( $seen_starts[ $start_offset ] ) ) { + continue; + } + + if ( $duration_seconds > 0 && $start_offset >= $duration_seconds ) { + continue; + } + + $end = $rows[ $i ]['end']; + if ( null === $end ) { + $end = ( $i + 1 < $count ) ? $rows[ $i + 1 ]['start'] : ( $duration_seconds > 0 ? $duration_seconds : null ); + } + + $clip = array( + '@type' => 'Clip', + 'name' => $rows[ $i ]['name'], + 'startOffset' => $start_offset, + ); + + if ( null !== $end ) { + $end_offset = (int) ceil( $end ); + if ( $duration_seconds > 0 ) { + $end_offset = min( $end_offset, (int) $duration_seconds ); + } + if ( $end_offset <= $start_offset ) { + continue; + } + $clip['endOffset'] = $end_offset; + } + + $clip['url'] = $base . $sep . 't=' . $start_offset . '#godam-video-' . (int) $attachment_id; + + $seen_starts[ $start_offset ] = true; + $clips[] = $clip; + } + + return $clips; + } + + /** + * Parse a chapter time: plain seconds ("90", "12.5"), "mm:ss" or "hh:mm:ss(.ms)". + * + * @since 2.4.0 + * + * @param mixed $value Time value. + * @return float|null Seconds, or null when unreadable. + */ + public static function parse_time_to_seconds( $value ) { + if ( null === $value || is_array( $value ) || is_object( $value ) ) { + return null; + } + + $value = trim( (string) $value ); + + if ( '' === $value ) { + return null; + } + + if ( is_numeric( $value ) ) { + return (float) $value; + } + + if ( ! preg_match( '/^(?:(\d+):)?(\d{1,2}):(\d{1,2}(?:\.\d+)?)$/', $value, $m ) ) { + return null; + } + + $hours = '' !== $m[1] ? (int) $m[1] : 0; + $minutes = (int) $m[2]; + $seconds = (float) $m[3]; + + return $hours * 3600 + $minutes * 60 + $seconds; + } + + /** + * Convert an ISO 8601 duration (e.g. "PT1H2M3S") to whole seconds. + * + * @since 2.4.0 + * + * @param string $duration ISO 8601 duration. + * @return int Seconds, or 0 when unparseable. + */ + public static function iso8601_to_seconds( $duration ) { + if ( ! is_string( $duration ) || ! preg_match( '/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i', trim( $duration ), $m ) ) { + return 0; + } + + $days = isset( $m[1] ) ? (int) $m[1] : 0; + $hours = isset( $m[2] ) ? (int) $m[2] : 0; + $minutes = isset( $m[3] ) ? (int) $m[3] : 0; + $seconds = isset( $m[4] ) ? (float) $m[4] : 0; + + return (int) round( $days * 86400 + $hours * 3600 + $minutes * 60 + $seconds ); + } + /** * Resolve the `contentUrl` for a cached SEO entry. * diff --git a/inc/classes/class-video-sitemap.php b/inc/classes/class-video-sitemap.php index 785443940..f1caf3958 100644 --- a/inc/classes/class-video-sitemap.php +++ b/inc/classes/class-video-sitemap.php @@ -446,16 +446,7 @@ public static function is_valid_video( array $video ) { * @return int Seconds, or 0 when unparseable. */ public static function iso8601_duration_to_seconds( $duration ) { - if ( ! is_string( $duration ) || ! preg_match( '/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i', trim( $duration ), $m ) ) { - return 0; - } - - $days = isset( $m[1] ) ? (int) $m[1] : 0; - $hours = isset( $m[2] ) ? (int) $m[2] : 0; - $minutes = isset( $m[3] ) ? (int) $m[3] : 0; - $seconds = isset( $m[4] ) ? (float) $m[4] : 0; - - return (int) round( $days * 86400 + $hours * 3600 + $minutes * 60 + $seconds ); + return Seo::iso8601_to_seconds( $duration ); } /** diff --git a/tests/php/VideoSeoKeyMomentsTest.php b/tests/php/VideoSeoKeyMomentsTest.php new file mode 100644 index 000000000..679b416ac --- /dev/null +++ b/tests/php/VideoSeoKeyMomentsTest.php @@ -0,0 +1,351 @@ +?t=#godam-video-`. + * + * @package GoDAM + */ + +namespace RTGODAM\Tests; + +use PHPUnit\Framework\TestCase; +use RTGODAM\Inc\Seo; + +require_once dirname( __DIR__ ) . '/stubs/video-seo-functions.php'; +require_once dirname( __DIR__, 2 ) . '/inc/classes/class-seo.php'; + +/** + * @covers \RTGODAM\Inc\Seo + */ +class VideoSeoKeyMomentsTest extends TestCase { + + const PAGE = 'https://example.test/demo/'; + + /** + * @var Seo + */ + private $seo; + + protected function setUp(): void { + parent::setUp(); + $GLOBALS['rtgodam_stub'] = array(); + $GLOBALS['rtgodam_hooks'] = array(); + $this->seo = ( new \ReflectionClass( Seo::class ) )->newInstanceWithoutConstructor(); + } + + protected function tearDown(): void { + unset( $GLOBALS['rtgodam_stub'] ); + $GLOBALS['rtgodam_hooks'] = array(); + parent::tearDown(); + } + + /** + * Chapters as the video editor stores them in rtgodam_meta. + * + * @return array + */ + private function chapters() { + return array( + array( + 'id' => 'c2', + 'text' => 'On-page basics', + 'startTime' => '60', + ), + array( + 'id' => 'c1', + 'text' => 'Introduction', + 'startTime' => '0', + ), + array( + 'id' => 'c3', + 'text' => 'Technical SEO', + 'startTime' => '300.5', + ), + ); + } + + // ----------------------------------------------------------------------------------- + // chapters_to_clips(). + // ----------------------------------------------------------------------------------- + + public function test_chapters_become_sorted_clips_with_deep_links() { + $clips = Seo::chapters_to_clips( $this->chapters(), self::PAGE, 42, 600 ); + + $this->assertSame( + array( + array( + '@type' => 'Clip', + 'name' => 'Introduction', + 'startOffset' => 0, + 'endOffset' => 60, + 'url' => 'https://example.test/demo/?t=0#godam-video-42', + ), + array( + '@type' => 'Clip', + 'name' => 'On-page basics', + 'startOffset' => 60, + 'endOffset' => 301, + 'url' => 'https://example.test/demo/?t=60#godam-video-42', + ), + array( + '@type' => 'Clip', + 'name' => 'Technical SEO', + 'startOffset' => 300, + 'endOffset' => 600, + 'url' => 'https://example.test/demo/?t=300#godam-video-42', + ), + ), + $clips + ); + } + + public function test_explicit_end_time_is_used() { + $chapters = array( + array( + 'text' => 'Intro', + 'startTime' => '0', + 'endTime' => '25', + ), + array( + 'text' => 'Main', + 'startTime' => '40', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 100 ); + + $this->assertSame( 25, $clips[0]['endOffset'] ); + $this->assertSame( 100, $clips[1]['endOffset'] ); + } + + public function test_last_clip_has_no_end_when_duration_is_unknown() { + $clips = Seo::chapters_to_clips( $this->chapters(), self::PAGE, 42, 0 ); + + $this->assertArrayNotHasKey( 'endOffset', $clips[2] ); + $this->assertSame( 300, $clips[2]['startOffset'] ); + } + + public function test_end_is_clamped_to_the_video_duration() { + $chapters = array( + array( + 'text' => 'Only', + 'startTime' => '10', + 'endTime' => '999', + ), + ); + + $this->assertSame( 50, Seo::chapters_to_clips( $chapters, self::PAGE, 1, 50 )[0]['endOffset'] ); + } + + public function test_invalid_chapters_are_skipped() { + $chapters = array( + array( + 'text' => '', + 'startTime' => '5', + ), + array( + 'text' => 'No start', + 'startTime' => 'abc', + ), + array( + 'text' => 'After the end', + 'startTime' => '120', + ), + array( + 'text' => 'Valid', + 'startTime' => '10', + ), + array( + 'text' => 'Zero length', + 'startTime' => '20', + 'endTime' => '20', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 100 ); + + $this->assertSame( array( 'Valid' ), array_column( $clips, 'name' ) ); + } + + public function test_duplicate_start_times_keep_the_first_chapter() { + $chapters = array( + array( + 'text' => 'First', + 'startTime' => '0', + ), + array( + 'text' => 'Duplicate', + 'startTime' => '0.4', + ), + array( + 'text' => 'Second', + 'startTime' => '30', + ), + ); + + $clips = Seo::chapters_to_clips( $chapters, self::PAGE, 1, 60 ); + + $this->assertSame( array( 'First', 'Second' ), array_column( $clips, 'name' ) ); + } + + public function test_chapter_names_are_stripped_of_html() { + $chapters = array( + array( + 'text' => 'Intro & welcome', + 'startTime' => '0', + ), + ); + + $this->assertSame( 'Intro & welcome', Seo::chapters_to_clips( $chapters, self::PAGE, 1, 10 )[0]['name'] ); + } + + public function test_original_time_is_used_when_start_time_is_missing() { + $chapters = array( + array( + 'text' => 'From original time', + 'originalTime' => '01:30', + ), + ); + + $this->assertSame( 90, Seo::chapters_to_clips( $chapters, self::PAGE, 1, 200 )[0]['startOffset'] ); + } + + public function test_url_uses_ampersand_for_plain_permalinks_and_drops_existing_fragment() { + $chapters = array( + array( + 'text' => 'Intro', + 'startTime' => '15', + ), + ); + + $this->assertSame( + 'https://example.test/?p=10&t=15#godam-video-7', + Seo::chapters_to_clips( $chapters, 'https://example.test/?p=10#comments', 7, 60 )[0]['url'] + ); + } + + /** + * @dataProvider time_provider + * + * @param mixed $value Input. + * @param float|null $expected Seconds. + */ + public function test_parse_time_to_seconds( $value, $expected ) { + $this->assertSame( $expected, Seo::parse_time_to_seconds( $value ) ); + } + + /** + * @return array + */ + public function time_provider() { + return array( + 'seconds' => array( '90', 90.0 ), + 'decimal' => array( '12.5', 12.5 ), + 'int' => array( 30, 30.0 ), + 'mm:ss' => array( '01:30', 90.0 ), + 'hh:mm:ss' => array( '1:02:03', 3723.0 ), + 'hh:mm:ss.ms' => array( '00:00:05.250', 5.25 ), + 'empty' => array( '', null ), + 'null' => array( null, null ), + 'garbage' => array( 'soon', null ), + 'array' => array( array( 1 ), null ), + ); + } + + public function test_iso8601_to_seconds() { + $this->assertSame( 3723, Seo::iso8601_to_seconds( 'PT1H2M3S' ) ); + $this->assertSame( 0, Seo::iso8601_to_seconds( 'nope' ) ); + } + + // ----------------------------------------------------------------------------------- + // In the schema. + // ----------------------------------------------------------------------------------- + + /** + * A cached SEO entry for attachment 42. + * + * @return array + */ + private function entry() { + return array( + 'headline' => 'SEO basics', + 'description' => 'Learn SEO.', + 'duration' => 'PT10M', + 'thumbnailUrl' => 'https://cdn.example.test/t.jpg', + 'uploadDate' => '2026-01-15T08:00:00+00:00', + 'attachment_id' => 42, + ); + } + + public function test_schema_has_part_lists_chapters_as_clips() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $schema = $this->seo->build_video_object_schema( $this->entry(), 10 ); + + $this->assertCount( 3, $schema['hasPart'] ); + $this->assertSame( 'Clip', $schema['hasPart'][0]['@type'] ); + $this->assertSame( 'https://shop.test/?p=10&t=60#godam-video-42', $schema['hasPart'][1]['url'] ); + // Last clip ends at the video duration (PT10M). + $this->assertSame( 600, $schema['hasPart'][2]['endOffset'] ); + } + + public function test_no_has_part_without_chapters() { + $schema = $this->seo->build_video_object_schema( $this->entry(), 10 ); + + $this->assertArrayNotHasKey( 'hasPart', $schema ); + } + + public function test_no_has_part_without_a_host_page() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + // Render-time output on an archive / 404: no page to deep-link into. + $schema = $this->seo->build_video_object_schema( $this->entry(), 0 ); + + $this->assertArrayNotHasKey( 'hasPart', $schema ); + } + + public function test_no_has_part_without_an_attachment() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $entry = $this->entry(); + unset( $entry['attachment_id'] ); + + $this->assertArrayNotHasKey( 'hasPart', $this->seo->build_video_object_schema( $entry, 10 ) ); + } + + public function test_key_moments_can_be_turned_off_with_a_filter() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + add_filter( + 'godam_video_seo_key_moments_enabled', + function () { + return false; + } + ); + + $this->assertArrayNotHasKey( 'hasPart', $this->seo->build_video_object_schema( $this->entry(), 10 ) ); + } + + public function test_clips_filter_can_change_the_output() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + add_filter( + 'godam_video_seo_key_moments', + function ( $clips ) { + return array_slice( $clips, 0, 1 ); + } + ); + + $this->assertCount( 1, $this->seo->build_video_object_schema( $this->entry(), 10 )['hasPart'] ); + } + + public function test_has_part_is_valid_json() { + $GLOBALS['rtgodam_stub']['post_meta']['rtgodam_meta'] = array( 'chapters' => $this->chapters() ); + + $json = wp_json_encode( $this->seo->build_video_object_schema( $this->entry(), 10 ) ); + $decoded = json_decode( $json, true ); + + $this->assertSame( 0, $decoded['hasPart'][0]['startOffset'] ); + $this->assertIsInt( $decoded['hasPart'][0]['endOffset'] ); + } +} diff --git a/tests/stubs/video-seo-functions.php b/tests/stubs/video-seo-functions.php index e9422175a..e5d611a33 100644 --- a/tests/stubs/video-seo-functions.php +++ b/tests/stubs/video-seo-functions.php @@ -168,6 +168,20 @@ function update_post_meta( $post_id, $key, $value ) { } } + if ( ! function_exists( 'get_permalink' ) ) { + + /** + * Same shape as tests/stubs/product-lookup-functions.php's get_permalink() + * (default "shop" site), so results match whichever stub loads first. + * + * @param int $post Post ID. + * @return string + */ + function get_permalink( $post = 0 ) { + return 'https://shop.test/?p=' . (int) $post; + } + } + if ( ! function_exists( 'get_the_post_thumbnail_url' ) ) { /**