phpcfdi/cfdi-sat-scraper

从SAT网页上抓取电子发票

v3.3.2 2024-09-09 19:08 UTC

This package is auto-updated.

Last update: 2024-09-09 19:10:01 UTC


README

Source Code Packagist PHP Version Support Discord Latest Version Software License Build Status Reliability Maintainability Code Coverage Violations Total Downloads

通过网页抓取从SAT页面发出的、接收的、有效的和已取消的发票。可下载的资源包括CFDI的XML文件和表示打印、取消请求和取消收据的PDF文件。

通过composer安装

composer require phpcfdi/cfdi-sat-scraper

功能

SAT提供的CFDI下载服务位于https://portalcfdi.facturaelectronica.sat.gob.mx/,需要使用RFC、CIEC密钥和解决验证码,或者使用FIEL证书和私钥。

进入网站后,可以查询发出的和接收的发票。无论是通过UUID还是通过过滤器。

  • 标准

    • 类型:发出的或接收的。
    • 过滤器:UUID或查询。
  • 发出的查询

    • 发行日期和时间。
    • 接收日期和时间。
    • 接收者RFC。
    • 凭证状态(任何状态、有效或已取消)。
    • 凭证类型(如果包含特定补充)。
    • 第三方账户RFC。
  • 接收的查询

    • 发行日期。
    • 开始时间和结束时间(发行日期内)。
    • 发行者RFC。
    • 凭证状态(任何状态、有效或已取消)。
    • 凭证类型(如果包含特定补充)。
    • 第三方账户RFC。

搜索服务返回一个包含信息的表格,每个查询最多返回500条记录(即使有更多,也只显示500)。

有了清单后,网站提供链接以下载CFDI的XML文件。

在库中实现网站功能

主要工作对象是名为SatScraper的对象,可以使用它进行日期范围或特定UUID的查询并获取结果。通过UUID(一个或多个)的查询使用listByUuids方法执行,结果是MetadataList。通过过滤器的查询称为QueryByFilters,使用listByPeriodlistByDateTime方法执行,结果是MetadataList

为了生成MetadataList的结果,库有一个分割策略。如果是通过CFDI过滤器的查询,则自动按天分割。如果查询的期间内有500条或更多记录,则搜索将细分到不同的期间,直到达到最小1秒查询。然后再次将结果合并。

一旦有了MetadataList清单,就可以应用过滤器以获取一个新清单,其中仅包含UUID相匹配的Metadata对象;或者使用其他过滤器,例如仅包含特定可下载资源的对象。

一旦有了MetadataList结果,可以请求下载到特定文件夹或通过一个handler对象。下载过程允许进行多个同时下载。

可以下载的文件类型有

  • CFDI文件(XML)。
  • CFDI打印表示(PDF)。
  • 取消请求(PDF)。
  • 取消收据(PDF)。

执行元数据下载的方法是

  • 通过UUID:SatScraper::listByUuids(string[] $uuids, DownloadType $type): MetadataList
  • 通过完整日期的过滤器:SatScraper::listByPeriod(Query $query): MetadataList
  • 通过精确日期的过滤器:SatScraper::listByDateTime(Query $query): MetadataList

一旦有了MetadataList对象,就创建一个资源下载器对象ResourceDownloader,并要求它执行按资源类型进行的下载。

  • 创建:SatScraper::resourceDownloader(ResourceType $resourceType, MetadataList $list = null, int $concurrency = 10): ResourceDownloader
  • 保存到文件夹:ResourceDownloader::saveTo(string $destination): void
  • 使用处理器保存:ResourceDownloader::download(ResourceDownloadHandlerInterface $handler): void

如果达到1秒的最小查询时间并且获取了500或更多记录,那么还会调用一个可选的回调来报告这一事件。

搜索始终需要创建一个日期范围,默认情况下,搜索由CSDI发出,任何补充和任何状态(有效或已取消)。然而,你可以在发送处理之前更改搜索。

这个库基于Guzzle,因此你可以根据需要配置客户端,如设置代理或调试HTTP调用。多亏了这个库,我们可以提供XML的同时下载,并且使通信过程比使用完整浏览器更快。

认证

这个库允许使用两种机制之一在SAT进行身份验证:CIEC密钥或FIEL。

FIEL认证

要使用FIEL进行身份验证,需要使用会话处理器FielSessionManager,相应的证书、私钥和私钥密码。

这种方法的优点是不需要解密。缺点是使用FIEL存在风险。

警告:除非是自己的FIEL,否则不要使用此机制。墨西哥的FIEL由“高级电子签名法”规定。它的使用范围广泛,不仅限于SAT,还可以进行多项法律操作。在PhpCfdi中,我们不推荐存储或使用第三方的FIEL。

CIEC密钥认证

要使用CIEC进行身份验证,需要使用会话处理器CiecSessionManager,包括RFC、CIEC密钥和验证码解析器。

这种方法的优点是不需要FIEL。缺点是需要验证码解析器。

我们没有自己的方法来解析验证码,但可以使用外部服务,如Anti-Captcha。对于测试或本地实现,可以使用`eclipxe/captcha-local-resolver,其中你自己将解析验证码,这三种实现都已创建。

验证码解析是通过phpcfdi/image-captcha-resolver库来执行的。如果你使用的是未实现的服务,可以检查此项目的文档,并在支持的客户中集成服务。

查询构建示例

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\QueryByFilters;
use PhpCfdi\CfdiSatScraper\Filters\Options\ComplementsOption;
use PhpCfdi\CfdiSatScraper\Filters\DownloadType;
use PhpCfdi\CfdiSatScraper\Filters\Options\StatesVoucherOption;
use PhpCfdi\CfdiSatScraper\Filters\Options\RfcOnBehalfOption;
use PhpCfdi\CfdiSatScraper\Filters\Options\RfcOption;

// se crea con un rango de fechas específico
$query = new QueryByFilters(new DateTimeImmutable('2019-03-01'), new DateTimeImmutable('2019-03-31'));
$query
    ->setDownloadType(DownloadType::recibidos())                // en lugar de emitidos
    ->setStateVoucher(StatesVoucherOption::vigentes())          // en lugar de todos
    ->setRfc(new RfcOption('EKU9003173C9'))                     // de este RFC específico
    ->setComplement(ComplementsOption::reciboPagoSalarios12())  // que incluya este complemento
    ->setRfcOnBehalf(new RfcOnBehalfOption('AAA010101AAA'))     // con este RFC A cuenta de terceros
;

按日期范围下载示例

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\QueryByFilters;
use PhpCfdi\CfdiSatScraper\ResourceType;
use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\Ciec\CiecSessionManager;
use PhpCfdi\ImageCaptchaResolver\CaptchaResolverInterface;

/** @var CaptchaResolverInterface $captchaResolver */
$satScraper = new SatScraper(CiecSessionManager::create('rfc', 'ciec', $captchaResolver));

$query = new QueryByFilters(new DateTimeImmutable('2019-03-01'), new DateTimeImmutable('2019-03-31'));
$list = $satScraper->listByPeriod($query);

// impresión de cada uno de los metadata
foreach ($list as $cfdi) {
    echo 'UUID: ', $cfdi->uuid(), PHP_EOL;
    echo 'Emisor: ', $cfdi->get('rfcEmisor'), ' - ', $cfdi->get('nombreEmisor'), PHP_EOL;
    echo 'Receptor: ', $cfdi->get('rfcReceptor'), ' - ', $cfdi->get('nombreReceptor'), PHP_EOL;
    echo 'Fecha: ', $cfdi->get('fechaEmision'), PHP_EOL;
    echo 'Tipo: ', $cfdi->get('efectoComprobante'), PHP_EOL;
    echo 'Estado: ', $cfdi->get('estadoComprobante'), PHP_EOL;
}

// descarga de cada uno de los CFDI, reporta los descargados en $downloadedUuids
$downloadedUuids = $satScraper->resourceDownloader(ResourceType::xml(), $list)
    ->setConcurrency(50)                            // cambiar a 50 descargas simultáneas
    ->saveTo('/storage/downloads');                 // ejecutar la instrucción de descarga
echo json_encode($downloadedUuids);

按UUID列表下载示例

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\Filters\DownloadType;
use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\Ciec\CiecSessionManager;
use PhpCfdi\ImageCaptchaResolver\CaptchaResolverInterface;

/** @var CaptchaResolverInterface $captchaResolver */
$satScraper = new SatScraper(CiecSessionManager::create('rfc', 'ciec', $captchaResolver));

$uuids = [
    '5cc88a1a-8672-11e6-ae22-56b6b6499611',
    '5cc88c4a-8672-11e6-ae22-56b6b6499612',
    '5cc88d4e-8672-11e6-ae22-56b6b6499613'
];
$list = $satScraper->listByUuids($uuids, DownloadType::recibidos());
echo json_encode($list);

元数据下载通知

SAT提供的服务有限制,例如,在日期范围内不能获取超过500条记录。这个库试图缩小范围,以便创建一个查询,每秒获取所有数据,但如果出现这种情况,则可以使用处理器MetadataMessageHandler来记录这种情况。

MetadataMessageHandler是一个接口,可以接收不同的消息

  • resolved(DateTimeImmutable $since, DateTimeImmutable $until, int $count): void:当在一天内的两个时刻之间解决查询时发生,总是少于500条记录。
  • date(DateTimeImmutable $since, DateTimeImmutable $until, int $count): void:当解决指定日期的查询时发生。有一个初始时间和一个结束时间,因为小时可能不是00:00:0023:59:59
  • divide(DateTimeImmutable $since, DateTimeImmutable $until): void: 当在一定时间段内找到500条记录时发生。将查询分割以尝试下载完整内容。
  • maximum(DateTimeImmutable $moment): void: 当在单一秒内找到500条记录时发生。

如果创建SatScraper对象时未设置处理程序或将其设置为null,则将使用NullMetadataMessageHandler实例,正如其名称所示,它不会执行任何操作。

以下代码示例展示了在找到500条记录问题时显示消息。

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\NullMetadataMessageHandler;
use PhpCfdi\CfdiSatScraper\QueryByFilters;
use PhpCfdi\CfdiSatScraper\SatHttpGateway;
use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\SessionManager;

/**
 * @var SessionManager $sessionManager
 * @var SatHttpGateway $httpGateway
 */

// se define el controlador de mensajes
$handler = new class () extends NullMetadataMessageHandler {
    public function maximum(DateTimeImmutable $date): void
    {
        echo 'Se encontraron más de 500 CFDI en el segundo: ', $date->format('c'), PHP_EOL;
    }
};

// se crea el scraper usando el controlador de mensajes
$satScraper = new SatScraper($sessionManager, $httpGateway, $handler);

$query = new QueryByFilters(new DateTimeImmutable('2019-03-01'), new DateTimeImmutable('2019-03-31'));
$list = $satScraper->listByPeriod($query);
echo json_encode($list);

MaximumRecordsHandler接口和NullMaximumRecordsHandler对象自3.3.0版本以来已弃用。这两个符号将从4.0.0版本中删除。

将CFDIS下载到文件夹

执行saveTo方法返回实际已下载的UUID数组。

如果下载过程中出现错误,将忽略该错误。

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\QueryByFilters;
use PhpCfdi\CfdiSatScraper\ResourceType;
use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\Ciec\CiecSessionManager;
use PhpCfdi\ImageCaptchaResolver\CaptchaResolverInterface;

/** @var CaptchaResolverInterface $captchaResolver */
$satScraper = new SatScraper(CiecSessionManager::create('rfc', 'ciec', $captchaResolver));

$query = new QueryByFilters(new DateTimeImmutable('2019-03-01'), new DateTimeImmutable('2019-03-31'));
$list = $satScraper->listByPeriod($query);

// $downloadedUuids contiene un listado de UUID que fueron procesados correctamente, 50 descargas simultáneas
$downloadedUuids = $satScraper->resourceDownloader(ResourceType::xml(), $list, 50)
    ->saveTo('/storage/downloads', true, 0777);
echo json_encode($downloadedUuids);

默认情况下,文件存储在文件夹中

  • CFDI: uuid + .xml
  • 打印表示: uuid + .pdf
  • 取消请求: uuid + -cancel-request.pdf
  • 取消收据: uuid + -cancel-voucher.pdf

要更改文件名,创建一个实现接口\PhpCfdi\CfdiSatScraper\Contracts\ResourceFileNamerInterface的实现,并使用方法ResourceDownloader::setResourceFileNamer()配置资源下载器。

自定义处理每个CFDI下载

执行ResourceDownloader::download方法返回实际已下载的UUID数组。它允许配置下载事件和错误处理。

如果要忽略错误,可以简单地指定无内容的ResourceDownloadHandlerInterface::onError()方法,那么错误仅会丢失。无论如何,由于download方法返回一个包含已实际下载的UUID数组的数组,因此可以过滤MetadataList对象以提取未下载的项。

查看类PhpCfdi\CfdiSatScraper\Internal\ResourceDownloadStoreInFolder作为实现接口ResourceDownloadHandlerInterface的示例。

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\Contracts\ResourceDownloadHandlerInterface;
use PhpCfdi\CfdiSatScraper\Exceptions\ResourceDownloadError;
use PhpCfdi\CfdiSatScraper\Exceptions\ResourceDownloadResponseError;
use PhpCfdi\CfdiSatScraper\Exceptions\ResourceDownloadRequestExceptionError;
use PhpCfdi\CfdiSatScraper\QueryByFilters;
use PhpCfdi\CfdiSatScraper\ResourceType;
use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\Ciec\CiecSessionManager;
use PhpCfdi\ImageCaptchaResolver\CaptchaResolverInterface;
use Psr\Http\Message\ResponseInterface;

/** @var CaptchaResolverInterface $captchaResolver */
$satScraper = new SatScraper(CiecSessionManager::create('rfc', 'ciec', $captchaResolver));

$query = new QueryByFilters(new DateTimeImmutable('2019-03-01'), new DateTimeImmutable('2019-03-31'));

$list = $satScraper->listByPeriod($query);

$myHandler = new class implements ResourceDownloadHandlerInterface {
    public function onSuccess(string $uuid, string $content, ResponseInterface $response): void
    {
        $filename = '/storage/' . $uuid . '.xml';
        echo 'Saving ', $uuid, PHP_EOL;
        file_put_contents($filename, (string) $response->getBody());
    }

    public function onError(ResourceDownloadError $error) : void
    {
        if ($error instanceof ResourceDownloadRequestExceptionError) {
            echo "Error getting {$error->getUuid()} from {$error->getReason()->getRequest()->getUri()}\n";
        } elseif ($error instanceof ResourceDownloadResponseError) {
            echo "Error getting {$error->getUuid()}, invalid response: {$error->getMessage()}\n";
            $response = $error->getReason(); // reason is a ResponseInterface
            print_r(['headers' => $response->getHeaders(), 'body' => $response->getBody()]);
        } else { // ResourceDownloadError
            echo "Error getting {$error->getUuid()}, reason: {$error->getMessage()}\n";
            print_r(['reason' => $error->getReason()]);
        }
    }
};

// $downloadedUuids contiene un listado de UUID que fueron procesados correctamente
$downloadedUuids = $satScraper->resourceDownloader(ResourceType::xml(), $list)->download($myHandler);
echo json_encode($downloadedUuids);

使用Anti-Captcha服务

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\Ciec\CiecSessionManager;
use PhpCfdi\ImageCaptchaResolver\Resolvers\AntiCaptchaResolver;

$captchaResolver = AntiCaptchaResolver::create('anticaptcha-client-key');

$satScraper = new SatScraper(CiecSessionManager::create('rfc', 'ciec', $captchaResolver));

不进行查询验证数据认证

以下示例展示了如何使用方法SatScraper::confirmSessionIsAlive来验证会话数据是否正确(或继续正确)。Scraper的内部工作原理是:如果会话之前未初始化,则将尝试进行认证过程,并检查会话(cookie)是否有效。

执行两个步骤以避免不必要地消耗Captcha解析服务。

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\Exceptions\LoginException;
use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\Ciec\CiecSessionManager;
use PhpCfdi\ImageCaptchaResolver\CaptchaResolverInterface;

/** @var CaptchaResolverInterface $captchaResolver */
$satScraper = new SatScraper(CiecSessionManager::create('rfc', 'ciec', $captchaResolver));
try {
    $satScraper->confirmSessionIsAlive();
} catch (LoginException $exception) {
    echo 'ERROR: ', $exception->getMessage(), PHP_EOL;
    return;
}

FIEL认证示例

以下示例使用FIEL,其中证书和私钥文件已加载到内存中且有效。有关如何在该项目中创建凭证的更多信息,请参阅phpcfdi/credentials项目。

要创建凭证,需要证书、私钥和密码。如果证书和私钥内容在内存中,则使用方法Credential::create()。如果证书和私钥在文件中,则使用方法Credential::openFiles()

<?php declare(strict_types=1);

use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\Fiel\FielSessionManager;
use PhpCfdi\CfdiSatScraper\Sessions\Fiel\FielSessionData;
use PhpCfdi\Credentials\Credential;

/**
 * @var string $certificate Contenido del certificado
 * @var string $privateKey Contenido de la llave privada
 * @var string $passPhrase Contraseña de la llave privada
 */

// crear la credencial
// se puede usar Credential::openFiles(certificateFile, privateKeyFile, passphrase) si la FIEL está en archivos 
$credential = Credential::create($certificate, $privateKey, $passPhrase);
if (! $credential->isFiel()) {
    throw new Exception('The certificate and private key is not a FIEL');
}
if (! $credential->certificate()->validOn()) {
    throw new Exception('The certificate and private key is not valid at this moment');
}

// crear el objeto scraper usando la FIEL
$satScraper = new SatScraper(FielSessionManager::create($credential));

移除SAT证书验证

如果用于HTTPS的SAT证书失败,可以取消对它们的验证。这可以通过创建带有禁用verify选项的Guzzle客户端来实现。

这不是推荐的做法,但在SAT面临的问题下可能需要。考虑到这可能会大大简化攻击(中间人攻击),从而导致您的CIEC密钥丢失。

注意:我们不推荐这种做法,只是因为它经常出现SAT的故障而提出。

<?php declare(strict_types=1);
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use PhpCfdi\CfdiSatScraper\SatHttpGateway;
use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\SessionManager;

$insecureClient = new Client([
    RequestOptions::VERIFY => false
]);
$gateway = new SatHttpGateway($insecureClient);

/** @var SessionManager $sessionManager */
$scraper = new SatScraper($sessionManager, $gateway);

SAT连接问题

根据系统的一般配置,经常遇到这个问题。

cURL error 35: error:141A318A:SSL routines:tls_process_ske_dhe:dh key too small (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://cfdiau.sat.gob.mx/...

这个问题是由于处理SAT请求的服务器配置引起的。

仅针对此库解决问题的一种方法是在创建SatScraper时,在SatHttpGateway客户端设置cURL配置。

<?php declare(strict_types=1);
use GuzzleHttp\Client;
use PhpCfdi\CfdiSatScraper\SatHttpGateway;
use PhpCfdi\CfdiSatScraper\SatScraper;
use PhpCfdi\CfdiSatScraper\Sessions\SessionManager;

$client = new Client([
    'curl' => [CURLOPT_SSL_CIPHER_LIST => 'DEFAULT@SECLEVEL=1'],
]);

/** @var SessionManager $sessionManager */
$scraper = new SatScraper($sessionManager, new SatHttpGateway($client));

另一种解决方案是降低OpenSSL的一般安全性,一些指令可以在https://askubuntu.com/questions/1250787/when-i-try-to-curl-a-website-i-get-ssl-error中看到。

兼容性

此库将保持与最新PHP支持版本的兼容性。

我们还使用语义版本2.0.0,因此您可以放心使用此库而不用担心破坏您的应用程序。

请参阅从版本2.x升级到版本3.x的指南

贡献

欢迎贡献。请阅读CONTRIBUTING获取更多详细信息,并记得检查待办事项文件变更日志文件

开发文档

版权和许可

phpcfdi/cfdi-sat-scraper库版权所有© PhpCfdi,许可在MIT许可(MIT)下使用。请参阅LICENSE获取更多信息。