Izvorni PHP: Automatsko unaprijed popunjavanje CRM leadova s web-stranica tvrtki
Prodajni predstavnik ne bi trebao kopirati naziv tvrtke, telefonski broj, adresu e-pošte, podatke za kontakt i informacije o timu s web-mjesta u CRM, jedno po jedno polje. Bolji tijek rada traži web-mjesto jednom, obogaćuje nacrt potencijalnog klijenta i prepušta čovjeku provjeru rezultata.
Ovaj vodič gradi taj tijek rada u izvornom PHP-u 8.3. Aplikacija izlaže mali JSON krajnji pristup koji provjerava poslano web-mjesto, poziva uslugu podataka Website to Company, mapira odgovor u objekt domene i vraća polja spremna za CRM. Integracija uključuje ograničena vremenska ograničenja, selektivne ponovne pokušaje, strukturirane pogreške, sigurno zapisivanje u zapisnike i determinističke PHPUnit testove.
Pristupite usluzi i kopirajte token usluge
Prije pisanja integracijskog koda, registrirajte račun ili se prijavite. Otvorite stranicu usluge podataka Website to Company, odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
Zatim otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu. Ova usluga nije bez tokena: svaki zahtjev mora poslati tu vjerodajnicu putem parametra upita token.
Ponovno generiranje tokena opoziva prethodno aktivni token. Tretirajte ponovno generiranje kao rotaciju vjerodajnica: ažurirajte okruženje aplikacije i ponovno implementirajte svaku instancu koja koristi staru vrijednost.
Točna API operacija je GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Potvrdite pristup minimalnim zahtjevom:
curl --fail-with-body --get \
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode 'website=https://example.com'
Zahtjev šalje samo dokumentirane parametre token i website. Nikada nemojte zalijepiti stvarni token u kontrolu izvornog koda, testne fixturee, snimke zaslona ili poruke podršci.
Odaberite namjerno malu arhitekturu
CRM bi trebao pozivati našu aplikaciju, a ne izravno vanjski API. Zadržavanje tokena na poslužiteljskoj strani sprječava izlaganje u pregledniku i daje nam jednu granicu za provjeru, mapiranje odgovora, ponovne pokušaje i telemetriju.
Projekt ima četiri odgovornosti:
- Kontroler: prihvaća web-mjesto prodajnog predstavnika i vraća JSON.
- Klijent: primjenjuje API ugovor i pravilo ponovnih pokušaja.
- Transport: izvršava izvorni cURL zahtjev.
- Mapper domene: pretvara vanjske podatke u stabilan objekt okrenut CRM-u.
crm-prefill/
├── composer.json
├── .env
├── config/bootstrap.php
├── public/index.php
├── src/
│ ├── Company/CompanyProfile.php
│ ├── Company/IntegrationFailure.php
│ ├── Company/WebsiteCompanyClient.php
│ └── Http/
│ ├── CurlTransport.php
│ └── Transport.php
└── tests/WebsiteCompanyClientTest.php
Izradite Composer konfiguraciju i instalirajte PHPUnit. PHP-ovo proširenje cURL jedina je produkcijska ovisnost.
{
"require": {
"php": "^8.3",
"ext-curl": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
composer dump-autoload
php -m | grep curl
Držite konfiguraciju izvan aplikacije
Za lokalni razvoj dodajte .env u .gitignore i tamo pohranite kopirani token:
WEBSITE_COMPANY_TOKEN="YOUR_SERVICE_TOKEN"
Izvorni PHP ne učitava datoteke okruženja automatski. Sljedeći bootstrap podržava jednostavnu lokalnu datoteku, dok varijablama produkcijskog okruženja dopušta prednost:
<?php
// config/bootstrap.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$localFile = dirname(__DIR__) . '/.env';
if (is_file($localFile)) {
$values = parse_ini_file($localFile, false, INI_SCANNER_RAW);
if ($values === false) {
throw new RuntimeException('Unable to parse .env');
}
foreach ($values as $name => $value) {
if (getenv((string) $name) === false) {
putenv($name . '=' . $value);
}
}
}
$token = getenv('WEBSITE_COMPANY_TOKEN');
if (!is_string($token) || trim($token) === '') {
throw new RuntimeException('WEBSITE_COMPANY_TOKEN is not configured');
}
return ['service_token' => $token];
Na produkcijskim PHP-FPM hostovima umetnite WEBSITE_COMPANY_TOKEN putem upravitelja procesa ili spremišta tajni umjesto implementiranja .env. Ako se lokalna datoteka zadrži, držite je izvan javnog korijena dokumenta s restriktivnim dozvolama.
Izgradite ograničeni izvorni cURL transport
Transport ne slijedi preusmjeravanja ni radi praktičnosti ni radi oporavka. To sprječava slučajno prosljeđivanje vjerodajnice iz niza upita drugom hostu. Vremenska ograničenja povezivanja i ukupnog trajanja osiguravaju da spor poziv za obogaćivanje ne može neograničeno zauzimati PHP radnik.
<?php
// src/Http/Transport.php
declare(strict_types=1);
namespace App\Http;
interface Transport
{
/**
* @return array{
* status: int,
* headers: array<string,string>,
* body: string
* }
*/
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs
): array;
}
<?php
// src/Http/CurlTransport.php
declare(strict_types=1);
namespace App\Http;
use RuntimeException;
final class CurlTransport implements Transport
{
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs
): array {
$headers = [];
$requestUrl = $url . '?' . http_build_query(
$query,
'',
'&',
PHP_QUERY_RFC3986
);
$handle = curl_init($requestUrl);
if ($handle === false) {
throw new RuntimeException('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $timeoutMs,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_HEADERFUNCTION => static function (
$curl,
string $line
) use (&$headers): int {
$length = strlen($line);
$position = strpos($line, ':');
if ($position !== false) {
$name = strtolower(trim(substr($line, 0, $position)));
$headers[$name] = trim(substr($line, $position + 1));
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
throw new RuntimeException('Network error: ' . curl_error($handle));
}
return [
'status' => (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
'headers' => $headers,
'body' => $body,
];
}
}
Mapirajte API na granici aplikacije
Usluga vraća podatke o tvrtki, kontaktu, e-pošti, telefonu i osobama. Vanjski JSON ne smije nekontrolirano prodrijeti kroz CRM, stoga mapper normalizira skalarna polja koja mogu biti null i provjerava je li people polje niz. Neočekivani oblici postaju izričiti neuspjesi sheme umjesto da tiho popunjavaju netočna polja.
<?php
// src/Company/CompanyProfile.php
declare(strict_types=1);
namespace App\Company;
final readonly class CompanyProfile
{
public function __construct(
public ?string $company,
public ?string $contact,
public ?string $email,
public ?string $phone,
public array $people
) {}
public static function fromApi(array $data): self
{
$people = $data['people'] ?? [];
if (!is_array($people)) {
throw new IntegrationFailure(
'invalid_schema',
'The people field is not an array.'
);
}
return new self(
self::text($data, 'company'),
self::text($data, 'contact'),
self::text($data, 'email'),
self::text($data, 'phone'),
array_values($people)
);
}
public function toArray(): array
{
return [
'company' => $this->company,
'contact' => $this->contact,
'email' => $this->email,
'phone' => $this->phone,
'people' => $this->people,
];
}
private static function text(array $data, string $key): ?string
{
$value = $data[$key] ?? null;
if ($value === null || $value === '') {
return null;
}
if (!is_scalar($value)) {
throw new IntegrationFailure(
'invalid_schema',
"The {$key} field is not scalar."
);
}
return trim((string) $value);
}
}
<?php
// src/Company/IntegrationFailure.php
declare(strict_types=1);
namespace App\Company;
use RuntimeException;
final class IntegrationFailure extends RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $upstreamStatus = null
) {
parent::__construct($message);
}
}
Dodajte selektivne ponovne pokušaje i sigurnu telemetriju
Neuspjesi autentikacije i odbijeni zahtjevi deterministički su, pa njihovo ponovno pokušavanje samo troši kvotu. Mrežne pogreške, HTTP 429 odgovori i 5xx neuspjesi na poslužiteljskoj strani mogu biti prolazni. Klijent ponovno pokušava ta stanja najviše dva puta nakon prvog pokušaja, poštuje numeričku vrijednost Retry-After do dvije sekunde, a u suprotnom koristi ograničeno eksponencijalno odgađanje s jitterom.
<?php
// src/Company/WebsiteCompanyClient.php
declare(strict_types=1);
namespace App\Company;
use App\Http\Transport;
use Closure;
use JsonException;
use RuntimeException;
final class WebsiteCompanyClient
{
private Closure $pause;
private Closure $log;
public function __construct(
private readonly Transport $transport,
private readonly string $token,
?Closure $pause = null,
?Closure $log = null
) {
$this->pause = $pause ?? static fn(int $ms) => usleep($ms * 1000);
$this->log = $log ?? static function (array $context): void {
error_log((string) json_encode($context, JSON_UNESCAPED_SLASHES));
};
}
public function extract(string $website): CompanyProfile
{
$correlationId = bin2hex(random_bytes(8));
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get(
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
['token' => $this->token, 'website' => $website],
2000,
8000
);
} catch (RuntimeException $error) {
($this->log)([
'event' => 'company_enrichment_network_error',
'correlation_id' => $correlationId,
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new IntegrationFailure(
'network',
'The enrichment service could not be reached.'
);
}
($this->pause)($this->backoff($attempt));
continue;
}
$status = $response['status'];
if ($status >= 200 && $status < 300) {
try {
$payload = json_decode(
$response['body'],
true,
512,
JSON_THROW_ON_ERROR
);
} catch (JsonException) {
throw new IntegrationFailure(
'invalid_schema',
'The enrichment service returned invalid JSON.',
$status
);
}
if (!is_array($payload)) {
throw new IntegrationFailure(
'invalid_schema',
'The enrichment response is not an object.',
$status
);
}
return CompanyProfile::fromApi($payload);
}
($this->log)([
'event' => 'company_enrichment_http_error',
'correlation_id' => $correlationId,
'attempt' => $attempt,
'upstream_status' => $status,
]);
if ($status === 401 || $status === 403) {
throw new IntegrationFailure(
'authentication',
'The service token was rejected.',
$status
);
}
$retryable = $status === 429 || $status >= 500;
if (!$retryable) {
throw new IntegrationFailure(
'request_rejected',
'The enrichment request was rejected.',
$status
);
}
if ($attempt === 3) {
$kind = $status === 429 ? 'rate_limited' : 'upstream';
throw new IntegrationFailure(
$kind,
'The enrichment service is temporarily unavailable.',
$status
);
}
($this->pause)($this->delay($attempt, $response['headers']));
}
throw new IntegrationFailure('upstream', 'Enrichment failed.');
}
private function delay(int $attempt, array $headers): int
{
$retryAfter = $headers['retry-after'] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
return min(2000, (int) $retryAfter * 1000);
}
return $this->backoff($attempt);
}
private function backoff(int $attempt): int
{
return min(2000, 200 * (2 ** ($attempt - 1)) + random_int(0, 100));
}
}
Zapisnici sadrže naziv događaja, lokalni ID korelacije, broj pokušaja i status. Namjerno izostavljaju token, potpuni URL zahtjeva, tijelo odgovora i poslano web-mjesto. Budući da autentikacija koristi parametar upita, obrnuti proxyji i sustavi za praćenje također moraju biti konfigurirani za redigiranje nizova upita.
Izložite krajnji pristup za CRM predfill
Kontroler prihvaća JSON poput {"website":"https://example.com"}. Dopušta samo HTTP i HTTPS URL-ove s hostom, zatim neuspjehe integracije prevodi u stabilna stanja na razini aplikacije.
<?php
// public/index.php
declare(strict_types=1);
use App\Company\IntegrationFailure;
use App\Company\WebsiteCompanyClient;
use App\Http\CurlTransport;
$config = require dirname(__DIR__) . '/config/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/lead-prefill') {
http_response_code(404);
echo '{"error":{"kind":"not_found"}}';
exit;
}
try {
$input = json_decode(
file_get_contents('php://input'),
true,
32,
JSON_THROW_ON_ERROR
);
$website = is_array($input) ? ($input['website'] ?? null) : null;
$valid = is_string($website)
? filter_var($website, FILTER_VALIDATE_URL)
: false;
$scheme = $valid !== false ? parse_url($website, PHP_URL_SCHEME) : null;
$host = $valid !== false ? parse_url($website, PHP_URL_HOST) : null;
if ($valid === false
|| !in_array($scheme, ['http', 'https'], true)
|| !is_string($host)
|| $host === '') {
http_response_code(422);
echo json_encode([
'error' => [
'kind' => 'validation',
'message' => 'Supply a complete HTTP or HTTPS company website.',
],
], JSON_THROW_ON_ERROR);
exit;
}
$client = new WebsiteCompanyClient(
new CurlTransport(),
$config['service_token']
);
echo json_encode([
'data' => $client->extract($website)->toArray(),
], JSON_THROW_ON_ERROR);
} catch (JsonException) {
http_response_code(400);
echo '{"error":{"kind":"invalid_json"}}';
} catch (IntegrationFailure $failure) {
$status = match ($failure->kind) {
'rate_limited', 'network' => 503,
default => 502,
};
http_response_code($status);
echo json_encode([
'error' => [
'kind' => $failure->kind,
'message' => $failure->getMessage(),
],
], JSON_THROW_ON_ERROR);
}
CRM može postaviti vraćene vrijednosti u nespremljeni obrazac potencijalnog klijenta. Ostavite ih izmjenjivima: javna web-mjesta mogu biti nepotpuna ili zastarjela, a obogaćivanje bi trebalo pomoći prodajnom predstavniku, a ne tiho postati autoritativan izvor podataka.
Testirajte bez upućivanja vanjskih zahtjeva
Lažni transport čini uspjeh, ponovne pokušaje i neuspjehe autentikacije determinističkima. Testovi nikada ne smiju koristiti aktivni token niti trošiti kvotu plana.
<?php
// tests/WebsiteCompanyClientTest.php
declare(strict_types=1);
use App\Company\IntegrationFailure;
use App\Company\WebsiteCompanyClient;
use App\Http\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs
): array {
$this->calls++;
return array_shift($this->responses);
}
}
final class WebsiteCompanyClientTest extends TestCase
{
public function testMapsAProfile(): void
{
$transport = new FakeTransport([[
'status' => 200,
'headers' => [],
'body' => json_encode([
'company' => 'Example Ltd',
'contact' => 'Sales',
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['name' => 'Alex']],
], JSON_THROW_ON_ERROR),
]]);
$client = new WebsiteCompanyClient(
$transport,
'test-token',
static fn(int $ms) => null,
static fn(array $context) => null
);
$profile = $client->extract('https://example.com');
self::assertSame('Example Ltd', $profile->company);
self::assertSame('[email protected]', $profile->email);
self::assertCount(1, $profile->people);
}
public function testRetriesAServiceFailureThenSucceeds(): void
{
$transport = new FakeTransport([
['status' => 503, 'headers' => [], 'body' => ''],
['status' => 200, 'headers' => [], 'body' => '{}'],
]);
$client = new WebsiteCompanyClient(
$transport,
'test-token',
static fn(int $ms) => null,
static fn(array $context) => null
);
$client->extract('https://example.com');
self::assertSame(2, $transport->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([[
'status' => 401,
'headers' => [],
'body' => '',
]]);
$client = new WebsiteCompanyClient(
$transport,
'test-token',
static fn(int $ms) => null,
static fn(array $context) => null
);
try {
$client->extract('https://example.com');
self::fail('Expected an IntegrationFailure');
} catch (IntegrationFailure $failure) {
self::assertSame('authentication', $failure->kind);
self::assertSame(1, $transport->calls);
}
}
}
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public
curl --fail-with-body \
-H 'Content-Type: application/json' \
-d '{"website":"https://example.com"}' \
http://127.0.0.1:8080/lead-prefill
Učvršćivanje produkcije i implementacija
Pokrenite testove prije instaliranja produkcijskih ovisnosti, zatim implementirajte iza konfiguriranog PHP-FPM web-poslužitelja s public kao korijenom dokumenta:
vendor/bin/phpunit tests
composer install --no-dev --classmap-authoritative
php -r 'exit(extension_loaded("curl") ? 0 : 1);'
Postavite rokove zahtjeva aplikacije i proxyja malo iznad klijentova vremenskog ograničenja od osam sekundi. Pratite uspjeh, neuspjeh provjere, neuspjeh autentikacije, ograničavanje stope, neuspjeh uzvodne usluge, latenciju i broj ponovnih pokušaja. Upozorite na trajne neuspjehe autentikacije jer oni često ukazuju na istekao, ponovno generiran ili pogrešno implementiran token.
Nemojte bez razlike predmemorirati obogaćivanje. Ako su ponovljena pretraživanja česta, predmemorirajte prema normaliziranom hostu web-mjesta za kratko razdoblje koje je odobrila tvrtka te razmotrite jesu li podaci za kontakt osobni podaci prema vašoj politici zadržavanja. Primijenite CRM autorizaciju i CSRF zaštitu tamo gdje ih zahtijeva okolna aplikacija.
Uobičajeni načini neuspjeha
- HTTP 401 ili 403: provjerite aktivaciju i trenutačni token ograničen na uslugu; nemojte automatski ponavljati pokušaj.
- HTTP 429: dostupni kapacitet plana možda je iscrpljen ili privremeno ograničen; sačuvajte nacrt potencijalnog klijenta i dopustite korisniku da pokuša ponovno kasnije.
- Istek vremena ili 5xx odgovori: ponovno pokušajte samo unutar ograničenog pravila, a zatim vratite CRM pogrešku od koje se može oporaviti.
- Nevaljana shema: zadržite sanitizirane metapodatke o neuspjehu i usporedite odgovor sa službenom dokumentacijom; nikada nemojte forsirati neočekivane nizove u tekstna polja.
- Prazna polja: tretirajte ih kao legitimno djelomično obogaćivanje, a ne kao neuspjeli zahtjev.
Završni kontrolni popis provjere
- Plan usluge je aktivan, a trenutačni token dolazi iz konfiguracije podržane varijablama okruženja.
- Zahtjev koristi dokumentirani GET krajnji pristup samo s parametrima upita
tokeniwebsite. - Preusmjeravanja su onemogućena, vremenska ograničenja su ograničena, a ponovno se pokušavaju samo prolazni neuspjesi.
- Podaci o tvrtki, kontaktu, e-pošti, telefonu i osobama mapiraju se na granici API-ja.
- Zapisnici i testovi ne sadrže stvarne vjerodajnice ni podatke odgovora.
- CRM prima izmjenjive vrijednosti za predfill i čuva nacrt prodajnog predstavnika kada obogaćivanje ne uspije.
- Uspjeh, pogreške autentikacije, ograničavanje stope, latencija i aktivnost ponovnih pokušaja vidljivi su nakon implementacije.
Važan rezultat nisu samo manji broj pritisaka tipki. To je čista granica između vanjske usluge obogaćivanja i vlastitog podatkovnog modela CRM-a. S tom granicom, jedno web-mjesto tvrtke postaje koristan nacrt potencijalnog klijenta bez pretvaranja prolaznog ponašanja mreže, promjenjivih javnih informacija ili osjetljivih vjerodajnica u skriveni operativni rizik.