rss-bridge/bridges/YouTubeCommunityTabBridge.php

331 lines
10 KiB
PHP
Raw Normal View History

<?php
class YouTubeCommunityTabBridge extends BridgeAbstract
{
const NAME = 'YouTube Community Tab Bridge';
const URI = 'https://www.youtube.com';
const DESCRIPTION = 'Returns posts from a channel\'s community tab';
const MAINTAINER = 'VerifiedJoseph';
const PARAMETERS = [
'By channel ID' => [
'channel' => [
'name' => 'Channel ID',
'type' => 'text',
'required' => true,
'exampleValue' => 'UCULkRHBdLC5ZcEQBaL0oYHQ'
]
],
'By username' => [
'username' => [
'name' => 'Username',
'type' => 'text',
'required' => true,
'exampleValue' => 'YouTubeUK'
],
]
];
const CACHE_TIMEOUT = 3600; // 1 hour
private $feedUrl = '';
private $feedName = '';
private $itemTitle = '';
private $urlRegex = '/youtube\.com\/(channel|user|c)\/([\w]+)\/community/';
private $jsonRegex = '/var ytInitialData = ([^<]*);<\/script>/';
public function detectParameters($url)
{
$params = [];
if (preg_match($this->urlRegex, $url, $matches)) {
if ($matches[1] === 'channel') {
$params['context'] = 'By channel ID';
$params['channel'] = $matches[2];
}
if ($matches[1] === 'user') {
$params['context'] = 'By username';
$params['username'] = $matches[2];
}
return $params;
}
return null;
}
public function collectData()
{
if (is_null($this->getInput('username')) === false) {
try {
$this->feedUrl = $this->buildCommunityUri($this->getInput('username'), 'c');
$html = getSimpleHTMLDOM($this->feedUrl);
} catch (Exception $e) {
$this->feedUrl = $this->buildCommunityUri($this->getInput('username'), 'user');
$html = getSimpleHTMLDOM($this->feedUrl);
}
} else {
$this->feedUrl = $this->buildCommunityUri($this->getInput('channel'), 'channel');
$html = getSimpleHTMLDOM($this->feedUrl);
}
$json = $this->extractJson($html->find('html', 0)->innertext);
$this->feedName = $json->header->c4TabbedHeaderRenderer->title;
if ($this->hasCommunityTab($json) === false) {
returnServerError('Channel does not have a community tab');
}
2023-07-29 01:14:30 +03:00
$posts = $this->getCommunityPosts($json);
foreach ($posts as $key => $post) {
$this->itemTitle = '';
if (!isset($post->backstagePostThreadRenderer)) {
continue;
}
2023-07-29 01:14:30 +03:00
if (isset($post->backstagePostThreadRenderer->post->backstagePostRenderer)) {
$details = $post->backstagePostThreadRenderer->post->backstagePostRenderer;
} elseif (isset($post->backstagePostThreadRenderer->post->sharedPostRenderer)) {
// todo: properly extract data from this shared post
$details = $post->backstagePostThreadRenderer->post->sharedPostRenderer;
} else {
continue;
}
$item = [];
$item['uri'] = self::URI . '/post/' . $details->postId;
2023-07-29 01:14:30 +03:00
$item['author'] = $details->authorText->runs[0]->text ?? null;
$item['content'] = $item['uri'];
if (isset($details->contentText->runs)) {
$text = $this->getText($details->contentText->runs);
$this->itemTitle = $this->ellipsisTitle($text);
$item['content'] = $text;
}
$item['content'] .= $this->getAttachments($details);
$item['title'] = $this->itemTitle;
$date = strtotime(str_replace(' (edited)', '', $details->publishedTimeText->runs[0]->text));
if (is_int($date)) {
// subtract an increasing multiple of 60 seconds to always preserve the original order
$item['timestamp'] = $date - $key * 60;
}
$this->items[] = $item;
}
}
public function getURI()
{
if (!empty($this->feedUrl)) {
return $this->feedUrl;
}
return parent::getURI();
}
public function getName()
{
if (!empty($this->feedName)) {
return $this->feedName . ' - YouTube Community Tab';
}
return parent::getName();
}
/**
* Build Community URI
*/
private function buildCommunityUri($value, $type)
{
return self::URI . '/' . $type . '/' . $value . '/community';
}
/**
* Extract JSON from page
*/
private function extractJson($html)
{
if (!preg_match($this->jsonRegex, $html, $parts)) {
returnServerError('Failed to extract data from page');
}
$data = json_decode($parts[1]);
if ($data === false) {
returnServerError('Failed to decode extracted data');
}
return $data;
}
/**
* Check if channel has a community tab
*/
private function hasCommunityTab($json)
{
foreach ($json->contents->twoColumnBrowseResultsRenderer->tabs as $tab) {
if (
isset($tab->tabRenderer)
&& str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'community')
) {
return true;
}
}
return false;
}
/**
* Get community tab posts
*/
private function getCommunityPosts($json)
{
foreach ($json->contents->twoColumnBrowseResultsRenderer->tabs as $tab) {
if (
isset($tab->tabRenderer)
&& str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'community')
) {
return $tab->tabRenderer->content->sectionListRenderer->contents[0]->itemSectionRenderer->contents;
}
}
}
/**
* Get text content for a post
*/
private function getText($runs)
{
$text = '';
foreach ($runs as $part) {
if (isset($part->navigationEndpoint->browseEndpoint->canonicalBaseUrl)) {
$text .= $this->formatUrls($part->text, $part->navigationEndpoint->browseEndpoint->canonicalBaseUrl);
} elseif (isset($part->navigationEndpoint->urlEndpoint->url)) {
$text .= $this->formatUrls($part->text, $part->navigationEndpoint->urlEndpoint->url);
} elseif (isset($part->navigationEndpoint->commandMetadata->webCommandMetadata->url)) {
$text .= $this->formatUrls($part->text, $part->navigationEndpoint->commandMetadata->webCommandMetadata->url);
} else {
$text .= $this->formatUrls($part->text, null);
}
}
return nl2br($text);
}
/**
* Get attachments for posts
*/
private function getAttachments($details)
{
$content = '';
if (isset($details->backstageAttachment)) {
$attachments = $details->backstageAttachment;
if (isset($attachments->videoRenderer) && isset($attachments->videoRenderer->videoId)) {
// Video
if (empty($this->itemTitle)) {
$this->itemTitle = $this->feedName . ' posted a video';
}
$content = <<<EOD
<iframe width="100%" height="410" src="https://www.youtube.com/embed/{$attachments->videoRenderer->videoId}"
frameborder="0" allow="encrypted-media;" allowfullscreen></iframe>
EOD;
} elseif (isset($attachments->backstageImageRenderer)) {
// Image
if (empty($this->itemTitle)) {
$this->itemTitle = $this->feedName . ' posted an image';
}
$lastThumb = end($attachments->backstageImageRenderer->image->thumbnails);
$content = <<<EOD
<p><img src="{$lastThumb->url}"></p>
EOD;
} elseif (isset($attachments->pollRenderer)) {
// Poll
if (empty($this->itemTitle)) {
$this->itemTitle = $this->feedName . ' posted a poll';
}
$pollChoices = '';
foreach ($attachments->pollRenderer->choices as $choice) {
$pollChoices .= <<<EOD
<li>{$choice->text->runs[0]->text}</li>
EOD;
}
$content = <<<EOD
<hr><p>Poll ({$attachments->pollRenderer->totalVotes->simpleText})<br><ul>{$pollChoices}</ul><p>
EOD;
} elseif (isset($attachments->postMultiImageRenderer->images)) {
// Multiple images
$images = $attachments->postMultiImageRenderer->images;
if (is_array($images)) {
if (empty($this->itemTitle)) {
$this->itemTitle = $this->feedName . ' posted ' . count($images) . ' images';
}
foreach ($images as $image) {
$lastThumb = end($image->backstageImageRenderer->image->thumbnails);
$content .= <<<EOD
<p><img src="{$lastThumb->url}"></p>
EOD;
}
}
}
}
return $content;
}
/*
Ellipsis text for title
*/
private function ellipsisTitle($text)
{
$length = 100;
$text = strip_tags($text);
if (strlen($text) > $length) {
$text = explode('<br>', wordwrap($text, $length, '<br>'));
return $text[0] . '...';
}
return $text;
}
private function formatUrls($content, $url)
{
if (substr(strval($url), 0, 1) == '/') {
// fix relative URL
$url = 'https://www.youtube.com' . $url;
} elseif (substr(strval($url), 0, 33) == 'https://www.youtube.com/redirect?') {
// extract actual URL from YouTube redirect
parse_str(substr($url, 33), $params);
if (strpos(($params['q'] ?? ''), rtrim($content, '.')) === 0) {
$url = $params['q'];
}
}
// ensure all URLs are made clickable
$url = $url ?? $content;
if (filter_var($url, FILTER_VALIDATE_URL)) {
return '<a href="' . $url . '" target="_blank">' . $content . '</a>';
}
return $content;
}
}