Skip to content

Commit 6728cb3

Browse files
feat: Improve duration detection using JSON+LD
1 parent 7bc26ed commit 6728cb3

4 files changed

Lines changed: 224 additions & 4 deletions

File tree

lib/SpiderBits/src/DomExtractor.php

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,16 +138,25 @@ public static function duration(Dom $dom): int
138138
// @see https://schema.org/docs/gs.html
139139
$duration_node = $dom->select('//*[@itemprop = "duration"]/attribute::content');
140140

141+
$duration_text = '';
142+
141143
if ($duration_node) {
144+
$duration_text = $duration_node->text();
145+
} else {
146+
$duration_text = self::extractDurationFromJsonLd($dom);
147+
}
148+
149+
if ($duration_text) {
142150
try {
143-
$interval = new \DateInterval($duration_node->text());
151+
$interval = new \DateInterval($duration_text);
144152
// Convert the interval to minutes
145153
$duration = $interval->y * 12 * 30 * 24 * 60;
146154
$duration += $interval->m * 30 * 24 * 60;
147155
$duration += $interval->d * 24 * 60;
148156
$duration += $interval->h * 60;
149157
$duration += $interval->i;
150-
if ($interval->s >= 30) {
158+
$duration += intval($interval->s / 60);
159+
if ($interval->s % 60 >= 30) {
151160
$duration += 1;
152161
}
153162
return $duration;
@@ -156,8 +165,9 @@ public static function duration(Dom $dom): int
156165
}
157166
}
158167

159-
// If there is no duration node (or if its content can't be parsed),
160-
// roughly estimate the duration from the DOM content.
168+
// If we didn't detect a duration indication (or if the duration
169+
// content can't be parsed), roughly estimate the duration from the DOM
170+
// content.
161171
$content = self::content($dom);
162172
$words = array_filter(explode(' ', $content));
163173
$average_reading_speed = 200;
@@ -189,4 +199,30 @@ public static function feeds(Dom $dom): array
189199

190200
return $feeds;
191201
}
202+
203+
private static function extractDurationFromJsonLd(Dom $dom): string
204+
{
205+
$json_ld_nodes = $dom->select('//script[@type = "application/ld+json"]');
206+
207+
if (!$json_ld_nodes) {
208+
return '';
209+
}
210+
211+
foreach ($json_ld_nodes->list() as $json_ld_node) {
212+
$json = json_decode(trim($json_ld_node->textContent), associative: true);
213+
214+
if (!is_array($json)) {
215+
continue;
216+
}
217+
218+
$json_ld = new JsonLd($json);
219+
$duration_text = $json_ld->duration();
220+
221+
if ($duration_text) {
222+
return $duration_text;
223+
}
224+
}
225+
226+
return '';
227+
}
192228
}

lib/SpiderBits/src/JsonLd.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace SpiderBits;
4+
5+
/**
6+
* This class helps to deal with json+ld contents. Note this is NOT a fully
7+
* operational json+ld library.
8+
*
9+
* @author Marien Fressinaud <dev@marienfressinaud.fr>
10+
* @license http://www.gnu.org/licenses/agpl-3.0.en.html AGPL
11+
*/
12+
class JsonLd
13+
{
14+
public function __construct(
15+
/** @var mixed[] $json */
16+
private array $json
17+
) {
18+
}
19+
20+
public function duration(): string
21+
{
22+
return $this->durationFromNode($this->json);
23+
}
24+
25+
/**
26+
* @param mixed[] $node
27+
*/
28+
private function durationFromNode(array $node): string
29+
{
30+
$duration_text = $node['duration'] ?? null;
31+
32+
if (is_string($duration_text)) {
33+
return $duration_text;
34+
}
35+
36+
$mainEntity = $node['mainEntity'] ?? null;
37+
38+
if (is_array($mainEntity)) {
39+
return self::durationFromNode($mainEntity);
40+
}
41+
42+
foreach ($this->graph($node) as $child_node) {
43+
if (!is_array($child_node)) {
44+
continue;
45+
}
46+
47+
$duration = $this->durationFromNode($child_node);
48+
49+
if ($duration) {
50+
return $duration;
51+
}
52+
}
53+
54+
return '';
55+
}
56+
57+
/**
58+
* @param mixed[] $node
59+
* @return mixed[]
60+
*/
61+
private function graph(array $node): array
62+
{
63+
$graph = $node['@graph'] ?? null;
64+
65+
if (!is_array($graph)) {
66+
return [];
67+
}
68+
69+
return $graph;
70+
}
71+
}

tests/lib/SpiderBits/DomExtractorTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,58 @@ public function testDurationWithInvalidItempropAttribute(): void
343343
$this->assertSame(2, $duration);
344344
}
345345

346+
public function testDurationWithJsonLdDurationAttribute(): void
347+
{
348+
/** @var string */
349+
$content = $this->fake('words', 400, true);
350+
$dom = Dom::fromText(<<<HTML
351+
<html>
352+
<body>
353+
<script type="application/ld+json">
354+
{
355+
"@context": "http://schema.org",
356+
"@type": "VideoObject",
357+
"name": "My video",
358+
"url": "https://videos.example.com/my-video",
359+
"duration": "PT2520S"
360+
}
361+
</script>
362+
363+
<main>
364+
{$content}
365+
</main>
366+
</body>
367+
</html>
368+
HTML);
369+
370+
$duration = DomExtractor::duration($dom);
371+
372+
$this->assertSame(42, $duration);
373+
}
374+
375+
public function testDurationWithInvalidJsonLd(): void
376+
{
377+
/** @var string */
378+
$content = $this->fake('words', 400, true);
379+
$dom = Dom::fromText(<<<HTML
380+
<html>
381+
<body>
382+
<script type="application/ld+json">
383+
Not JSON+LD
384+
</script>
385+
386+
<main>
387+
{$content}
388+
</main>
389+
</body>
390+
</html>
391+
HTML);
392+
393+
$duration = DomExtractor::duration($dom);
394+
395+
$this->assertSame(2, $duration);
396+
}
397+
346398
public function testFeeds(): void
347399
{
348400
$dom = Dom::fromText(<<<HTML
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace SpiderBits;
4+
5+
class JsonLdTest extends \PHPUnit\Framework\TestCase
6+
{
7+
public function testDurationWithRootAttribute(): void
8+
{
9+
$json_ld_array = [
10+
'@context' => 'http://schema.org',
11+
'@type' => 'VideoObject',
12+
'name' => 'My video',
13+
'url' => 'https://videos.example.com/my-video',
14+
'duration' => 'PT2520',
15+
];
16+
$json_ld = new JsonLd($json_ld_array);
17+
18+
$duration = $json_ld->duration();
19+
20+
$this->assertSame('PT2520', $duration);
21+
}
22+
23+
public function testDurationInMainEntity(): void
24+
{
25+
$json_ld_array = [
26+
'@context' => 'http://schema.org',
27+
'@type' => 'RadioEpisode',
28+
'mainEntity' => [
29+
'@type' => 'AudioObject',
30+
'contentUrl' => 'https://radio.example.com/episode',
31+
'duration' => 'PT2520',
32+
],
33+
];
34+
35+
$json_ld = new JsonLd($json_ld_array);
36+
37+
$duration = $json_ld->duration();
38+
39+
$this->assertSame('PT2520', $duration);
40+
}
41+
42+
public function testDurationInGraphAttribute(): void
43+
{
44+
$json_ld_array = [
45+
'@context' => 'http://schema.org',
46+
'@graph' => [
47+
[
48+
'@type' => 'VideoObject',
49+
'name' => 'My video',
50+
'url' => 'https://videos.example.com/my-video',
51+
'duration' => 'PT2520',
52+
],
53+
],
54+
];
55+
$json_ld = new JsonLd($json_ld_array);
56+
57+
$duration = $json_ld->duration();
58+
59+
$this->assertSame('PT2520', $duration);
60+
}
61+
}

0 commit comments

Comments
 (0)