From 66b3dabf37a9861584351dd9474e48e3124733a4 Mon Sep 17 00:00:00 2001 From: LordArrin <79581469+LordArrin@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:09:45 +0300 Subject: [PATCH 1/2] Add RuStoreBridge Returns app updates from the Russian app store. --- bridges/RuStoreBridge.php | 278 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 bridges/RuStoreBridge.php diff --git a/bridges/RuStoreBridge.php b/bridges/RuStoreBridge.php new file mode 100644 index 00000000000..49e811d8edb --- /dev/null +++ b/bridges/RuStoreBridge.php @@ -0,0 +1,278 @@ + [ + 'name' => 'Package name', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'com.flyersoft.moonreader', + ] + ] + ]; + + const BASE_URL = 'https://www.rustore.ru/catalog/app/'; + + const CSS = [ + 'item' => 'background:#f9f9f9;padding:12px;border-left:3px solid #0077ff;margin:0 0 15px 0;font-size:0.95em;line-height:1.5', + 'empty' => 'font-style:italic;color:#999', + ]; + + const HTTP_HEADERS = [ + 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + 'Accept: text/html,application/xhtml+xml,application/xml', + 'Referer: https://www.rustore.ru/', + ]; + + private $appName = ''; + private $appIcon = null; + + public function collectData() + { + $package = $this->getInput('package'); + + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/', $package)) { + throw new \Exception('Invalid package name format.'); + } + + $url = self::BASE_URL . urlencode($package) . '/versions'; + $html = getSimpleHTMLDOM($url, self::HTTP_HEADERS); + + if (!$html) { + throw new \Exception('Failed to load page: ' . $url); + } + + $this->extractMeta($html); + $versions = $this->parseJsonLd($html) ?: $this->parseNextJsPayload($html); + + if (empty($versions)) { + throw new \Exception('No version data found on the page.'); + } + + foreach ($versions as $version) { + $this->items[] = $this->buildItem($version, $package); + } + } + + public function getName() { return $this->appName ?: parent::getName(); } + public function getURI() { return $this->getInput('package') ? self::BASE_URL . urlencode($this->getInput('package')) : parent::getURI(); } + public function getIcon() { return $this->appIcon ?: parent::getIcon(); } + + private function extractMeta($html) + { + foreach ($html->find('script[type="application/ld+json"]') as $script) { + $data = json_decode(trim($script->innertext), true); + if (!is_array($data)) continue; + + if (($data['@type'] ?? '') === 'BreadcrumbList' && !empty($data['itemListElement'])) { + $last = end($data['itemListElement']); + $this->appName = $last['name'] ?? ''; + break; + } + } + + $og = $html->find('meta[property="og:image"]', 0); + if ($og && !empty($og->content)) { + $this->appIcon = strpos($og->content, '//') === 0 ? 'https:' . $og->content : $og->content; + } + } + + private function parseJsonLd($html) + { + $versions = []; + + foreach ($html->find('script[type="application/ld+json"]') as $script) { + $data = json_decode(trim($script->innertext), true); + if (!is_array($data) || ($data['@type'] ?? '') !== 'ItemList') continue; + + foreach ($data['itemListElement'] ?? [] as $element) { + if (($element['@type'] ?? '') !== 'UpdateAction' || empty($element['name'])) continue; + + $versions[] = [ + 'versionName' => $element['name'], + 'whatsNew' => $element['description'] ?? '', + 'date' => $element['startTime'] ?? '', + ]; + } + + if (!empty($versions)) break; + } + + return $versions; + } + + private function parseNextJsPayload($html) + { + $allContent = ''; + foreach ($html->find('script') as $script) { + if (strpos($script->innertext, 'self.__next_f.push') !== false) { + $allContent .= $script->innertext . "\n"; + } + } + + if (empty($allContent)) return []; + + if (preg_match('/"versions"\s*:\s*\[(.+?)\]\s*,\s*"continuation"/s', $allContent, $match)) { + $json = stripcslashes('[' . $match[1] . ']'); + $data = json_decode($json, true); + + if (!is_array($data)) return []; + + $versions = []; + foreach ($data as $v) { + if (empty($v['versionName'])) continue; + $versions[] = [ + 'versionName' => $v['versionName'], + 'whatsNew' => $v['whatsNew'] ?? '', + 'date' => $v['appVerUpdatedAt'] ?? '', + ]; + } + return $versions; + } + + return []; + } + + private function looksLikeImplicitList($lines) + { + if (count($lines) < 2) { + return false; + } + + $semicolonCount = 0; + $capitalStartCount = 0; + $hasExplicitMarker = false; + + foreach ($lines as $line) { + if (preg_match('/;\s*$/u', $line)) { + $semicolonCount++; + } + if (preg_match('/^[\p{Lu}0-9]/u', $line)) { + $capitalStartCount++; + } + if (preg_match('/^[\x{00AD}\x{FEFF}]?[\x{2014}\x{2013}\-\x{2022}\x{25CF}*]/u', $line)) { + $hasExplicitMarker = true; + } + } + + if ($hasExplicitMarker) { + return false; + } + + if ($semicolonCount >= count($lines) - 1) { + return true; + } + + return $capitalStartCount >= count($lines); + } + + private function formatChangelogHtml($text) + { + $text = trim((string) $text); + if ($text === '') { + return '

No changelog available for this version.

'; + } + + $lines = preg_split('/\r\n|\r|\n/', $text); + $lines = array_values(array_filter(array_map('trim', $lines))); + + if (empty($lines)) { + return '

No changelog available for this version.

'; + } + + $bullet = "\u{2022}"; + $processed = []; + $isImplicitList = $this->looksLikeImplicitList($lines); + + foreach ($lines as $line) { + if (preg_match('/^[\x{00AD}\x{FEFF}]?([\x{2460}-\x{2473}])\s*(.*)$/u', $line, $m)) { + $num = mb_ord($m[1], 'UTF-8') - 0x245F; + $clean = trim($m[2]); + if ($clean !== '') { + $clean = mb_strtoupper(mb_substr($clean, 0, 1)) . mb_substr($clean, 1); + $processed[] = ['type' => 'numbered', 'num' => $num, 'text' => $clean]; + } + continue; + } + + if (preg_match('/^[\x{00AD}\x{FEFF}]?\x{25CF}\s*(.*)$/u', $line, $m)) { + $clean = trim($m[1]); + if ($clean !== '') { + $clean = mb_strtoupper(mb_substr($clean, 0, 1)) . mb_substr($clean, 1); + $processed[] = ['type' => 'bullet', 'text' => $clean]; + } + continue; + } + + if (preg_match('/^[\x{00AD}\x{FEFF}]?[\x{2014}\x{2013}\-\x{2022}*]\s*(.*)$/u', $line, $m)) { + $clean = trim($m[1]); + if ($clean !== '') { + $clean = mb_strtoupper(mb_substr($clean, 0, 1)) . mb_substr($clean, 1); + $processed[] = ['type' => 'bullet', 'text' => $clean]; + } + continue; + } + + if ($line !== '') { + if ($isImplicitList) { + $clean = rtrim($line, '; '); + $clean = mb_strtoupper(mb_substr($clean, 0, 1)) . mb_substr($clean, 1); + $processed[] = ['type' => 'bullet', 'text' => $clean]; + } else { + $processed[] = ['type' => 'plain', 'text' => $line]; + } + } + } + + if (empty($processed)) { + return '

No changelog available for this version.

'; + } + + $formatted = []; + foreach ($processed as $item) { + $escaped = htmlspecialchars($item['text']); + if ($item['type'] === 'plain') { + $formatted[] = $escaped; + } elseif (count($processed) === 1) { + $formatted[] = $escaped; + } elseif ($item['type'] === 'numbered') { + $formatted[] = $item['num'] . '. ' . $escaped; + } else { + $formatted[] = $bullet . ' ' . $escaped; + } + } + + return '

' + . implode('
', $formatted) + . '

'; + } + + private function buildItem($version, $package) + { + $versionName = $version['versionName']; + $content = $this->formatChangelogHtml($version['whatsNew'] ?? ''); + + $timestamp = time(); + if (!empty($version['date'])) { + $ts = strtotime($version['date']); + if ($ts !== false) $timestamp = $ts; + } + + return [ + 'uri' => self::BASE_URL . urlencode($package) . '/versions#v' . urlencode($versionName), + 'title' => $versionName, + 'content' => $content, + 'uid' => 'rustore-' . $package . '-' . $versionName, + 'timestamp' => $timestamp, + ]; + } +} \ No newline at end of file From 807c6bc37327c2b489d9d5bce93911e4b091e645 Mon Sep 17 00:00:00 2001 From: LordArrin <79581469+LordArrin@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:16:40 +0300 Subject: [PATCH 2/2] update user-agent, fix for angry checker --- bridges/RuStoreBridge.php | 51 ++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/bridges/RuStoreBridge.php b/bridges/RuStoreBridge.php index 49e811d8edb..2481722c074 100644 --- a/bridges/RuStoreBridge.php +++ b/bridges/RuStoreBridge.php @@ -28,7 +28,7 @@ class RuStoreBridge extends BridgeAbstract ]; const HTTP_HEADERS = [ - 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36', 'Accept: text/html,application/xhtml+xml,application/xml', 'Referer: https://www.rustore.ru/', ]; @@ -63,15 +63,28 @@ public function collectData() } } - public function getName() { return $this->appName ?: parent::getName(); } - public function getURI() { return $this->getInput('package') ? self::BASE_URL . urlencode($this->getInput('package')) : parent::getURI(); } - public function getIcon() { return $this->appIcon ?: parent::getIcon(); } + public function getName() + { + return $this->appName ?: parent::getName(); + } + + public function getURI() + { + return $this->getInput('package') ? self::BASE_URL . urlencode($this->getInput('package')) : parent::getURI(); + } + + public function getIcon() + { + return $this->appIcon ?: parent::getIcon(); + } private function extractMeta($html) { foreach ($html->find('script[type="application/ld+json"]') as $script) { $data = json_decode(trim($script->innertext), true); - if (!is_array($data)) continue; + if (!is_array($data)) { + continue; + } if (($data['@type'] ?? '') === 'BreadcrumbList' && !empty($data['itemListElement'])) { $last = end($data['itemListElement']); @@ -92,10 +105,14 @@ private function parseJsonLd($html) foreach ($html->find('script[type="application/ld+json"]') as $script) { $data = json_decode(trim($script->innertext), true); - if (!is_array($data) || ($data['@type'] ?? '') !== 'ItemList') continue; + if (!is_array($data) || ($data['@type'] ?? '') !== 'ItemList') { + continue; + } foreach ($data['itemListElement'] ?? [] as $element) { - if (($element['@type'] ?? '') !== 'UpdateAction' || empty($element['name'])) continue; + if (($element['@type'] ?? '') !== 'UpdateAction' || empty($element['name'])) { + continue; + } $versions[] = [ 'versionName' => $element['name'], @@ -104,7 +121,9 @@ private function parseJsonLd($html) ]; } - if (!empty($versions)) break; + if (!empty($versions)) { + break; + } } return $versions; @@ -119,17 +138,23 @@ private function parseNextJsPayload($html) } } - if (empty($allContent)) return []; + if (empty($allContent)) { + return []; + } if (preg_match('/"versions"\s*:\s*\[(.+?)\]\s*,\s*"continuation"/s', $allContent, $match)) { $json = stripcslashes('[' . $match[1] . ']'); $data = json_decode($json, true); - if (!is_array($data)) return []; + if (!is_array($data)) { + return []; + } $versions = []; foreach ($data as $v) { - if (empty($v['versionName'])) continue; + if (empty($v['versionName'])) { + continue; + } $versions[] = [ 'versionName' => $v['versionName'], 'whatsNew' => $v['whatsNew'] ?? '', @@ -264,7 +289,9 @@ private function buildItem($version, $package) $timestamp = time(); if (!empty($version['date'])) { $ts = strtotime($version['date']); - if ($ts !== false) $timestamp = $ts; + if ($ts !== false) { + $timestamp = $ts; + } } return [