Native PHP 8.3: Archive Important Web Pages Weekly with Screenshot API
A website can change quietly. A supplier edits a product page, a booking button disappears, or a redesign alters a promotion that was supposed to remain visible all month. Backups preserve files and databases, but they do not show what a customer actually saw.
This project builds a small, production-minded archive that captures each important page once per ISO week. It uses Native PHP 8.3, native cURL, deterministic retries, atomic storage, structured metadata, and an idempotent scheduled command. The result is a browsable visual history without operating Chromium, browser drivers, or a rendering cluster.
Get access to the Screenshot API
Start by registering an account, or use the sign-in page if you already have one.
- Open the Screenshot API service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official documentation.
- Find the Service token panel and copy the service-scoped token.
- Store that token in the project environment configuration, never in PHP source code.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses a Bearer token so the credential does not appear in URLs, proxy access logs, or shell histories. Regenerating the service token revokes the previously active token, so deployments must be updated together during rotation.
Confirm the endpoint before writing the application
The exact request is GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, with the target page supplied in the required url query parameter. A successful response carries an image/png body plus cache and quota-related response headers.
Make a minimal test while keeping the token in an environment variable:
export SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body --silent --show-error \
--get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
--data-urlencode 'url=https://www.example.com/' \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
--dump-header response-headers.txt \
--output example.png
file example.png
Inspect response-headers.txt rather than assuming particular cache or quota header names. The documented contract requires handling those headers, but application code should normalize and preserve what the service actually returns.
Create a local .env file, exclude it from version control, and restrict it to the account running the command:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_ARCHIVE_DIR=/srv/page-archive/data
Choose a deliberately small architecture
A CLI command is a better boundary than a web controller here. Captures are scheduled work, may take longer than an interactive request, and must not become a public screenshot proxy. A dedicated API client owns authentication, timeouts, retries, response validation, and domain mapping. The command owns page selection, weekly idempotency, logging, and storage.
The archive uses one PNG and one JSON manifest per page and ISO week. The manifest records status, attempt count, and the returned cache or quota metadata. It never stores the token or a failed response body.
page-archive/
├── bin/archive.php
├── config/pages.php
├── src/Http.php
├── src/ScreenshotClient.php
├── tests/ScreenshotClientTest.php
├── .env
└── composer.json
Use Composer only for autoloading and PHPUnit. Runtime HTTP remains native:
{
"require": {
"php": "^8.3",
"ext-curl": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
Build a replaceable HTTP boundary
The transport does one network attempt. Retry policy belongs to the higher-level client, where HTTP status and domain meaning are available. Disabling redirects prevents an unexpected endpoint redirect from carrying the Authorization header elsewhere.
<?php
// src/Http.php
declare(strict_types=1);
namespace App;
final readonly class HttpResponse
{
/** @param array<string, list<string>> $headers */
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
final class TransportException extends \RuntimeException {}
interface Transport
{
/** @param list<string> $headers */
public function get(
string $url,
array $headers,
float $connectTimeout,
float $timeout,
): HttpResponse;
}
final class CurlTransport implements Transport
{
public function get(
string $url,
array $headers,
float $connectTimeout,
float $timeout,
): HttpResponse {
$received = [];
$handle = curl_init($url);
if ($handle === false) {
throw new TransportException('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT_MS => (int) ($connectTimeout * 1000),
CURLOPT_TIMEOUT_MS => (int) ($timeout * 1000),
CURLOPT_HEADERFUNCTION => static function (
\CurlHandle $handle,
string $line
) use (&$received): int {
$length = strlen($line);
if (str_starts_with($line, 'HTTP/')) {
$received = [];
} elseif (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$received[strtolower(trim($name))][] = trim($value);
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
throw new TransportException(
'Screenshot transport failed: ' . curl_error($handle)
);
}
return new HttpResponse(
(int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
$received,
$body,
);
}
}
Map HTTP responses into domain outcomes
The client verifies both Content-Type and the PNG signature. That matters because an upstream HTML error page can otherwise be archived with a misleading .png extension.
Only network errors, HTTP 429, and 5xx responses are retried. Authentication, validation, and other client errors return immediately. Delays are bounded, and an integer Retry-After value is honored up to five seconds.
<?php
// src/ScreenshotClient.php
declare(strict_types=1);
namespace App;
enum FailureKind: string
{
case Authentication = 'authentication';
case Validation = 'validation';
case Quota = 'quota';
case Upstream = 'upstream';
case Network = 'network';
case InvalidResponse = 'invalid_response';
}
final readonly class CaptureOutcome
{
/** @param array<string, list<string>> $metadata */
public function __construct(
public ?string $png,
public ?FailureKind $failure,
public ?int $status,
public int $attempts,
public array $metadata,
public string $message,
) {}
public function succeeded(): bool
{
return $this->png !== null;
}
}
final class ScreenshotClient
{
private \Closure $sleep;
public function __construct(
private readonly string $token,
private readonly Transport $transport,
?\Closure $sleep = null,
) {
if ($token === '') {
throw new \InvalidArgumentException('Screenshot token is missing');
}
$this->sleep = $sleep ?? static fn (int $microseconds) =>
usleep($microseconds);
}
public function capture(string $pageUrl): CaptureOutcome
{
$parts = parse_url($pageUrl);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
if (
filter_var($pageUrl, FILTER_VALIDATE_URL) === false
|| !in_array($scheme, ['http', 'https'], true)
|| isset($parts['user'])
|| isset($parts['pass'])
) {
return new CaptureOutcome(
null,
FailureKind::Validation,
null,
0,
[],
'Configured page URL is invalid'
);
}
$endpoint = 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture?'
. http_build_query(['url' => $pageUrl], '', '&', PHP_QUERY_RFC3986);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get(
$endpoint,
[
'Authorization: Bearer ' . $this->token,
'Accept: image/png',
],
5.0,
30.0,
);
} catch (TransportException $exception) {
if ($attempt < 3) {
($this->sleep)($attempt === 1 ? 250000 : 1000000);
continue;
}
return new CaptureOutcome(
null,
FailureKind::Network,
null,
$attempt,
[],
$exception->getMessage()
);
}
$metadata = $this->responseMetadata($response->headers);
if ($response->status >= 200 && $response->status < 300) {
$type = strtolower($response->headers['content-type'][0] ?? '');
$isPng = str_starts_with($type, 'image/png')
&& str_starts_with($response->body, "\x89PNG\r\n\x1a\n");
return $isPng
? new CaptureOutcome(
$response->body,
null,
$response->status,
$attempt,
$metadata,
'capture_complete'
)
: new CaptureOutcome(
null,
FailureKind::InvalidResponse,
$response->status,
$attempt,
$metadata,
'Successful response was not a valid PNG'
);
}
$retryable = $response->status === 429
|| $response->status >= 500;
if ($retryable && $attempt < 3) {
$seconds = $attempt === 1 ? 0.25 : 1.0;
$retryAfter = $response->headers['retry-after'][0] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
$seconds = min(5.0, (float) $retryAfter);
}
($this->sleep)((int) ($seconds * 1000000));
continue;
}
$kind = match (true) {
in_array($response->status, [401, 403], true)
=> FailureKind::Authentication,
$response->status === 429 => FailureKind::Quota,
$response->status >= 500 => FailureKind::Upstream,
$response->status >= 400 && $response->status < 500
=> FailureKind::Validation,
default => FailureKind::InvalidResponse,
};
return new CaptureOutcome(
null,
$kind,
$response->status,
$attempt,
$metadata,
'Screenshot request failed'
);
}
throw new \LogicException('Retry loop ended unexpectedly');
}
/** @return array<string, list<string>> */
private function responseMetadata(array $headers): array
{
return array_filter(
$headers,
static fn (string $name): bool =>
preg_match(
'/cache|quota|rate.?limit|^age$|^etag$|^expires$|^retry-after$/i',
$name
) === 1,
ARRAY_FILTER_USE_KEY,
);
}
}
Write one archive per page and week
Keep the page inventory in trusted configuration. Do not accept arbitrary URLs from an HTTP request, because that would expose the account’s quota and could turn the application into a rendering proxy.
<?php
// config/pages.php
return [
'home' => 'https://www.example.com/',
'services' => 'https://www.example.com/services',
'booking' => 'https://www.example.com/book',
];
The command loads simple local environment values when they are not already supplied by the process manager. It obtains an exclusive lock, skips completed weeks, continues after individual page failures, and exits nonzero if any capture fails. Files are written through temporary files in the destination directory, so each rename is atomic on the same filesystem.
<?php
// bin/archive.php
declare(strict_types=1);
use App\CurlTransport;
use App\ScreenshotClient;
require dirname(__DIR__) . '/vendor/autoload.php';
$envFile = dirname(__DIR__) . '/.env';
if (is_file($envFile)) {
$values = parse_ini_file($envFile, false, INI_SCANNER_RAW);
if ($values === false) {
throw new RuntimeException('Cannot parse .env');
}
foreach ($values as $name => $value) {
if (getenv((string) $name) === false) {
putenv($name . '=' . $value);
}
}
}
$token = (string) getenv('SCREENSHOT_API_TOKEN');
$root = (string) getenv('SCREENSHOT_ARCHIVE_DIR');
if ($token === '' || $root === '') {
throw new RuntimeException('Required environment configuration is missing');
}
if (!is_dir($root) && !mkdir($root, 0750, true) && !is_dir($root)) {
throw new RuntimeException('Cannot create archive directory');
}
$lock = fopen($root . '/.weekly.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
throw new RuntimeException('Another archive process is running');
}
$writeAtomic = static function (string $path, string $contents): void {
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0750, true)) {
throw new RuntimeException('Cannot create page directory');
}
$temporary = tempnam($directory, '.capture-');
if ($temporary === false) {
throw new RuntimeException('Cannot allocate temporary file');
}
if (file_put_contents($temporary, $contents, LOCK_EX) === false) {
throw new RuntimeException('Cannot write temporary file');
}
chmod($temporary, 0640);
if (!rename($temporary, $path)) {
throw new RuntimeException('Cannot commit archive file');
}
};
$log = static function (array $context): void {
$record = ['time' => gmdate(DATE_ATOM)] + $context;
fwrite(STDERR, json_encode(
$record,
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
) . PHP_EOL);
};
$client = new ScreenshotClient($token, new CurlTransport());
$pages = require dirname(__DIR__) . '/config/pages.php';
$week = (new DateTimeImmutable('now', new DateTimeZone('UTC')))
->format('o-\WW');
$failed = false;
foreach ($pages as $slug => $url) {
if (preg_match('/^[a-z0-9][a-z0-9-]*$/', $slug) !== 1) {
throw new RuntimeException('Unsafe page slug in configuration');
}
$base = $root . '/' . $slug . '/' . $week;
$pngPath = $base . '.png';
$manifestPath = $base . '.json';
if (is_file($pngPath) && is_file($manifestPath)) {
$log(['event' => 'capture_skipped', 'page' => $slug, 'week' => $week]);
continue;
}
$outcome = $client->capture($url);
$manifest = [
'state' => $outcome->succeeded() ? 'complete' : 'failed',
'page' => $slug,
'url' => $url,
'week' => $week,
'captured_at' => gmdate(DATE_ATOM),
'http_status' => $outcome->status,
'attempts' => $outcome->attempts,
'failure' => $outcome->failure?->value,
'response_metadata' => $outcome->metadata,
];
$writeAtomic(
$manifestPath,
json_encode(
$manifest,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
)
);
if ($outcome->succeeded()) {
$writeAtomic($pngPath, $outcome->png);
$log([
'event' => 'capture_complete',
'page' => $slug,
'week' => $week,
'bytes' => strlen($outcome->png),
'attempts' => $outcome->attempts,
]);
} else {
$failed = true;
$log([
'event' => 'capture_failed',
'page' => $slug,
'week' => $week,
'kind' => $outcome->failure?->value,
'status' => $outcome->status,
'attempts' => $outcome->attempts,
]);
}
}
exit($failed ? 1 : 0);
Test retries without making external calls
A fake transport makes failure paths fast and deterministic. These tests prove that a transient server failure is retried, while an authentication failure is not. They also verify the PNG boundary instead of merely asserting a status code.
<?php
// tests/ScreenshotClientTest.php
declare(strict_types=1);
use App\CaptureOutcome;
use App\FailureKind;
use App\HttpResponse;
use App\ScreenshotClient;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
/** @param list<HttpResponse> $responses */
public function __construct(private array $responses) {}
public function get(
string $url,
array $headers,
float $connectTimeout,
float $timeout,
): HttpResponse {
$this->calls++;
return array_shift($this->responses);
}
}
final class ScreenshotClientTest extends TestCase
{
public function testRetriesServerFailureAndReturnsPng(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
$transport = new FakeTransport([
new HttpResponse(503, ['retry-after' => ['0']], ''),
new HttpResponse(
200,
[
'content-type' => ['image/png'],
'cache-control' => ['public'],
],
$png
),
]);
$client = new ScreenshotClient(
'test-token',
$transport,
static fn (int $microseconds) => null
);
$result = $client->capture('https://www.example.com/');
self::assertTrue($result->succeeded());
self::assertSame($png, $result->png);
self::assertSame(2, $result->attempts);
self::assertSame(2, $transport->calls);
self::assertArrayHasKey('cache-control', $result->metadata);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([
new HttpResponse(401, ['content-type' => ['application/json']], ''),
]);
$client = new ScreenshotClient(
'expired-token',
$transport,
static fn (int $microseconds) => null
);
$result = $client->capture('https://www.example.com/');
self::assertSame(FailureKind::Authentication, $result->failure);
self::assertSame(1, $transport->calls);
}
}
Run composer install, then composer test. Tests contain an obviously synthetic token and never need the real service credential.
Schedule for recovery, not merely punctuality
A daily timer may sound odd for a weekly archive, but the command’s ISO-week key makes it useful: after the first successful run, subsequent executions skip the page. If Monday’s capture fails because of a temporary outage or exhausted quota, Tuesday can repair the missing week automatically.
# /etc/systemd/system/page-archive.service
[Unit]
Description=Capture weekly page archive
[Service]
Type=oneshot
User=pagearchive
Group=pagearchive
WorkingDirectory=/srv/page-archive
EnvironmentFile=/srv/page-archive/.env
ExecStart=/usr/bin/php /srv/page-archive/bin/archive.php
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/page-archive/data
# /etc/systemd/system/page-archive.timer
[Unit]
Description=Check weekly page archive daily
[Timer]
OnCalendar=*-*-* 03:15:00 UTC
Persistent=true
RandomizedDelaySec=20m
[Install]
WantedBy=timers.target
Send the command’s JSON logs to the platform’s normal log collector and alert on nonzero service exits or repeated capture_failed events. Watch stored image size and quota metadata for unexpected changes, but do not log authorization headers, response bodies, or the token.
Common failures and operational safeguards
- 401 or 403: verify the service-scoped token and deployment environment. If it was regenerated, the old token is already revoked. Do not retry these responses automatically.
- 429: inspect the preserved quota and retry metadata, review plan capacity, and avoid adding aggressive retries.
- 400-class validation errors: check the configured URL and its encoding. The client already uses RFC 3986 query encoding.
- Invalid PNG: treat it as an upstream contract failure. Never archive an HTML or JSON body as an image.
- Timeouts or 5xx responses: bounded retries are appropriate, but persistent failures should remain visible through the exit status and manifest.
- Missing files: verify directory ownership, free space, and the systemd write allowlist. Keep archive storage outside the public web root.
Protect .env with restrictive permissions, rotate tokens through the service panel and deployment secret store, and retain screenshots according to the business’s actual needs. Screenshots may contain customer names, unpublished offers, account states, or other sensitive material even when the original page seemed harmless.
Final verification checklist
- The real token exists only in environment-backed configuration.
- The minimal request produces a valid PNG and exposes response headers for inspection.
composer testpasses without network access.- A manual command run creates matching weekly
.pngand.jsonfiles for every configured page. - A second run skips completed captures instead of consuming more quota.
- An intentionally invalid test token produces a structured authentication failure without retries or leaked response content.
- The timer is enabled, logs are collected, and nonzero exits are monitored.
- Archive files are readable only by authorized operators and are backed up according to their value.
The most useful archive is not the most elaborate one. It is the one that runs quietly, proves what customers could see, and fails loudly enough to be repaired. With a narrow API boundary, defensive PNG validation, quota-aware metadata, and an idempotent weekly key, a few pages become a dependable visual record rather than another fragile browser automation project.