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:
@@ -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
|
||||||
|
|||||||
@@ -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,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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user