diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index b8c49c3..3b11299 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -10,6 +10,7 @@ use Reqxide\Contract\TransportInterface; use Reqxide\Cookie\CookieJar; use Reqxide\Emulation\Browser; +use Reqxide\Emulation\Platform; use Reqxide\Emulation\Profile; use Reqxide\Middleware\CompressionMiddleware; use Reqxide\Middleware\CookieMiddleware; @@ -27,6 +28,8 @@ final class ClientBuilder private ?Profile $profile = null; + private ?Platform $platform = null; + private ?TransportInterface $transport = null; private ?Proxy $proxy = null; @@ -64,6 +67,13 @@ public function profile(Profile $profile): self return $this; } + public function platform(Platform $platform): self + { + $this->platform = $platform; + + return $this; + } + public function transport(TransportInterface $transport): self { $this->transport = $transport; @@ -145,6 +155,11 @@ public function build(): Client // Resolve profile $profile = $this->profile ?? $this->browser?->profile() ?? new Profile; + // Apply platform overrides to profile headers + if ($this->platform instanceof Platform) { + $profile = $this->applyPlatform($profile, $this->platform); + } + // Resolve transport $transport = $this->transport ?? TransportFactory::create(); @@ -172,6 +187,62 @@ public function build(): Client ); } + private function applyPlatform(Profile $profile, Platform $platform): Profile + { + $headers = $profile->defaultHeaders; + + if (isset($headers['User-Agent'])) { + $headers['User-Agent'] = $this->rewriteUserAgent($headers['User-Agent'], $platform); + } + + if (isset($headers['sec-ch-ua-platform'])) { + $headers['sec-ch-ua-platform'] = $platform->secChUaPlatform(); + } + + if (isset($headers['sec-ch-ua-mobile'])) { + $headers['sec-ch-ua-mobile'] = $platform->isMobile() ? '?1' : '?0'; + } + + return new Profile( + tlsOptions: $profile->tlsOptions, + http2Options: $profile->http2Options, + http1Options: $profile->http1Options, + defaultHeaders: $headers, + originalHeaderMap: $profile->originalHeaderMap, + connectionGroup: $profile->connectionGroup, + impersonateTarget: $profile->impersonateTarget, + ); + } + + private function rewriteUserAgent(string $ua, Platform $platform): string + { + $platformString = $platform->userAgentPlatform(); + + // Chrome/Edge: Mozilla/5.0 ({platform}) AppleWebKit/... + if (preg_match('#^(Mozilla/5\.0 \()([^)]+)(\) AppleWebKit/537\.36.+)$#', $ua, $m)) { + if ($platform === Platform::IOS) { + $suffix = preg_replace('#Chrome/[\d.]+#', 'CriOS/$0', $m[3]) ?? $m[3]; + $suffix = str_replace('CriOS/CriOS/', 'CriOS/', $suffix); + + return $m[1].$platformString.$suffix; + } + + return $m[1].$platformString.$m[3]; + } + + // Firefox: Mozilla/5.0 ({platform}; rv:{ver}) Gecko/... + if (preg_match('#^(Mozilla/5\.0 \()([^;]+(?:;[^)]*)?)(; rv:[\d.]+\) Gecko/.+)$#', $ua, $m)) { + return $m[1].$platformString.$m[3]; + } + + // Safari: Mozilla/5.0 ({platform}) AppleWebKit/605... + if (preg_match('#^(Mozilla/5\.0 \()([^)]+)(\) AppleWebKit/605.+)$#', $ua, $m)) { + return $m[1].$platformString.$m[3]; + } + + return $ua; + } + /** @return list */ private function buildMiddlewares(): array { diff --git a/src/Emulation/Browser.php b/src/Emulation/Browser.php index dc6c5c3..e77042c 100644 --- a/src/Emulation/Browser.php +++ b/src/Emulation/Browser.php @@ -115,4 +115,11 @@ public function impersonateTarget(): ?string default => null, }; } + + public static function random(): self + { + $cases = self::cases(); + + return $cases[array_rand($cases)]; + } } diff --git a/src/Emulation/Platform.php b/src/Emulation/Platform.php new file mode 100644 index 0000000..b124f26 --- /dev/null +++ b/src/Emulation/Platform.php @@ -0,0 +1,51 @@ + '"macOS"', + self::Windows => '"Windows"', + self::Linux => '"Linux"', + self::Android => '"Android"', + self::IOS => '"iOS"', + }; + } + + public function isMobile(): bool + { + return match ($this) { + self::Android, self::IOS => true, + default => false, + }; + } + + public function userAgentPlatform(): string + { + return match ($this) { + self::MacOS => 'Macintosh; Intel Mac OS X 10_15_7', + self::Windows => 'Windows NT 10.0; Win64; x64', + self::Linux => 'X11; Linux x86_64', + self::Android => 'Linux; Android 10; K', + self::IOS => 'iPhone; CPU iPhone OS 18_0 like Mac OS X', + }; + } + + public static function random(): self + { + $cases = self::cases(); + + return $cases[array_rand($cases)]; + } +} diff --git a/tests/Unit/ClientBuilderTest.php b/tests/Unit/ClientBuilderTest.php index fc0e3a9..3204074 100644 --- a/tests/Unit/ClientBuilderTest.php +++ b/tests/Unit/ClientBuilderTest.php @@ -11,6 +11,7 @@ use Reqxide\Contract\TransportInterface; use Reqxide\Cookie\CookieJar; use Reqxide\Emulation\Browser; +use Reqxide\Emulation\Platform; use Reqxide\Emulation\Profile; use Reqxide\Proxy\Proxy; use Reqxide\Redirect\RedirectPolicy; @@ -264,6 +265,52 @@ public function supportsHttp2Configuration(): bool expect($builder)->toBeInstanceOf(ClientBuilder::class); }); +it('platform overrides sec-ch-ua-platform and User-Agent', function (): void { + $transport = createMockTransport(); + $client = Client::builder() + ->emulation(Browser::Chrome145) + ->platform(Platform::Windows) + ->transport($transport) + ->build(); + + $request = new Request('GET', 'https://example.com'); + $client->sendRequest($request); + + expect($transport->lastProfile->defaultHeaders['sec-ch-ua-platform'])->toBe('"Windows"') + ->and($transport->lastProfile->defaultHeaders['sec-ch-ua-mobile'])->toBe('?0') + ->and($transport->lastProfile->defaultHeaders['User-Agent'])->toContain('Windows NT 10.0'); +}); + +it('platform Android sets mobile flag', function (): void { + $transport = createMockTransport(); + $client = Client::builder() + ->emulation(Browser::Chrome145) + ->platform(Platform::Android) + ->transport($transport) + ->build(); + + $request = new Request('GET', 'https://example.com'); + $client->sendRequest($request); + + expect($transport->lastProfile->defaultHeaders['sec-ch-ua-mobile'])->toBe('?1') + ->and($transport->lastProfile->defaultHeaders['sec-ch-ua-platform'])->toBe('"Android"') + ->and($transport->lastProfile->defaultHeaders['User-Agent'])->toContain('Android'); +}); + +it('platform does not affect profile without relevant headers', function (): void { + $transport = createMockTransport(); + $client = Client::builder() + ->profile(new Profile(defaultHeaders: ['X-Custom' => 'value'])) + ->platform(Platform::Linux) + ->transport($transport) + ->build(); + + $request = new Request('GET', 'https://example.com'); + $client->sendRequest($request); + + expect($transport->lastProfile->defaultHeaders)->toBe(['X-Custom' => 'value']); +}); + it('profile takes precedence over emulation', function (): void { $transport = createMockTransport(); $customProfile = new Profile(defaultHeaders: ['X-Source' => 'custom-profile']); diff --git a/tests/Unit/Emulation/PlatformTest.php b/tests/Unit/Emulation/PlatformTest.php new file mode 100644 index 0000000..dbc5726 --- /dev/null +++ b/tests/Unit/Emulation/PlatformTest.php @@ -0,0 +1,47 @@ +toHaveCount(5); +}); + +it('has correct backed values', function (Platform $case, string $expected): void { + expect($case->value)->toBe($expected); +})->with([ + [Platform::MacOS, 'macos'], + [Platform::Windows, 'windows'], + [Platform::Linux, 'linux'], + [Platform::Android, 'android'], + [Platform::IOS, 'ios'], +]); + +it('returns correct sec-ch-ua-platform', function (Platform $case, string $expected): void { + expect($case->secChUaPlatform())->toBe($expected); +})->with([ + [Platform::MacOS, '"macOS"'], + [Platform::Windows, '"Windows"'], + [Platform::Linux, '"Linux"'], + [Platform::Android, '"Android"'], + [Platform::IOS, '"iOS"'], +]); + +it('identifies mobile platforms', function (): void { + expect(Platform::Android->isMobile())->toBeTrue() + ->and(Platform::IOS->isMobile())->toBeTrue() + ->and(Platform::MacOS->isMobile())->toBeFalse() + ->and(Platform::Windows->isMobile())->toBeFalse() + ->and(Platform::Linux->isMobile())->toBeFalse(); +}); + +it('returns platform-specific UA string', function (Platform $case): void { + expect($case->userAgentPlatform())->toBeString()->not->toBeEmpty(); +})->with(Platform::cases()); + +it('random returns a valid Platform', function (): void { + $platform = Platform::random(); + + expect($platform)->toBeInstanceOf(Platform::class); +});