Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [4.6.0] - 2026-10-30
### Added
- `mastodon:iframe` setting (off by default). Mastodon oEmbed html (`blockquote.mastodon-embed` with `data-embed-url`) becomes a sandboxed `<iframe>` to the post's `/embed` page, so no `embed.js` is needed. Only same-host `https` embed URLs are used
- Bilibili adapter: `bilibili.com/video/BV...` and `av...` URLs (also `b23.tv` short links) get a `player.bilibili.com` iframe built from the URL. The title, description and image of Bilibili's error pages ("出错啦!" and "视频去哪了呢?") are ignored, and so is a description template that was never filled [#243]
- Youku adapter: `v.youku.com/v_show/id_<ID>.html` URLs get a `player.youku.com/embed` iframe built from the URL [#243]
- NetEase Music adapter: songs, albums and playlists on `music.163.com` get an `outchain/player` iframe, and the author is read from the description. URLs with a `#/` route are requested without it. MV pages (`/mv?id=` and `/#/mv?id=`) get metadata only, because NetEase has no official embeddable MV player [#243]
- `Extractor::normalizeUri()`, used by adapters to rewrite a URL before it is requested

## [4.5.4] - 2026-10-30
### Security
Expand Down Expand Up @@ -350,6 +354,7 @@ Full library refactoring.
[#572]: https://github.com/oscarotero/Embed/issues/572
[#573]: https://github.com/oscarotero/Embed/issues/573
[#584]: https://github.com/php-embed/Embed/issues/584
[#243]: https://github.com/php-embed/Embed/issues/243
[#575]: https://github.com/php-embed/Embed/issues/575
[#574]: https://github.com/oscarotero/Embed/issues/574
[#577]: https://github.com/oscarotero/Embed/issues/577
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ The `Extractor` class has many `Detectors`. Each detector is responsible to dete

So, an adapter is basically an extractor created specifically for a site. It can contains also custom detectors or apis. If you see the `src/Adapters` folder you can see all adapters.

An adapter can also rewrite the URL before it is requested by overriding the static `normalizeUri()` method of its extractor. For example, the NetEase Music adapter turns `music.163.com/#/song?id=1` into `music.163.com/song?id=1`, because the part after `#` is never sent to the server.

If you create an adapter, you need also register to Embed, so it knows in which website needs to use. To do that, there's the `ExtractorFactory` object, that is responsible for instantiate the right extractor for each site.

```php
Expand Down
33 changes: 33 additions & 0 deletions src/Adapters/Bilibili/Detectors/Code.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Bilibili\Detectors;

use Embed\Adapters\Bilibili\Extractor;
use Embed\Detectors\Code as Detector;
use Embed\EmbedCode;
use function Embed\html;

/**
* @extends Detector<\Embed\Adapters\Bilibili\Extractor>
*/
class Code extends Detector
{
public function detect(): ?EmbedCode
{
$params = Extractor::parseVideoPath($this->extractor->getUri()->getPath());

if ($params === null) {
return parent::detect();
}

return new EmbedCode(html('iframe', [
'src' => 'https://player.bilibili.com/player.html?'.http_build_query($params + ['autoplay' => 0]),
'frameborder' => 0,
'allowfullscreen' => 'true',
'scrolling' => 'no',
'width' => 640,
'height' => 360,
]), 640, 360);
}
}
28 changes: 28 additions & 0 deletions src/Adapters/Bilibili/Detectors/Description.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Bilibili\Detectors;

use Embed\Adapters\Bilibili\Extractor;
use Embed\Detectors\Description as Detector;

/**
* @extends Detector<\Embed\Adapters\Bilibili\Extractor>
*/
class Description extends Detector
{
public function detect(): ?string
{
if ($this->extractor->isErrorPage()) {
return null;
}

$description = parent::detect();

if ($description !== null && Extractor::isTemplate($description)) {
return null;
}

return $description;
}
}
22 changes: 22 additions & 0 deletions src/Adapters/Bilibili/Detectors/Image.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Bilibili\Detectors;

use Embed\Detectors\Image as Detector;
use Psr\Http\Message\UriInterface;

/**
* @extends Detector<\Embed\Adapters\Bilibili\Extractor>
*/
class Image extends Detector
{
public function detect(): ?UriInterface
{
if ($this->extractor->isErrorPage()) {
return null;
}

return parent::detect();
}
}
21 changes: 21 additions & 0 deletions src/Adapters/Bilibili/Detectors/Title.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Bilibili\Detectors;

use Embed\Detectors\Title as Detector;

/**
* @extends Detector<\Embed\Adapters\Bilibili\Extractor>
*/
class Title extends Detector
{
public function detect(): ?string
{
if ($this->extractor->isErrorPage()) {
return null;
}

return parent::detect();
}
}
72 changes: 72 additions & 0 deletions src/Adapters/Bilibili/Extractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Bilibili;

use Embed\Extractor as Base;

/**
* @template-extends Base<\Embed\Detectors\Detector<self>>
*/
class Extractor extends Base
{
/**
* Titles of the pages Bilibili serves when the video is not available,
* for example when the request comes from a restricted region.
*/
private const ERROR_TITLES = ['出错啦', '视频去哪了呢'];

public function createCustomDetectors(): array
{
return [
'code' => new Detectors\Code($this),
'title' => new Detectors\Title($this),
'description' => new Detectors\Description($this),
'image' => new Detectors\Image($this),
];
}

public function isErrorPage(): bool
{
$title = $this->getDocument()->select('.//head/title')->str();

if ($title === null) {
return false;
}

foreach (self::ERROR_TITLES as $errorTitle) {
if (strpos($title, $errorTitle) !== false) {
return true;
}
}

return false;
}

/**
* Error pages may also carry a description template that was never
* filled, with placeholders like {$0} or undefined.
*/
public static function isTemplate(string $text): bool
{
return preg_match('/\{\$\d+\}|\bundefined\b/', $text) === 1;
}

/**
* Returns the player query (bvid or aid) for a video path.
*
* @return array<string, string>|null
*/
public static function parseVideoPath(string $path): ?array
{
if (preg_match('#^/video/(BV[0-9A-Za-z]{10})(?:/|$)#', $path, $matches) === 1) {
return ['bvid' => $matches[1]];
}

if (preg_match('#^/video/av(\d+)(?:/|$)#i', $path, $matches) === 1) {
return ['aid' => $matches[1]];
}

return null;
}
}
29 changes: 29 additions & 0 deletions src/Adapters/NetEase/Detectors/AuthorName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\NetEase\Detectors;

use Embed\Detectors\AuthorName as Detector;

/**
* @extends Detector<\Embed\Adapters\NetEase\Extractor>
*/
class AuthorName extends Detector
{
public function detect(): ?string
{
$result = parent::detect();

if ($result !== null) {
return $result;
}

$description = $this->extractor->getMetas()->str('description', 'og:description');

if ($description !== null && preg_match('/由\s*(.+?)\s*演唱/u', $description, $matches) === 1) {
return $matches[1];
}

return null;
}
}
39 changes: 39 additions & 0 deletions src/Adapters/NetEase/Detectors/Code.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\NetEase\Detectors;

use Embed\Adapters\NetEase\Extractor;
use Embed\Detectors\Code as Detector;
use Embed\EmbedCode;
use function Embed\html;

/**
* @extends Detector<\Embed\Adapters\NetEase\Extractor>
*/
class Code extends Detector
{
public function detect(): ?EmbedCode
{
$item = Extractor::parseUri($this->extractor->getUri());

if ($item === null) {
return parent::detect();
}

$query = http_build_query([
'type' => $item[0],
'id' => $item[1],
'auto' => 0,
'height' => 66,
]);

return new EmbedCode(html('iframe', [
'src' => "https://music.163.com/outchain/player?{$query}",
'frameborder' => 0,
'scrolling' => 'no',
'width' => 330,
'height' => 86,
]), 330, 86);
}
}
69 changes: 69 additions & 0 deletions src/Adapters/NetEase/Extractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\NetEase;

use Embed\Extractor as Base;
use Psr\Http\Message\UriInterface;

/**
* @template-extends Base<\Embed\Detectors\Detector<self>>
*/
class Extractor extends Base
{
/** Player types used by music.163.com/outchain/player */
private const TYPES = [
'song' => 2,
'album' => 1,
'playlist' => 0,
];

public function createCustomDetectors(): array
{
return [
'code' => new Detectors\Code($this),
'authorName' => new Detectors\AuthorName($this),
];
}

/**
* NetEase Music URLs like /#/song?id=1 keep the route in the fragment,
* which is never sent to the server. Move it to the path and query.
* MV pages (/mv?id=1) are normalized too, but NetEase has no official
* embeddable MV player, so they only get metadata.
*/
public static function normalizeUri(UriInterface $uri): UriInterface
{
$fragment = $uri->getFragment();

if (preg_match('#^/(?:m/)?(song|album|playlist|mv)\?(.*)$#', $fragment, $matches) !== 1) {
return $uri;
}

return $uri->withPath('/'.$matches[1])->withQuery($matches[2])->withFragment('');
}

/**
* Returns the player type and the id of a song, album or playlist.
*
* @return array{0: int, 1: string}|null
*/
public static function parseUri(UriInterface $uri): ?array
{
$path = $uri->getPath();
$type = self::TYPES[trim($path, '/')] ?? null;

if ($type === null) {
return null;
}

parse_str($uri->getQuery(), $query);
$id = $query['id'] ?? null;

if (!is_string($id) || preg_match('/^\d+$/', $id) !== 1) {
return null;
}

return [$type, $id];
}
}
33 changes: 33 additions & 0 deletions src/Adapters/Youku/Detectors/Code.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Youku\Detectors;

use Embed\Adapters\Youku\Extractor;
use Embed\Detectors\Code as Detector;
use Embed\EmbedCode;
use function Embed\html;

/**
* @extends Detector<\Embed\Adapters\Youku\Extractor>
*/
class Code extends Detector
{
public function detect(): ?EmbedCode
{
$id = Extractor::parseVideoPath($this->extractor->getUri()->getPath());

if ($id === null) {
return parent::detect();
}

return new EmbedCode(html('iframe', [
'src' => 'https://player.youku.com/embed/'.rawurlencode($id),
'frameborder' => 0,
'allowfullscreen' => 'true',
'scrolling' => 'no',
'width' => 640,
'height' => 360,
]), 640, 360);
}
}
Loading
Loading