80 lines
4.3 KiB
PHP
80 lines
4.3 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use DOMDocument;
|
||
use DOMElement;
|
||
use DOMXPath;
|
||
|
||
/** Presentation-only adaptation: keep every original account/password pair intact. */
|
||
final class AppleIdArticleLayout
|
||
{
|
||
public static function prepare(string $html): ?array
|
||
{
|
||
if (!preg_match('/免费苹果(?:账号|ID)/u', $html)) return null;
|
||
$dom = new DOMDocument('1.0', 'UTF-8');
|
||
$before = libxml_use_internal_errors(true);
|
||
try {
|
||
$dom->loadHTML('<?xml encoding="UTF-8"><html><body><div id="aid-root">'.str_replace(ArticleRecommendations::SLOT, '', $html).'</div></body></html>', LIBXML_NONET);
|
||
$root = $dom->getElementById('aid-root');
|
||
if (!$root) return null;
|
||
$xpath = new DOMXPath($dom);
|
||
$copies = $xpath->query('.//*[@data-clipboard-text]', $root);
|
||
if (!$copies || $copies->length % 2) return null;
|
||
// Empty historical updates are valid, but plaintext account rows must not disappear.
|
||
if (!$copies->length && (preg_match('/账号[::]|密码[::]/u', $root->textContent)
|
||
|| !$xpath->query('.//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][starts-with(normalize-space(.), "免费苹果")]', $root)->length)) return null;
|
||
|
||
$result = ['accounts' => [], 'regions' => [], 'warnings' => [], 'intro' => '', 'outro' => ''];
|
||
$containers = [];
|
||
for ($i = 0; $i < $copies->length; $i += 2) {
|
||
$account = $copies->item($i);
|
||
$password = $copies->item($i + 1);
|
||
$container = $account->parentNode;
|
||
while ($container && $container !== $root && $container->parentNode !== $root) $container = $container->parentNode;
|
||
// Unknown nesting, labels or additional copy controls retain the legacy rendering.
|
||
if (!$container instanceof DOMElement || $container->parentNode !== $root || $container->tagName !== 'p') return null;
|
||
$fields = $xpath->query('.//*[@data-clipboard-text]', $container);
|
||
if ($fields->length !== 2 || !$fields->item(0)->isSameNode($account) || !$fields->item(1)->isSameNode($password)) return null;
|
||
$text = self::text($container->textContent);
|
||
if (!preg_match('/^账号[::]\s*.+[,,]\s*密码[::]\s*.+[,,]\s*地区[::]\s*(.*?)[,,]\s*检测时间[::]\s*(.*?)\s*$/u', $text, $match)) return null;
|
||
$username = $account->getAttribute('data-clipboard-text');
|
||
$secret = $password->getAttribute('data-clipboard-text');
|
||
if ($username === '' || $secret === '') return null;
|
||
$region = trim($match[1]);
|
||
if ($region === '') $region = '未注明';
|
||
$result['accounts'][] = ['account' => $username, 'password' => $secret, 'region' => $region, 'checked_at' => trim($match[2])];
|
||
$result['regions'][$region] = ($result['regions'][$region] ?? 0) + 1;
|
||
$containers[] = $container;
|
||
}
|
||
|
||
$seenAccount = false;
|
||
foreach (iterator_to_array($root->childNodes) as $node) {
|
||
$isAccount = false;
|
||
foreach ($containers as $container) if ($node->isSameNode($container)) { $isAccount = true; break; }
|
||
if ($isAccount) { $seenAccount = true; continue; }
|
||
$text = self::text($node->textContent);
|
||
if ($text === '') continue;
|
||
if ($node instanceof DOMElement && preg_match('/^免费苹果(?:账号|ID)/u', $text) && preg_match('/^h[1-6]$/', $node->tagName)) continue;
|
||
if (str_contains($text, '必须从App Store登录') && str_contains($text, 'iCloud')) {
|
||
$warning = preg_replace('/^[\x{26A0}\x{FE0F}\s]+/u', '', $text);
|
||
if (!in_array($warning, $result['warnings'], true)) $result['warnings'][] = $warning;
|
||
continue;
|
||
}
|
||
$result[$seenAccount ? 'outro' : 'intro'] .= $dom->saveHTML($node);
|
||
}
|
||
return $result;
|
||
} catch (\Throwable) {
|
||
return null;
|
||
} finally {
|
||
libxml_clear_errors();
|
||
libxml_use_internal_errors($before);
|
||
}
|
||
}
|
||
|
||
private static function text(string $text): string
|
||
{
|
||
return trim(preg_replace('/\s+/u', ' ', $text));
|
||
}
|
||
}
|