[], 'excluded' => [], 'nodes' => []]; foreach ($sources as $source) { $name = $source['name']; $stats = ['uri_read' => 0, 'clash_read' => 0, 'invalid' => 0]; foreach (preg_split('/\r\n|\n|\r/', $source['v2ray'] ?? '') as $line) { if (trim($line) === '') continue; $stats['uri_read']++; try { $node = Codec::parse($line); $id = $node['id']; if (!isset($uri[$id])) $uri[$id] = $node; $uri[$id]['sources'][$name] = true; } catch (Throwable $e) { $stats['invalid']++; $reason = $e instanceof InvalidArgumentException ? $e->getMessage() : 'invalid_uri'; $report['excluded'][$name][$reason] = ($report['excluded'][$name][$reason] ?? 0) + 1; } } if (!empty($source['clash'])) { try { $data = Yaml::parse(preg_replace('/^\xEF\xBB\xBF/', '', $source['clash'])); if (!is_array($data['proxies'] ?? null)) throw new RuntimeException('no_proxies'); } catch (Throwable $e) { throw new RuntimeException('invalid_source_yaml:'.$name); } foreach ($data['proxies'] as $raw) { $stats['clash_read']++; try { if (!is_array($raw) || !is_string($raw['type'] ?? null)) throw new RuntimeException(); Codec::endpoint((string) ($raw['server'] ?? ''), (int) ($raw['port'] ?? 0)); $originalName = (string) ($raw['name'] ?? $raw['type']); unset($raw['name']); $sorted = $raw; ksort($sorted); $id = hash('sha256', json_encode($sorted, JSON_THROW_ON_ERROR)); if (!isset($clash[$id])) $clash[$id] = ['id' => $id, 'name' => $originalName, 'data' => $raw, 'sources' => []]; $clash[$id]['sources'][$name] = true; } catch (Throwable $e) { $stats['invalid']++; $report['excluded'][$name]['invalid_clash'] = ($report['excluded'][$name]['invalid_clash'] ?? 0) + 1; } } } if ($stats['uri_read'] + $stats['clash_read'] === 0) throw new RuntimeException('empty_source:'.$name); $report['sources'][$name] = $stats; } if (!$uri || !$clash) throw new RuntimeException('empty_input_format'); ksort($uri); ksort($clash); $lines = []; $singbox = []; $proxies = []; foreach ($uri as $id => $node) { $label = $this->label($node['name'], $id); $line = Codec::uri($node, $label); $roundtrip = Codec::parse($line); if ($roundtrip['id'] !== $id) throw new RuntimeException('uri_roundtrip_failed'); $lines[$id] = $line; $report['nodes']['uri'][$id] = array_keys($node['sources']); try { $singbox[$id] = Codec::singbox($node, $label); } catch (Throwable $e) { $reason = $e instanceof InvalidArgumentException ? $e->getMessage() : 'conversion_error'; $report['excluded']['singbox'][$reason] = ($report['excluded']['singbox'][$reason] ?? 0) + 1; } } foreach ($clash as $id => $node) { $proxies[$id] = ['name' => $this->label($node['name'], $id)] + $node['data']; $report['nodes']['clash'][$id] = array_keys($node['sources']); } $singbox = $validator->filter('singbox', $singbox); $proxies = $validator->filter('clash', $proxies); if (!$singbox || !$proxies) throw new RuntimeException('empty_validated_format'); $outputs = ['v2ray' => base64_encode(implode("\n", $lines)), 'singbox' => $validator->render('singbox', $singbox), 'clash' => $validator->render('clash', $proxies)]; if (!$validator->check('singbox', $outputs['singbox']) || !$validator->check('clash', $outputs['clash'])) throw new RuntimeException('final_validation_failed'); $files = []; foreach (array_merge([['code' => 'base', 'clients' => array_keys($outputs)]], $sites) as $site) { if (!preg_match('/^[a-zA-Z0-9_.-]+$/D', $site['code'])) throw new RuntimeException('invalid_site_code'); foreach ($site['clients'] as $client) { if (!isset($outputs[$client])) throw new RuntimeException('unsupported_output_format'); $files[$site['code'].'/'.$client] = $outputs[$client]; } } $report['counts'] = ['v2ray' => count($lines), 'clash' => count($proxies), 'singbox' => count($singbox)]; $report['native_rejected'] = $validator->rejected; $report['native_checks'] = $validator->checks; return ['files' => $files, 'report' => $report]; } private function label(string $name, string $id): string { $name = preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?? 'Node'; return mb_substr($name, 0, 80).' ['.substr($id, 0, 12).']'; } }