Add tech-stack fingerprinting (server, powered-by, generator)

WarcRecordFetcher was splitting out and discarding the HTTP response
headers to get at the HTML body — now it keeps and parses them, so
Server/X-Powered-By headers get captured alongside the <meta
name="generator"> tag already sitting in the parsed DOM. No extra
requests: same WARC segment already fetched for --with-metadata.

Site copies these from its homepage capture, same as
title/description/keywords. New badges on the site detail page show
the detected platform (e.g. "Wix.com Website Builder") and server
signature at a glance.

Verified against real .es data: correctly detected Wix
("00padel.es"), nginx/Apache/Cloudflare server headers, and PHP
X-Powered-By headers across the batch.
This commit is contained in:
m105
2026-07-22 22:14:40 +03:00
parent 6e6be7d7c6
commit c7299fe928
6 changed files with 112 additions and 9 deletions
+3
View File
@@ -14,6 +14,9 @@ class Site extends Model
'title', 'title',
'description', 'description',
'keywords', 'keywords',
'server',
'powered_by',
'generator',
]; ];
public function pages(): HasMany public function pages(): HasMany
+3
View File
@@ -27,6 +27,9 @@ class SitePage extends Model
'title', 'title',
'description', 'description',
'keywords', 'keywords',
'server',
'powered_by',
'generator',
'content', 'content',
'raw', 'raw',
'record_hash', 'record_hash',
@@ -125,6 +125,9 @@ class CommonCrawlIndexService
'title' => $content['title'], 'title' => $content['title'],
'description' => $content['description'], 'description' => $content['description'],
'keywords' => $content['keywords'], 'keywords' => $content['keywords'],
'server' => $content['server'],
'powered_by' => $content['powered_by'],
'generator' => $content['generator'],
]); ]);
} }
} }
+47 -9
View File
@@ -47,16 +47,19 @@ class WarcRecordFetcher
return []; return [];
} }
$html = $this->extractHtmlFromWarcSegment($response->body()); $segment = $this->extractHtmlFromWarcSegment($response->body());
return $html === null ? [] : $this->extractHtmlMeta($html, $attributes['url']); return $segment === null
? []
: $this->extractHtmlMeta($segment['html'], $attributes['url'], $segment['headers']);
} }
/** /**
* A WARC segment (gzip-compressed) contains WARC headers, then the raw HTTP * A WARC segment (gzip-compressed) contains WARC headers, then the raw HTTP
* response (its own headers followed by the HTML body). Strip both header blocks. * response (its own headers followed by the HTML body). Strip the WARC header
* block; the HTTP header block is kept (for Server/X-Powered-By) alongside the HTML.
*/ */
private function extractHtmlFromWarcSegment(string $gzipped): ?string private function extractHtmlFromWarcSegment(string $gzipped): ?array
{ {
$decompressed = @gzdecode($gzipped); $decompressed = @gzdecode($gzipped);
@@ -72,12 +75,45 @@ class WarcRecordFetcher
[$httpHeaders, $html] = array_pad(preg_split("/\r?\n\r?\n/", $rest, 2), 2, null); [$httpHeaders, $html] = array_pad(preg_split("/\r?\n\r?\n/", $rest, 2), 2, null);
return $html; return ['headers' => $httpHeaders ?? '', 'html' => $html];
} }
private function extractHtmlMeta(string $html, string $pageUrl): array /**
* Parses a raw HTTP header block (status line + "Name: value" lines) into a
* lowercase-keyed map. First occurrence of a header name wins.
*/
private function parseHttpHeaders(string $raw): array
{ {
$meta = ['title' => null, 'description' => null, 'keywords' => null, 'content' => null, 'links' => []]; $headers = [];
foreach (preg_split("/\r?\n/", $raw) as $index => $line) {
if ($index === 0 || ! str_contains($line, ':')) {
continue; // line 0 is the "HTTP/1.1 200 OK" status line, not a header
}
[$name, $value] = explode(':', $line, 2);
$key = strtolower(trim($name));
$headers[$key] ??= trim($value);
}
return $headers;
}
private function extractHtmlMeta(string $html, string $pageUrl, string $rawHttpHeaders): array
{
$headers = $this->parseHttpHeaders($rawHttpHeaders);
$meta = [
'title' => null,
'description' => null,
'keywords' => null,
'generator' => null,
'server' => isset($headers['server']) ? mb_substr($headers['server'], 0, 255) : null,
'powered_by' => isset($headers['x-powered-by']) ? mb_substr($headers['x-powered-by'], 0, 255) : null,
'content' => null,
'links' => [],
];
$document = new DOMDocument; $document = new DOMDocument;
@@ -93,11 +129,13 @@ class WarcRecordFetcher
$meta['title'] = trim(preg_replace('/\s+/', ' ', $titleNode->textContent)) ?: null; $meta['title'] = trim(preg_replace('/\s+/', ' ', $titleNode->textContent)) ?: null;
} }
foreach (['description', 'keywords'] as $name) { foreach (['description', 'keywords', 'generator'] as $name) {
$node = $xpath->query("//meta[translate(@name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='{$name}']")->item(0); $node = $xpath->query("//meta[translate(@name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='{$name}']")->item(0);
if ($node && $node->getAttribute('content') !== '') { if ($node && $node->getAttribute('content') !== '') {
$meta[$name] = trim(preg_replace('/\s+/', ' ', $node->getAttribute('content'))); $value = trim(preg_replace('/\s+/', ' ', $node->getAttribute('content')));
// generator is stored in a varchar column; description/keywords are text.
$meta[$name] = $name === 'generator' ? mb_substr($value, 0, 255) : $value;
} }
} }
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Server/X-Powered-By (from HTTP response headers, already downloaded alongside the
* HTML but previously discarded) and generator (from <meta name="generator">) — cheap
* tech-stack signals captured for free during the existing --with-metadata WARC fetch.
*/
public function up(): void
{
Schema::table('site_pages', function (Blueprint $table) {
$table->string('server')->nullable()->after('keywords');
$table->string('powered_by')->nullable()->after('server');
$table->string('generator')->nullable()->after('powered_by');
});
Schema::table('sites', function (Blueprint $table) {
$table->string('server')->nullable()->after('keywords');
$table->string('powered_by')->nullable()->after('server');
$table->string('generator')->nullable()->after('powered_by');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('site_pages', function (Blueprint $table) {
$table->dropColumn(['server', 'powered_by', 'generator']);
});
Schema::table('sites', function (Blueprint $table) {
$table->dropColumn(['server', 'powered_by', 'generator']);
});
}
};
+14
View File
@@ -18,6 +18,20 @@
</p> </p>
@endif @endif
@if ($site->server || $site->powered_by || $site->generator)
<div class="mt-3 flex flex-wrap gap-2">
@if ($site->generator)
<span class="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">{{ $site->generator }}</span>
@endif
@if ($site->server)
<span class="rounded-full bg-neutral-100 px-3 py-1 text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">Server: {{ $site->server }}</span>
@endif
@if ($site->powered_by)
<span class="rounded-full bg-neutral-100 px-3 py-1 text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">Powered by: {{ $site->powered_by }}</span>
@endif
</div>
@endif
<h2 class="mt-8 text-lg font-medium">Pages ({{ $pages->total() }})</h2> <h2 class="mt-8 text-lg font-medium">Pages ({{ $pages->total() }})</h2>
<div class="mt-4 overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800"> <div class="mt-4 overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">