Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/ClientBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +28,8 @@ final class ClientBuilder

private ?Profile $profile = null;

private ?Platform $platform = null;

private ?TransportInterface $transport = null;

private ?Proxy $proxy = null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<MiddlewareInterface> */
private function buildMiddlewares(): array
{
Expand Down
7 changes: 7 additions & 0 deletions src/Emulation/Browser.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,11 @@ public function impersonateTarget(): ?string
default => null,
};
}

public static function random(): self
{
$cases = self::cases();

return $cases[array_rand($cases)];
}
}
51 changes: 51 additions & 0 deletions src/Emulation/Platform.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Reqxide\Emulation;

enum Platform: string
{
case MacOS = 'macos';
case Windows = 'windows';
case Linux = 'linux';
case Android = 'android';
case IOS = 'ios';

public function secChUaPlatform(): string
{
return match ($this) {
self::MacOS => '"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)];
}
}
47 changes: 47 additions & 0 deletions tests/Unit/ClientBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
Expand Down
47 changes: 47 additions & 0 deletions tests/Unit/Emulation/PlatformTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

use Reqxide\Emulation\Platform;

it('has the correct number of cases', function (): void {
expect(Platform::cases())->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);
});
Loading