94 lines
5.8 KiB
PHP
94 lines
5.8 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use DOMDocument;
|
||
use DOMElement;
|
||
|
||
/** Read-only adaptation of the legacy daily-node article into a task-oriented layout. */
|
||
final class FreeNodeArticleLayout
|
||
{
|
||
public static function prepare(string $html): ?array
|
||
{
|
||
if (!preg_match('~(?:<p\b[^>]*>\s*)?<h[2-6]\b[^>]*>\s*免费节点订阅链接\s*[::]?\s*</h[2-6]>(?:\s*</p>)?~u', $html, $heading, PREG_OFFSET_CAPTURE)) return null;
|
||
if (!preg_match('~<p\b[^>]*>\s*温馨提示\s*[::]?\s*</p>~u', $html, $help, PREG_OFFSET_CAPTURE, $heading[0][1])) return null;
|
||
$start = $heading[0][1];$from = $start + strlen($heading[0][0]);$end = $help[0][1];
|
||
$intro = self::fragment(str_replace(ArticleRecommendations::SLOT, '', substr($html, 0, $start)));
|
||
$subscriptions = self::fragment(substr($html, $from, $end - $from));
|
||
if (!$intro || !$subscriptions) return null;
|
||
$result = ['count' => null, 'speed' => null, 'regions' => [], 'community' => [], 'intro' => '', 'notes' => '', 'subscriptions' => [], 'usage' => substr($html, $end + strlen($help[0][0]))];
|
||
foreach (iterator_to_array($intro->childNodes) as $node) {
|
||
$text = self::text($node->textContent);
|
||
if ($text === '') continue;
|
||
if (preg_match('/^本次更新共\s*(\d+)\s*个可用节点[,,]\s*最高速度\s*([^。]+)[。]?$/u', $text, $m)) {
|
||
$result['count'] = (int)$m[1];$result['speed'] = trim($m[2]);continue;
|
||
}
|
||
if (preg_match('/^覆盖(.+?)等多个区域[。]?$/u', $text, $m)) {
|
||
$result['regions'] = array_values(array_unique(array_filter(array_map('trim', preg_split('/[、,,]/u', $m[1])))));continue;
|
||
}
|
||
if (str_starts_with($text, '复制下方订阅链接')) continue;
|
||
if ($node instanceof DOMElement && preg_match('/点击加入我们的tg频道|点击关注我们的github仓库/u', $text)) {
|
||
$recognized = false;
|
||
foreach ($node->getElementsByTagName('a') as $a) {
|
||
$url = $a->getAttribute('href');$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
|
||
if (parse_url($url, PHP_URL_SCHEME) !== 'https' || !in_array($host, ['t.me', 'github.com'], true)) continue;
|
||
$result['community'][] = ['url' => $url, 'label' => $host === 't.me' ? 'Telegram 更新频道' : 'GitHub 项目仓库'];$recognized = true;
|
||
}
|
||
if ($recognized) continue;
|
||
}
|
||
$result['intro'] .= $intro->ownerDocument->saveHTML($node);
|
||
}
|
||
$urls = [];
|
||
foreach (iterator_to_array($subscriptions->getElementsByTagName('a')) as $a) {
|
||
if (!$a->hasAttribute('data-clipboard-text')) continue;
|
||
$url = $a->getAttribute('data-clipboard-text');
|
||
if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) return null;
|
||
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
||
$type = match (true) {
|
||
(bool)preg_match('/-clash\.(yaml|yml)$/i', $path) => ['key'=>'clash', 'title'=>'Clash / Mihomo', 'format'=>'YAML', 'clients'=>'Clash Verge Rev、Mihomo 等客户端', 'symbol'=>'C'],
|
||
(bool)preg_match('/-singbox\.(txt|json)$/i', $path) => ['key'=>'singbox', 'title'=>'Sing-box / Hiddify', 'format'=>'SING-BOX', 'clients'=>'Sing-box、Hiddify 等客户端', 'symbol'=>'S'],
|
||
(bool)preg_match('/-v2ray\.txt$/i', $path) => ['key'=>'v2ray', 'title'=>'V2ray / Shadowrocket', 'format'=>'通用订阅', 'clients'=>'v2rayNG、Shadowrocket 等客户端', 'symbol'=>'V'],
|
||
default => null,
|
||
};
|
||
// Unknown formats keep the original article rather than dropping a subscription.
|
||
if (!$type || isset($result['subscriptions'][$type['key']])) return null;
|
||
$result['subscriptions'][$type['key']] = $type + ['url'=>$url];$urls[] = $url;
|
||
}
|
||
if ($result['subscriptions'] === []) return null;
|
||
// Keep every supplementary note while removing only labels/URLs now shown in cards.
|
||
foreach (iterator_to_array($subscriptions->childNodes) as $node) {
|
||
$text = self::text($node->textContent);
|
||
if ($text === '' || in_array($text, $urls, true) || preg_match('/订阅链接\s*[::]$/u', $text)) continue;
|
||
if ($node instanceof DOMElement && ($node->hasAttribute('data-clipboard-text') || $node->getElementsByTagName('a')->length)) {
|
||
foreach (iterator_to_array($node->getElementsByTagName('a')) as $link) {
|
||
if ($link->hasAttribute('data-clipboard-text')) $link->parentNode->removeChild($link);
|
||
}
|
||
if (self::text($node->textContent) === '' || in_array(self::text($node->textContent), $urls, true)) continue;
|
||
}
|
||
$result['notes'] .= $subscriptions->ownerDocument->saveHTML($node);
|
||
}
|
||
$order = ['clash'=>0, 'v2ray'=>1, 'singbox'=>2];
|
||
uasort($result['subscriptions'], fn ($a, $b) => $order[$a['key']] <=> $order[$b['key']]);
|
||
$result['subscriptions'] = array_values($result['subscriptions']);
|
||
return $result;
|
||
}
|
||
|
||
private static function text(string $text): string
|
||
{
|
||
return trim(preg_replace('/\s+/u', ' ', $text));
|
||
}
|
||
|
||
private static function fragment(string $html): ?DOMElement
|
||
{
|
||
$dom = new DOMDocument('1.0', 'UTF-8');$before = libxml_use_internal_errors(true);
|
||
try {
|
||
$dom->loadHTML('<?xml encoding="UTF-8"><html><body><div id="fn-root">'.$html.'</div></body></html>', LIBXML_NONET);
|
||
return $dom->getElementById('fn-root');
|
||
} catch (\Throwable) {
|
||
return null;
|
||
} finally {
|
||
libxml_clear_errors();libxml_use_internal_errors($before);
|
||
}
|
||
}
|
||
}
|