From d5a0ffa6dbc39f6f122109b4618b64d88e522eb1 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 20 Jun 2026 04:47:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0whatshub=E5=B7=A5=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _php_code/A2c.php | 0 _php_code/AbstractScrmSpider.class.php | 13 +- _php_code/Chatknow.php | 99 + _php_code/Haiwang.php | 0 _php_code/Huojian.php | 0 _php_code/SsCustomer.php | 0 _php_code/UnifiedScrmData.class.php | 0 _php_code/Whatshub.php | 124 + _php_code/Xinghe.php | 18 +- _php_code/Xinghe2.php | 176 + _php_code/index.php | 126 +- .../admin/command/Install/split_config.sql | 0 .../command/Install/split_config_chatknow.sql | 6 + .../Install/split_config_fix_ss_customer.sql | 14 + .../admin/command/Install/split_link.sql | 0 .../command/Install/split_link_auto_reply.sql | 0 .../command/Install/split_link_pixel.sql | 0 .../admin/command/Install/split_number.sql | 7 + .../admin/command/Install/split_ticket.sql | 0 .../command/Install/split_ticket_stats.sql | 0 .../admin/command/SplitSyncTickets.php | 17 +- application/admin/controller/split/Link.php | 25 + application/admin/controller/split/Number.php | 130 + application/admin/controller/split/Ticket.php | 130 +- application/admin/lang/zh-cn/split/link.php | 2 +- application/admin/lang/zh-cn/split/number.php | 12 + application/admin/lang/zh-cn/split/ticket.php | 3 + application/admin/model/split/Link.php | 0 application/admin/model/split/Number.php | 2 +- application/admin/model/split/Ticket.php | 1 + application/admin/validate/split/Link.php | 0 application/admin/validate/split/Number.php | 0 application/admin/validate/split/Ticket.php | 0 application/admin/view/split/link/add.html | 0 application/admin/view/split/link/edit.html | 0 application/admin/view/split/link/index.html | 0 application/admin/view/split/number/add.html | 0 application/admin/view/split/number/edit.html | 0 .../admin/view/split/number/form_body.html | 0 .../admin/view/split/number/index.html | 1 + application/admin/view/split/ticket/add.html | 0 application/admin/view/split/ticket/edit.html | 0 .../admin/view/split/ticket/form_body.html | 0 .../admin/view/split/ticket/index.html | 51 + application/common/library/CountryIso.php | 296 +- .../library/scrm/AbstractScrmSpider.php | 112 +- .../library/scrm/ScrmSpiderInterface.php | 0 .../common/library/scrm/UnifiedScrmData.php | 0 .../common/library/scrm/spider/A2cSpider.php | 0 .../library/scrm/spider/ChatknowSpider.php | 110 + .../library/scrm/spider/HaiwangSpider.php | 0 .../library/scrm/spider/HuojianSpider.php | 0 .../library/scrm/spider/SsCustomerSpider.php | 0 .../library/scrm/spider/WhatshubSpider.php | 138 + .../library/scrm/spider/XingheSpider.php | 225 +- .../library/scrm/spider/XingheSpider.php.bak | 93 + .../service/DomainDetectRateLimitService.php | 0 .../common/service/SplitAutoReplyService.php | 0 .../common/service/SplitCronLockService.php | 64 + .../common/service/SplitFriendUrlBuilder.php | 20 +- .../common/service/SplitGeoIpService.php | 0 .../common/service/SplitIpProtectService.php | 0 .../common/service/SplitLinkCodeService.php | 0 .../common/service/SplitNodeHealthService.php | 96 + .../service/SplitNumberWeighService.php | 0 .../service/SplitPixelBrowserRenderer.php | 0 .../service/SplitPixelConfigService.php | 0 .../service/SplitPixelPostbackService.php | 0 .../service/SplitPlatformDomainService.php | 0 .../common/service/SplitRedirectService.php | 8 +- .../common/service/SplitRoundRobinStore.php | 0 .../common/service/SplitScrmSpiderFactory.php | 14 + .../common/service/SplitSyncConfigService.php | 36 + .../service/SplitTicketNumberSyncService.php | 0 .../common/service/SplitTicketRuleService.php | 0 .../service/SplitTicketSyncLockService.php | 0 .../common/service/SplitTicketSyncLogger.php | 0 .../common/service/SplitTicketSyncService.php | 62 +- .../common/service/SplitVisitPageService.php | 0 application/config.php | 6 +- application/extra/site.php | 13 +- application/index/controller/Index.php | 3 +- application/index/controller/S.php | 0 application/index/controller/Split.php | 0 application/index/view/split/not_found.html | 0 application/index/view/split/visit.html | 0 deploy-domain-detect-rate-limit.sh | 0 deploy_web.php | 0 .../admin/command/Install/split_config.sql | 0 .../command/Install/split_config_chatknow.sql | 6 + .../Install/split_config_fix_ss_customer.sql | 14 + .../command/Install/split_config_sync.sql | 29 + .../admin/command/Install/split_link.sql | 0 .../command/Install/split_link_auto_reply.sql | 0 .../command/Install/split_link_pixel.sql | 0 .../command/Install/split_mydomain_remove.sql | 0 .../admin/command/Install/split_number.sql | 7 + .../command/Install/split_number_validate.php | 0 .../command/Install/split_number_weigh.sql | 0 .../admin/command/Install/split_ticket.sql | 0 .../Install/split_ticket_add_status.sql | 0 .../command/Install/split_ticket_stats.sql | 0 .../command/Install/split_ticket_sync.sql | 0 .../command/Install/split_ticket_validate.php | 0 .../admin/command/SplitSyncTickets.php | 17 +- .../admin/controller/split/Link.php | 25 + .../admin/controller/split/Number.php | 130 + .../admin/controller/split/Ticket.php | 130 +- .../application/admin/lang/zh-cn/domain.php | 0 .../admin/lang/zh-cn/split/link.php | 2 +- .../admin/lang/zh-cn/split/number.php | 12 + .../admin/lang/zh-cn/split/ticket.php | 3 + .../application/admin/model/split/Link.php | 0 .../application/admin/model/split/Number.php | 2 +- .../application/admin/model/split/Ticket.php | 1 + .../application/admin/validate/split/Link.php | 0 .../admin/view/split/link/add.html | 0 .../admin/view/split/link/edit.html | 0 .../admin/view/split/link/index.html | 0 .../admin/view/split/number/add.html | 0 .../admin/view/split/number/edit.html | 0 .../admin/view/split/number/form_body.html | 0 .../admin/view/split/number/index.html | 1 + .../admin/view/split/ticket/add.html | 0 .../admin/view/split/ticket/edit.html | 0 .../admin/view/split/ticket/form_body.html | 0 .../admin/view/split/ticket/index.html | 51 + .../application/common/library/CountryIso.php | 0 .../library/scrm/AbstractScrmSpider.php | 112 +- .../library/scrm/ScrmSpiderInterface.php | 0 .../common/library/scrm/UnifiedScrmData.php | 0 .../common/library/scrm/spider/A2cSpider.php | 0 .../library/scrm/spider/ChatknowSpider.php | 110 + .../library/scrm/spider/HaiwangSpider.php | 0 .../library/scrm/spider/HuojianSpider.php | 0 .../library/scrm/spider/SsCustomerSpider.php | 0 .../library/scrm/spider/WhatshubSpider.php | 138 + .../library/scrm/spider/XingheSpider.php | 225 +- .../library/scrm/spider/XingheSpider.php.bak | 93 + patches/application/common/model/Config.php | 0 .../common/service/CloudflareService.php | 0 .../service/DomainDetectRateLimitService.php | 0 .../common/service/SplitAutoReplyService.php | 0 .../common/service/SplitCronLockService.php | 64 + .../common/service/SplitFriendUrlBuilder.php | 20 +- .../common/service/SplitGeoIpService.php | 0 .../common/service/SplitIpProtectService.php | 0 .../common/service/SplitLinkCodeService.php | 0 .../common/service/SplitNodeHealthService.php | 96 + .../service/SplitNumberWeighService.php | 0 .../service/SplitPixelBrowserRenderer.php | 0 .../service/SplitPixelConfigService.php | 0 .../service/SplitPixelPostbackService.php | 0 .../service/SplitPlatformDomainService.php | 0 .../common/service/SplitRedirectService.php | 8 +- .../common/service/SplitRoundRobinStore.php | 0 .../common/service/SplitScrmSpiderFactory.php | 14 + .../common/service/SplitSyncConfigService.php | 36 + .../service/SplitTicketNumberSyncService.php | 0 .../common/service/SplitTicketRuleService.php | 0 .../service/SplitTicketSyncLockService.php | 0 .../common/service/SplitTicketSyncLogger.php | 0 .../common/service/SplitTicketSyncService.php | 62 +- .../common/service/SplitVisitPageService.php | 0 patches/application/extra/cloudflare.php | 0 patches/application/index/controller/S.php | 0 .../application/index/controller/Split.php | 0 .../index/view/split/not_found.html | 0 .../application/index/view/split/visit.html | 0 patches/application/route_split_visit.php | 0 patches/public/assets/js/backend/domain.js | 0 .../public/assets/js/backend/split/link.js | 58 +- .../public/assets/js/backend/split/number.js | 176 +- .../public/assets/js/backend/split/ticket.js | 155 +- .../Crash Reports/settings.dat | Bin 0 -> 40 bytes .../.runtime/xdg-runtime/dconf/user | Bin 0 -> 2 bytes patches/puppeteer-api/http-ip-rewrite.js | 62 + patches/puppeteer-api/lib/browser-factory.js | 160 + patches/puppeteer-api/lib/captcha-solver.js | 153 + patches/puppeteer-api/lib/cf-detector.js | 97 + patches/puppeteer-api/lib/cf-handler.js | 121 + patches/puppeteer-api/lib/concurrency.js | 82 + patches/puppeteer-api/lib/constants.js | 140 + .../puppeteer-api/lib/launch-real-browser.js | 70 + patches/puppeteer-api/lib/launch-standard.js | 58 + patches/puppeteer-api/lib/session-store.js | 121 + .../puppeteer-api/lib/turnstile-handler.js | 90 + patches/puppeteer-api/package-lock.json | 3103 +++++++++++++++++ patches/puppeteer-api/package.json | 20 + patches/puppeteer-api/server.js | 887 +++++ patches/puppeteer-api/url-resolve.js | 73 + patches/runtime/split_rr/7.cnt | 0 patches/runtime/split_rr/999001.cnt | 0 patches/test_write.txt | 0 .../third_party/geoip2/geoip2/CHANGELOG.md | 0 patches/third_party/geoip2/geoip2/LICENSE | 0 patches/third_party/geoip2/geoip2/README.md | 0 .../third_party/geoip2/geoip2/composer.json | 0 .../geoip2/geoip2/examples/benchmark.php | 0 .../geoip2/geoip2/src/Database/Reader.php | 0 .../Exception/AddressNotFoundException.php | 0 .../src/Exception/AuthenticationException.php | 0 .../geoip2/src/Exception/GeoIp2Exception.php | 0 .../geoip2/src/Exception/HttpException.php | 0 .../src/Exception/InvalidRequestException.php | 0 .../src/Exception/OutOfQueriesException.php | 0 .../geoip2/geoip2/src/Model/AbstractModel.php | 0 .../geoip2/geoip2/src/Model/AnonymousIp.php | 0 .../geoip2/geoip2/src/Model/Asn.php | 0 .../geoip2/geoip2/src/Model/City.php | 0 .../geoip2/src/Model/ConnectionType.php | 0 .../geoip2/geoip2/src/Model/Country.php | 0 .../geoip2/geoip2/src/Model/Domain.php | 0 .../geoip2/geoip2/src/Model/Enterprise.php | 0 .../geoip2/geoip2/src/Model/Insights.php | 0 .../geoip2/geoip2/src/Model/Isp.php | 0 .../geoip2/geoip2/src/ProviderInterface.php | 0 .../geoip2/src/Record/AbstractPlaceRecord.php | 0 .../geoip2/src/Record/AbstractRecord.php | 0 .../geoip2/geoip2/src/Record/City.php | 0 .../geoip2/geoip2/src/Record/Continent.php | 0 .../geoip2/geoip2/src/Record/Country.php | 0 .../geoip2/geoip2/src/Record/Location.php | 0 .../geoip2/geoip2/src/Record/MaxMind.php | 0 .../geoip2/geoip2/src/Record/Postal.php | 0 .../geoip2/src/Record/RepresentedCountry.php | 0 .../geoip2/geoip2/src/Record/Subdivision.php | 0 .../geoip2/geoip2/src/Record/Traits.php | 0 .../third_party/geoip2/geoip2/src/Util.php | 0 .../geoip2/geoip2/src/WebService/Client.php | 0 patches/third_party/load_geoip2.php | 0 .../maxmind-db/reader/CHANGELOG.md | 0 patches/third_party/maxmind-db/reader/LICENSE | 0 .../third_party/maxmind-db/reader/README.md | 0 .../maxmind-db/reader/autoload.php | 0 .../maxmind-db/reader/composer.json | 0 .../maxmind-db/reader/ext/config.m4 | 0 .../maxmind-db/reader/ext/config.w32 | 0 .../maxmind-db/reader/ext/maxminddb.c | 0 .../maxmind-db/reader/ext/php_maxminddb.h | 0 .../maxmind-db/reader/ext/tests/001-load.phpt | 0 .../reader/ext/tests/002-final.phpt | 0 .../reader/ext/tests/003-open-basedir.phpt | 0 .../third_party/maxmind-db/reader/package.xml | 0 .../reader/src/MaxMind/Db/Reader.php | 0 .../reader/src/MaxMind/Db/Reader/Decoder.php | 0 .../Db/Reader/InvalidDatabaseException.php | 0 .../reader/src/MaxMind/Db/Reader/Metadata.php | 0 .../reader/src/MaxMind/Db/Reader/Util.php | 0 .../maxmind/web-service-common/CHANGELOG.md | 0 .../maxmind/web-service-common/LICENSE | 0 .../maxmind/web-service-common/README.md | 0 .../maxmind/web-service-common/composer.json | 0 .../maxmind/web-service-common/phpstan.neon | 0 .../src/Exception/AuthenticationException.php | 0 .../src/Exception/HttpException.php | 0 .../Exception/InsufficientFundsException.php | 0 .../src/Exception/InvalidInputException.php | 0 .../src/Exception/InvalidRequestException.php | 0 .../Exception/IpAddressNotFoundException.php | 0 .../Exception/PermissionRequiredException.php | 0 .../src/Exception/WebServiceException.php | 0 .../src/WebService/Client.php | 0 .../src/WebService/Http/CurlRequest.php | 0 .../src/WebService/Http/Request.php | 0 .../src/WebService/Http/RequestFactory.php | 0 public/assets/js/backend/split/link.js | 59 +- public/assets/js/backend/split/number.js | 176 +- public/assets/js/backend/split/ticket.js | 156 +- public/assets/js/require-table.js | 2 +- test_root_write | 0 271 files changed, 9377 insertions(+), 303 deletions(-) mode change 100644 => 100755 _php_code/A2c.php mode change 100644 => 100755 _php_code/AbstractScrmSpider.class.php create mode 100644 _php_code/Chatknow.php mode change 100644 => 100755 _php_code/Haiwang.php mode change 100644 => 100755 _php_code/Huojian.php mode change 100644 => 100755 _php_code/SsCustomer.php mode change 100644 => 100755 _php_code/UnifiedScrmData.class.php create mode 100644 _php_code/Whatshub.php mode change 100644 => 100755 _php_code/Xinghe.php create mode 100644 _php_code/Xinghe2.php mode change 100644 => 100755 _php_code/index.php mode change 100644 => 100755 application/admin/command/Install/split_config.sql create mode 100755 application/admin/command/Install/split_config_chatknow.sql create mode 100755 application/admin/command/Install/split_config_fix_ss_customer.sql mode change 100644 => 100755 application/admin/command/Install/split_link.sql mode change 100644 => 100755 application/admin/command/Install/split_link_auto_reply.sql mode change 100644 => 100755 application/admin/command/Install/split_link_pixel.sql mode change 100644 => 100755 application/admin/command/Install/split_number.sql mode change 100644 => 100755 application/admin/command/Install/split_ticket.sql mode change 100644 => 100755 application/admin/command/Install/split_ticket_stats.sql mode change 100644 => 100755 application/admin/command/SplitSyncTickets.php mode change 100644 => 100755 application/admin/controller/split/Link.php mode change 100644 => 100755 application/admin/controller/split/Number.php mode change 100644 => 100755 application/admin/controller/split/Ticket.php mode change 100644 => 100755 application/admin/lang/zh-cn/split/link.php mode change 100644 => 100755 application/admin/lang/zh-cn/split/number.php mode change 100644 => 100755 application/admin/lang/zh-cn/split/ticket.php mode change 100644 => 100755 application/admin/model/split/Link.php mode change 100644 => 100755 application/admin/model/split/Number.php mode change 100644 => 100755 application/admin/model/split/Ticket.php mode change 100644 => 100755 application/admin/validate/split/Link.php mode change 100644 => 100755 application/admin/validate/split/Number.php mode change 100644 => 100755 application/admin/validate/split/Ticket.php mode change 100644 => 100755 application/admin/view/split/link/add.html mode change 100644 => 100755 application/admin/view/split/link/edit.html mode change 100644 => 100755 application/admin/view/split/link/index.html mode change 100644 => 100755 application/admin/view/split/number/add.html mode change 100644 => 100755 application/admin/view/split/number/edit.html mode change 100644 => 100755 application/admin/view/split/number/form_body.html mode change 100644 => 100755 application/admin/view/split/number/index.html mode change 100644 => 100755 application/admin/view/split/ticket/add.html mode change 100644 => 100755 application/admin/view/split/ticket/edit.html mode change 100644 => 100755 application/admin/view/split/ticket/form_body.html mode change 100644 => 100755 application/admin/view/split/ticket/index.html mode change 100644 => 100755 application/common/library/CountryIso.php mode change 100644 => 100755 application/common/library/scrm/AbstractScrmSpider.php mode change 100644 => 100755 application/common/library/scrm/ScrmSpiderInterface.php mode change 100644 => 100755 application/common/library/scrm/UnifiedScrmData.php mode change 100644 => 100755 application/common/library/scrm/spider/A2cSpider.php create mode 100755 application/common/library/scrm/spider/ChatknowSpider.php mode change 100644 => 100755 application/common/library/scrm/spider/HaiwangSpider.php mode change 100644 => 100755 application/common/library/scrm/spider/HuojianSpider.php mode change 100644 => 100755 application/common/library/scrm/spider/SsCustomerSpider.php create mode 100755 application/common/library/scrm/spider/WhatshubSpider.php mode change 100644 => 100755 application/common/library/scrm/spider/XingheSpider.php create mode 100755 application/common/library/scrm/spider/XingheSpider.php.bak mode change 100644 => 100755 application/common/service/DomainDetectRateLimitService.php mode change 100644 => 100755 application/common/service/SplitAutoReplyService.php create mode 100755 application/common/service/SplitCronLockService.php mode change 100644 => 100755 application/common/service/SplitFriendUrlBuilder.php mode change 100644 => 100755 application/common/service/SplitGeoIpService.php mode change 100644 => 100755 application/common/service/SplitIpProtectService.php mode change 100644 => 100755 application/common/service/SplitLinkCodeService.php create mode 100755 application/common/service/SplitNodeHealthService.php mode change 100644 => 100755 application/common/service/SplitNumberWeighService.php mode change 100644 => 100755 application/common/service/SplitPixelBrowserRenderer.php mode change 100644 => 100755 application/common/service/SplitPixelConfigService.php mode change 100644 => 100755 application/common/service/SplitPixelPostbackService.php mode change 100644 => 100755 application/common/service/SplitPlatformDomainService.php mode change 100644 => 100755 application/common/service/SplitRedirectService.php mode change 100644 => 100755 application/common/service/SplitRoundRobinStore.php mode change 100644 => 100755 application/common/service/SplitScrmSpiderFactory.php mode change 100644 => 100755 application/common/service/SplitSyncConfigService.php mode change 100644 => 100755 application/common/service/SplitTicketNumberSyncService.php mode change 100644 => 100755 application/common/service/SplitTicketRuleService.php mode change 100644 => 100755 application/common/service/SplitTicketSyncLockService.php mode change 100644 => 100755 application/common/service/SplitTicketSyncLogger.php mode change 100644 => 100755 application/common/service/SplitTicketSyncService.php mode change 100644 => 100755 application/common/service/SplitVisitPageService.php mode change 100644 => 100755 application/index/controller/S.php mode change 100644 => 100755 application/index/controller/Split.php mode change 100644 => 100755 application/index/view/split/not_found.html mode change 100644 => 100755 application/index/view/split/visit.html mode change 100644 => 100755 deploy-domain-detect-rate-limit.sh mode change 100644 => 100755 deploy_web.php mode change 100644 => 100755 patches/application/admin/command/Install/split_config.sql create mode 100755 patches/application/admin/command/Install/split_config_chatknow.sql create mode 100755 patches/application/admin/command/Install/split_config_fix_ss_customer.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_config_sync.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_link.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_link_auto_reply.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_link_pixel.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_mydomain_remove.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_number.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_number_validate.php mode change 100644 => 100755 patches/application/admin/command/Install/split_number_weigh.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_ticket.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_ticket_add_status.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_ticket_stats.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_ticket_sync.sql mode change 100644 => 100755 patches/application/admin/command/Install/split_ticket_validate.php mode change 100644 => 100755 patches/application/admin/command/SplitSyncTickets.php mode change 100644 => 100755 patches/application/admin/controller/split/Link.php mode change 100644 => 100755 patches/application/admin/controller/split/Number.php mode change 100644 => 100755 patches/application/admin/controller/split/Ticket.php mode change 100644 => 100755 patches/application/admin/lang/zh-cn/domain.php mode change 100644 => 100755 patches/application/admin/lang/zh-cn/split/link.php mode change 100644 => 100755 patches/application/admin/lang/zh-cn/split/number.php mode change 100644 => 100755 patches/application/admin/lang/zh-cn/split/ticket.php mode change 100644 => 100755 patches/application/admin/model/split/Link.php mode change 100644 => 100755 patches/application/admin/model/split/Number.php mode change 100644 => 100755 patches/application/admin/model/split/Ticket.php mode change 100644 => 100755 patches/application/admin/validate/split/Link.php mode change 100644 => 100755 patches/application/admin/view/split/link/add.html mode change 100644 => 100755 patches/application/admin/view/split/link/edit.html mode change 100644 => 100755 patches/application/admin/view/split/link/index.html mode change 100644 => 100755 patches/application/admin/view/split/number/add.html mode change 100644 => 100755 patches/application/admin/view/split/number/edit.html mode change 100644 => 100755 patches/application/admin/view/split/number/form_body.html mode change 100644 => 100755 patches/application/admin/view/split/number/index.html mode change 100644 => 100755 patches/application/admin/view/split/ticket/add.html mode change 100644 => 100755 patches/application/admin/view/split/ticket/edit.html mode change 100644 => 100755 patches/application/admin/view/split/ticket/form_body.html mode change 100644 => 100755 patches/application/admin/view/split/ticket/index.html mode change 100644 => 100755 patches/application/common/library/CountryIso.php mode change 100644 => 100755 patches/application/common/library/scrm/AbstractScrmSpider.php mode change 100644 => 100755 patches/application/common/library/scrm/ScrmSpiderInterface.php mode change 100644 => 100755 patches/application/common/library/scrm/UnifiedScrmData.php mode change 100644 => 100755 patches/application/common/library/scrm/spider/A2cSpider.php create mode 100755 patches/application/common/library/scrm/spider/ChatknowSpider.php mode change 100644 => 100755 patches/application/common/library/scrm/spider/HaiwangSpider.php mode change 100644 => 100755 patches/application/common/library/scrm/spider/HuojianSpider.php mode change 100644 => 100755 patches/application/common/library/scrm/spider/SsCustomerSpider.php create mode 100755 patches/application/common/library/scrm/spider/WhatshubSpider.php mode change 100644 => 100755 patches/application/common/library/scrm/spider/XingheSpider.php create mode 100755 patches/application/common/library/scrm/spider/XingheSpider.php.bak mode change 100644 => 100755 patches/application/common/model/Config.php mode change 100644 => 100755 patches/application/common/service/CloudflareService.php mode change 100644 => 100755 patches/application/common/service/DomainDetectRateLimitService.php mode change 100644 => 100755 patches/application/common/service/SplitAutoReplyService.php create mode 100755 patches/application/common/service/SplitCronLockService.php mode change 100644 => 100755 patches/application/common/service/SplitFriendUrlBuilder.php mode change 100644 => 100755 patches/application/common/service/SplitGeoIpService.php mode change 100644 => 100755 patches/application/common/service/SplitIpProtectService.php mode change 100644 => 100755 patches/application/common/service/SplitLinkCodeService.php create mode 100755 patches/application/common/service/SplitNodeHealthService.php mode change 100644 => 100755 patches/application/common/service/SplitNumberWeighService.php mode change 100644 => 100755 patches/application/common/service/SplitPixelBrowserRenderer.php mode change 100644 => 100755 patches/application/common/service/SplitPixelConfigService.php mode change 100644 => 100755 patches/application/common/service/SplitPixelPostbackService.php mode change 100644 => 100755 patches/application/common/service/SplitPlatformDomainService.php mode change 100644 => 100755 patches/application/common/service/SplitRedirectService.php mode change 100644 => 100755 patches/application/common/service/SplitRoundRobinStore.php mode change 100644 => 100755 patches/application/common/service/SplitScrmSpiderFactory.php mode change 100644 => 100755 patches/application/common/service/SplitSyncConfigService.php mode change 100644 => 100755 patches/application/common/service/SplitTicketNumberSyncService.php mode change 100644 => 100755 patches/application/common/service/SplitTicketRuleService.php mode change 100644 => 100755 patches/application/common/service/SplitTicketSyncLockService.php mode change 100644 => 100755 patches/application/common/service/SplitTicketSyncLogger.php mode change 100644 => 100755 patches/application/common/service/SplitTicketSyncService.php mode change 100644 => 100755 patches/application/common/service/SplitVisitPageService.php mode change 100644 => 100755 patches/application/extra/cloudflare.php mode change 100644 => 100755 patches/application/index/controller/S.php mode change 100644 => 100755 patches/application/index/controller/Split.php mode change 100644 => 100755 patches/application/index/view/split/not_found.html mode change 100644 => 100755 patches/application/index/view/split/visit.html mode change 100644 => 100755 patches/application/route_split_visit.php mode change 100644 => 100755 patches/public/assets/js/backend/domain.js mode change 100644 => 100755 patches/public/assets/js/backend/split/link.js mode change 100644 => 100755 patches/public/assets/js/backend/split/number.js mode change 100644 => 100755 patches/public/assets/js/backend/split/ticket.js create mode 100755 patches/puppeteer-api/.runtime/xdg-config/google-chrome-for-testing/Crash Reports/settings.dat create mode 100755 patches/puppeteer-api/.runtime/xdg-runtime/dconf/user create mode 100755 patches/puppeteer-api/http-ip-rewrite.js create mode 100755 patches/puppeteer-api/lib/browser-factory.js create mode 100755 patches/puppeteer-api/lib/captcha-solver.js create mode 100755 patches/puppeteer-api/lib/cf-detector.js create mode 100755 patches/puppeteer-api/lib/cf-handler.js create mode 100755 patches/puppeteer-api/lib/concurrency.js create mode 100755 patches/puppeteer-api/lib/constants.js create mode 100755 patches/puppeteer-api/lib/launch-real-browser.js create mode 100755 patches/puppeteer-api/lib/launch-standard.js create mode 100755 patches/puppeteer-api/lib/session-store.js create mode 100755 patches/puppeteer-api/lib/turnstile-handler.js create mode 100755 patches/puppeteer-api/package-lock.json create mode 100755 patches/puppeteer-api/package.json create mode 100755 patches/puppeteer-api/server.js create mode 100755 patches/puppeteer-api/url-resolve.js mode change 100644 => 100755 patches/runtime/split_rr/7.cnt mode change 100644 => 100755 patches/runtime/split_rr/999001.cnt mode change 100644 => 100755 patches/test_write.txt mode change 100644 => 100755 patches/third_party/geoip2/geoip2/CHANGELOG.md mode change 100644 => 100755 patches/third_party/geoip2/geoip2/LICENSE mode change 100644 => 100755 patches/third_party/geoip2/geoip2/README.md mode change 100644 => 100755 patches/third_party/geoip2/geoip2/composer.json mode change 100644 => 100755 patches/third_party/geoip2/geoip2/examples/benchmark.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Database/Reader.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Exception/AddressNotFoundException.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Exception/AuthenticationException.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Exception/GeoIp2Exception.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Exception/HttpException.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Exception/InvalidRequestException.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Exception/OutOfQueriesException.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/AbstractModel.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/AnonymousIp.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/Asn.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/City.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/ConnectionType.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/Country.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/Domain.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/Enterprise.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/Insights.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Model/Isp.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/ProviderInterface.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/AbstractPlaceRecord.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/AbstractRecord.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/City.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/Continent.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/Country.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/Location.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/MaxMind.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/Postal.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/RepresentedCountry.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/Subdivision.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Record/Traits.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/Util.php mode change 100644 => 100755 patches/third_party/geoip2/geoip2/src/WebService/Client.php mode change 100644 => 100755 patches/third_party/load_geoip2.php mode change 100644 => 100755 patches/third_party/maxmind-db/reader/CHANGELOG.md mode change 100644 => 100755 patches/third_party/maxmind-db/reader/LICENSE mode change 100644 => 100755 patches/third_party/maxmind-db/reader/README.md mode change 100644 => 100755 patches/third_party/maxmind-db/reader/autoload.php mode change 100644 => 100755 patches/third_party/maxmind-db/reader/composer.json mode change 100644 => 100755 patches/third_party/maxmind-db/reader/ext/config.m4 mode change 100644 => 100755 patches/third_party/maxmind-db/reader/ext/config.w32 mode change 100644 => 100755 patches/third_party/maxmind-db/reader/ext/maxminddb.c mode change 100644 => 100755 patches/third_party/maxmind-db/reader/ext/php_maxminddb.h mode change 100644 => 100755 patches/third_party/maxmind-db/reader/ext/tests/001-load.phpt mode change 100644 => 100755 patches/third_party/maxmind-db/reader/ext/tests/002-final.phpt mode change 100644 => 100755 patches/third_party/maxmind-db/reader/ext/tests/003-open-basedir.phpt mode change 100644 => 100755 patches/third_party/maxmind-db/reader/package.xml mode change 100644 => 100755 patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader.php mode change 100644 => 100755 patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php mode change 100644 => 100755 patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php mode change 100644 => 100755 patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Metadata.php mode change 100644 => 100755 patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/CHANGELOG.md mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/LICENSE mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/README.md mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/composer.json mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/phpstan.neon mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/Exception/AuthenticationException.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/Exception/HttpException.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/Exception/InsufficientFundsException.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/Exception/InvalidInputException.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/Exception/InvalidRequestException.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/Exception/IpAddressNotFoundException.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/Exception/PermissionRequiredException.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/Exception/WebServiceException.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/WebService/Client.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/WebService/Http/CurlRequest.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/WebService/Http/Request.php mode change 100644 => 100755 patches/third_party/maxmind/web-service-common/src/WebService/Http/RequestFactory.php mode change 100644 => 100755 public/assets/js/backend/split/link.js mode change 100644 => 100755 public/assets/js/backend/split/number.js mode change 100644 => 100755 public/assets/js/backend/split/ticket.js mode change 100644 => 100755 test_root_write diff --git a/_php_code/A2c.php b/_php_code/A2c.php old mode 100644 new mode 100755 diff --git a/_php_code/AbstractScrmSpider.class.php b/_php_code/AbstractScrmSpider.class.php old mode 100644 new mode 100755 index 58976e4..362557a --- a/_php_code/AbstractScrmSpider.class.php +++ b/_php_code/AbstractScrmSpider.class.php @@ -107,6 +107,11 @@ abstract class AbstractScrmSpider if ($detailApi) { $apiUrlsToIntercept[] = $detailApi; } if ($countApi) { $apiUrlsToIntercept[] = $countApi; } // var_dump($apiUrlsToIntercept); +// dump([ +// 'pageUrl' => $config['pageUrl'], +// 'apiUrls' => $apiUrlsToIntercept, +// 'authActions' => $config['authActions'] +// ]); // 【阶段一】:初始化并首屏拦截 $initResult = $this->requestNode('/api/auth-and-intercept', [ 'pageUrl' => $config['pageUrl'], @@ -120,7 +125,8 @@ abstract class AbstractScrmSpider $interceptedApis = $initResult['interceptedApis']; $cookies = $initResult['cookies']; -// dd($interceptedApis); + $finalPageUrl = $initResult['finalPageUrl'] ?? $config['pageUrl']; + // 必须拦截到 List 接口,否则无法继续 if (!isset($interceptedApis[$listApi])) { throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]"); @@ -133,7 +139,7 @@ abstract class AbstractScrmSpider // dd($countData); $listApiNode = $interceptedApis[$listApi]; $allListPagesData = [$listApiNode['data']]; // 初始化列表容器,装入第一页数据 - + // dd($allListPagesData);exit; $totalPages = $this->extractListTotalPages($listApiNode['data'], $countData); $mode = $config['paginationMode'] ?? self::MODE_FETCH; @@ -163,7 +169,7 @@ abstract class AbstractScrmSpider if ($pResult['success']) $allListPagesData[] = $pResult['data']; } } - } + } // 策略 2:强制 UI 点击 elseif ($mode === self::MODE_UI) { $uiConfig = $this->getUiPaginationConfig(); @@ -176,6 +182,7 @@ abstract class AbstractScrmSpider $uiResult = $this->requestNode('/api/ui-pagination', [ 'apiUrl' => $listApi, 'pageUrl' => $config['pageUrl'], + 'finalPageUrl' => $finalPageUrl, 'nextBtnSelector' => $uiConfig['nextBtnSelector'], 'waitMs' => $uiConfig['waitMs'] ?? 2000, 'clicksToPerform' => $clicksToPerform, diff --git a/_php_code/Chatknow.php b/_php_code/Chatknow.php new file mode 100644 index 0000000..6976d9a --- /dev/null +++ b/_php_code/Chatknow.php @@ -0,0 +1,99 @@ +account = $account; + $this->password = $password; + $this->pageUrl = $pageUrl; + + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig() + { + return [ + 'pageUrl' => $this->pageUrl, + 'apiUrls' => [self::API_LIST], + 'listApi' => self::API_LIST, // 必须,第一页的列表数据 + // 'detailApi' => self::API_DETAILS, // 选填 + // 'countApi' => self::API_COUNT, + + 'listMethod' => 'GET', + + 'paginationMode' => self::MODE_UI, + + 'authActions' => [ + // ['type' => 'vue_select', 'selector' => 'input[type="password"]', 'value' => $this->password], + // ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account], + // ['type' => 'vue_click', 'selector' => '.layui-btn', 'text' => '搜索'], + + ['type' => 'wait', 'ms' => 4000] + ] + ]; + } + + // 只负责解析 List 的总页数 + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $default_per_page_count = self::DEFAULT_PER_PAGE_COUNT; + $this->unifiedData->total = $listFirstPageData['total']; + $this->unifiedData->todayNewCount = $listFirstPageData['data']['today_num']; + + if($listFirstPageData['total'] <= $default_per_page_count) { + return 1; + } + + return ceil($listFirstPageData['total']/$default_per_page_count); + } + + // 只负责组装 List 的翻页参数 + protected function buildListPageParams($page) + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + // 提供 List 的下一页按钮信息 + protected function getUiPaginationConfig() + { + return [ + 'nextBtnSelector' => '.tabs-content .arco-pagination-item-next', + 'waitMs' => 2000 + ]; + } + + // 清爽至极的数据清洗:详情是详情,列表是列表 + protected function parseToUnifiedData($detailData, $allListPagesData) + { + $unifiedData = $this->unifiedData; + + // 循环合并了所有页数的 List 数组 + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['rows'] ?? []; + foreach ($records as $item) { + if(!empty($item['username'])) { + $number = $item['username'] ?? null; + $isOnline = (isset($item['state']) && $item['state'] == 1); + + $unifiedData->addNumber($number, $isOnline, $item['today_num']); + } + } + } + + return $unifiedData; + } +} \ No newline at end of file diff --git a/_php_code/Haiwang.php b/_php_code/Haiwang.php old mode 100644 new mode 100755 diff --git a/_php_code/Huojian.php b/_php_code/Huojian.php old mode 100644 new mode 100755 diff --git a/_php_code/SsCustomer.php b/_php_code/SsCustomer.php old mode 100644 new mode 100755 diff --git a/_php_code/UnifiedScrmData.class.php b/_php_code/UnifiedScrmData.class.php old mode 100644 new mode 100755 diff --git a/_php_code/Whatshub.php b/_php_code/Whatshub.php new file mode 100644 index 0000000..d51076a --- /dev/null +++ b/_php_code/Whatshub.php @@ -0,0 +1,124 @@ +account = $account; + $this->password = $password; + $this->pageUrl = $pageUrl; + + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig() + { + $host = (string) parse_url($this->pageUrl, PHP_URL_HOST); + return [ + 'pageUrl' => $this->pageUrl, + 'apiUrls' => [self::API_LIST, self::API_DETAILS], + + // 明确指派角色 + 'listApi' => self::API_LIST, // 必须 + + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + + 'authActions' => [ + // 1. 填入密码:寻找 class 包含 el-message-box__input 下面的任意 input + // Node.js 会自动扫描所有匹配项,并只把密码强行注入到那个“肉眼可见”的框里 + ['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password], + + // 2. 停顿 500ms,让 Vue 绑定的 v-model 彻底反应过来 + ['type' => 'wait', 'ms' => 500], + + // 3. 点击确认:寻找 MessageBox 底部的蓝色 primary 确认按钮 + // 同样利用 vue_click 的可见性过滤,无视隐藏的旧弹窗按钮 + ['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'], + + // 4. 等待弹窗淡出,接口开始请求 + ['type' => 'wait', 'ms' => 2200] + ], + // Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用 + 'antiBot' => [ + 'enabled' => true, + 'profile' => 'real', + 'turnstile' => true, + 'solverFallback' => true, + 'sessionKey' => 'whatshub:' . $host, + 'challengeTimeoutMs' => 60000, + ], + ]; + } + + // 只负责解析 List 的总页数 + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $default_per_page_count = self::DEFAULT_PER_PAGE_COUNT; + $this->unifiedData->total = $listFirstPageData['data']['total']; + + if($this->unifiedData->total <= $default_per_page_count) { + return 1; + } + + return ceil($this->unifiedData->total/$default_per_page_count); + } + + // 没有分页返回空数组 + protected function buildListPageParams($page) + { + return ['pageNum' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + // 提供 List 的下一页按钮信息 + protected function getUiPaginationConfig() + { + return [ + 'nextBtnSelector' => '.vxe-pager--next-btn', + 'waitMs' => 1500 + ]; + } + + // 清爽至极的数据清洗:详情是详情,列表是列表 + protected function parseToUnifiedData($detailData, $allListPagesData) + { + $unifiedData = $this->unifiedData; + + // 1. 如果捕获到了详情数据,提取今日新增 + if ($detailData) { + $unifiedData->todayNewCount = (int)($detailData['data']['dayNewFans'] ?? 0); + } + + // 2. 循环合并了所有页数的 List 数组 + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data']['rows'] ?? []; + foreach ($records as $item) { + if(!empty($item['account'])) { + $number = $item['account'] ?? null; + $isOnline = (isset($item['isOnline']) && $item['isOnline'] == 1); + + $unifiedData->addNumber($number, $isOnline, $item['dayNewFans']); + } + } + } + + // 🚀 3. 终极统计:所有翻页数据均已入库,此时再统计真实的 Total 总数 + $unifiedData->total = count($unifiedData->numbers); + + return $unifiedData; + } +} \ No newline at end of file diff --git a/_php_code/Xinghe.php b/_php_code/Xinghe.php old mode 100644 new mode 100755 index 70c2a2b..68ad729 --- a/_php_code/Xinghe.php +++ b/_php_code/Xinghe.php @@ -5,10 +5,10 @@ require_once __DIR__ . '/AbstractScrmSpider.class.php'; class Xinghe extends AbstractScrmSpider { - const API_LIST = '/share/share/api_yinliu_count.html'; // 列表API地址 - const API_DETAILS = ''; // 详情API地址 - const API_COUNT = ''; // 总数API地址 - const DEFAULT_PER_PAGE_COUNT = 10; // List默认每页显示的数量 + const API_LIST = '/share/share/api_yinliu_count.html'; // 列表API地址 + const API_DETAILS = ''; // 详情API地址 + const API_COUNT = ''; // 总数API地址 + const DEFAULT_PER_PAGE_COUNT = 10; // List默认每页显示的数量 private $pageUrl; private $account; private $password; @@ -29,7 +29,7 @@ class Xinghe extends AbstractScrmSpider { return [ 'pageUrl' => $this->pageUrl, - 'apiUrls' => [self::API_LIST . "?page=1&limit=10"], + 'apiUrls' => [self::API_LIST . "?page=1&limit=10&is_repet=1"], 'listApi' => self::API_LIST, // 必须,第一页的列表数据 // 'detailApi' => self::API_DETAILS, // 选填 // 'countApi' => self::API_COUNT, @@ -39,9 +39,9 @@ class Xinghe extends AbstractScrmSpider 'paginationMode' => self::MODE_FETCH, 'authActions' => [ - // ['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password], + // ['type' => 'vue_select', 'selector' => 'input[type="password"]', 'value' => $this->password], // ['type' => 'type', 'selector' => '#username_input', 'value' => $this->account], - // ['type' => 'press', 'key' => 'Enter'], + // ['type' => 'vue_click', 'selector' => '.layui-btn', 'text' => '搜索'], ['type' => 'wait', 'ms' => 2000] ] @@ -53,7 +53,7 @@ class Xinghe extends AbstractScrmSpider { $default_per_page_count = self::DEFAULT_PER_PAGE_COUNT; $this->unifiedData->total = $listFirstPageData['count']; - $this->unifiedData->todayNewCount = $listFirstPageData['totalRow']['day_sum']; + // $this->unifiedData->todayNewCount = $listFirstPageData['totalRow']['day_sum']; if($listFirstPageData['count'] <= $default_per_page_count) { return 1; @@ -84,7 +84,7 @@ class Xinghe extends AbstractScrmSpider // 循环合并了所有页数的 List 数组 foreach ($allListPagesData as $pageRaw) { - $records = $pageRaw['data'] ?? []; + $records = $pageRaw['data'] ?? []; foreach ($records as $item) { if(!empty($item['user'])) { $number = $item['user'] ?? null; diff --git a/_php_code/Xinghe2.php b/_php_code/Xinghe2.php new file mode 100644 index 0000000..07c5a17 --- /dev/null +++ b/_php_code/Xinghe2.php @@ -0,0 +1,176 @@ +pageUrl = $pageUrl; + $this->username = $username; + $this->password = $password; + + $parsedUrl = parse_url($pageUrl); + $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : ''; + $host = isset($parsedUrl['host']) ? $parsedUrl['host'] : ''; + $baseUrl = $scheme . $host; + $this->baseUrl = $baseUrl; + + $this->unifiedData = new UnifiedScrmData(); + } + + public function run() + { + // 1. 创建临时 Cookie 文件 + $this->cookieFile = tempnam(sys_get_temp_dir(), 'spider_cookie_'); + if ($this->cookieFile === false) { + throw new \RuntimeException("无法在系统临时目录创建 Cookie 文件"); + } + + // 2. 初始化 cURL 并设置全局参数 + $this->ch = curl_init(); + + $apiUrl = '/share/share/api_yinliu_count.html?page=1&limit=10&id=&class_id=&is_repet=1&start_time=&end_time='; + + $defaultOptions = [ + CURLOPT_RETURNTRANSFER => true, // 将结果返回为字符串 + CURLOPT_HEADER => false, // 不输出响应头 + CURLOPT_TIMEOUT => 15, // 设置超时时间(秒) + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', + // 核心优化:同时指定读写 Cookie 为同一个文件,cURL 会自动维护会话 + CURLOPT_COOKIEJAR => $this->cookieFile, + CURLOPT_COOKIEFILE => $this->cookieFile, + ]; + + curl_setopt_array($this->ch, $defaultOptions); + + $this->authenticate(); + $first_page_data = $this->fetchApiData($apiUrl); // 首页数据 + $this->unifiedData->total = $first_page_data['count']; // 总在线人数 + $this->unifiedData->todayNewCount = $first_page_data['totalRow']['day_sum']; // 今日新增在线人数 + + $total_pages = ceil($this->unifiedData->total/10); // 总页数 + $allListPagesData = [$first_page_data['data']]; // 初始化列表容器,装入第一页数据 + for ($page = 2; $page <= $total_pages; $page++) { + $apiUrl = '/share/share/api_yinliu_count.html?page=' . $page . '&limit=10&id=&class_id=&is_repet=1&start_time=&end_time='; + + $page_data = $this->fetchApiData($apiUrl); + + $allListPagesData[] = $page_data['data']; + } + + return $this->parseToUnifiedData($first_page_data, $allListPagesData); + } + + // 清爽至极的数据清洗:详情是详情,列表是列表 + protected function parseToUnifiedData($detailData, $allListPagesData) + { + $unifiedData = $this->unifiedData; + + // 循环合并了所有页数的 List 数组 + foreach ($allListPagesData as $records) { + foreach ($records as $item) { + if(!empty($item['user'])) { + $number = $item['user'] ?? null; + $isOnline = (isset($item['online']) && $item['online'] == 1); + + $unifiedData->addNumber($number, $isOnline, $item['day_sum']); + } + } + } + + return $unifiedData; + } + + /** + * 析构函数:释放资源,清理垃圾 + */ + public function __destruct() + { + // 关闭 cURL 会话 + if (is_resource($this->ch) || $this->ch instanceof \CurlHandle) { + curl_close($this->ch); + } + + // 删除临时 Cookie 文件 + if (file_exists($this->cookieFile)) { + unlink($this->cookieFile); + } + } + + /** + * 第一步:访问授权页面,建立会话 + * + * @param string $authUrl 包含 token 的授权地址 + * @return bool 授权请求是否成功 + * @throws \Exception + */ + public function authenticate() + { + $this->sendRequest($this->pageUrl); + return true; // 如果没有抛出异常,则认为请求成功 + } + + /** + * 第二步:请求 API 接口获取数据 + * + * @param string $apiUrl 接口数据地址 + * @return array|null 解析后的数组数据,如果解析失败返回 null + * @throws \Exception + */ + public function fetchApiData($apiUrl) + { + $response = $this->sendRequest($this->baseUrl . $apiUrl); + + // 尝试解析 JSON 数据 + $data = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException("JSON 解析失败: " . json_last_error_msg() . "。原始响应: " . $response); + } + + return $data; + } + + /** + * 发送 cURL 请求的底层私有方法 + * + * @param string $url 目标地址 + * @return string 服务器响应内容 + * @throws \Exception + */ + private function sendRequest($url) + { + curl_setopt($this->ch, CURLOPT_URL, $url); + + $response = curl_exec($this->ch); + + // 检查是否有网络或 cURL 底层错误 + if ($response === false) { + $error = curl_error($this->ch); + throw new \RuntimeException("请求失败 [{$url}]: {$error}"); + } + + // 检查 HTTP 状态码 + $httpCode = curl_getinfo($this->ch, CURLINFO_HTTP_CODE); + if ($httpCode >= 400) { + throw new \RuntimeException("HTTP 请求异常 [{$url}],状态码: {$httpCode}"); + } + + return (string)$response; + } +} \ No newline at end of file diff --git a/_php_code/index.php b/_php_code/index.php old mode 100644 new mode 100755 index db78969..65b207a --- a/_php_code/index.php +++ b/_php_code/index.php @@ -1,15 +1,86 @@ 抓取任务 (多引擎智能调度)...\n\r"; + $pageUrl = 'https://web.whatshub.cc/m/iTYsWKQH5030/1'; // PageUrl 入口授权页 + $username = ""; // 登录账号 + $password = "745030"; // 登录密码 + $spider = new Whatshub($pageUrl, $username, $password); + $finalData = $spider->run(); + + echo "✅ 任务完成!统一数据如下:\n\r"; + echo "----------------------------------------\n\r"; + echo "当日新增:{$finalData->todayNewCount} 人\n\r"; + echo "在线号码:{$finalData->totalOnline} 个\n\r"; + echo "离线号码:{$finalData->totalOffline} 个\n\r"; + echo "Total:" . $finalData->total . " 个号码\n\r"; + echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r"; + echo "号码列表:\n\r"; + echo dd($finalData->numbers); +} catch (Exception $e) { + echo "🚨 抓取异常:" . $e->getMessage() . "\n\r"; +} + + +// try { +// echo "🚀 开始执行抓取任务 (多引擎智能调度)...\n\r"; +// $pageUrl = 'https://user.chatknow.com/child/workorder-share?shareKey=jf5t6MNrJ2mC76N'; // PageUrl 入口授权页 +// $username = ""; // 登录账号 +// $password = ""; // 登录密码 +// $spider = new Chatknow($pageUrl, $username, $password); +// $finalData = $spider->run(); + +// echo "✅ 任务完成!统一数据如下:\n\r"; +// echo "----------------------------------------\n\r"; +// echo "当日新增:{$finalData->todayNewCount} 人\n\r"; +// echo "在线号码:{$finalData->totalOnline} 个\n\r"; +// echo "离线号码:{$finalData->totalOffline} 个\n\r"; +// echo "Total:" . $finalData->total . " 个号码\n\r"; +// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r"; +// echo "号码列表:\n\r"; +// echo dd($finalData->numbers); +// } catch (Exception $e) { +// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r"; +// } // try { // echo "🚀 开始执行抓取任务 (多引擎智能调度)...\n\r"; -// $pageUrl = 'https://user.a2c.chat/visitors/counter/share?id=33e449dc83c24ee59275bf03a2d82234'; // PageUrl 入口授权页 +// $pageUrl = 'https://yyk.ink/8415O53'; // PageUrl 入口授权页 // $username = ""; // 登录账号 // $password = ""; // 登录密码 // $spider = new A2c($pageUrl, $username, $password); @@ -31,7 +102,7 @@ require_once __DIR__ . '/Haiwang.php'; // 海王 // try { // echo "🚀 开始执行<星河云控>抓取任务 (多引擎智能调度)...\n\r"; -// $pageUrl = 'http://103.251.112.35:10158/share/share/index.html?token=pds65jl202t2kjis5firb8epu4d8a83ptfhc63d89l3mv11nwa'; // PageUrl 入口授权页 +// $pageUrl = 'http://8.218.14.51/share/share/index.html?token=hn6z3egq4nnnebkv4063pcdouzr4ug5js902mlqo2n3yp9gzhe'; // PageUrl 入口授权页 // $username = ""; // 登录账号 // $password = ""; // 登录密码 // $spider = new Xinghe($pageUrl, $username, $password); @@ -46,16 +117,15 @@ require_once __DIR__ . '/Haiwang.php'; // 海王 // echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r"; // echo "号码列表:\n\r"; // echo dd($finalData->numbers); - // } catch (Exception $e) { // echo "🚨 抓取异常:" . $e->getMessage() . "\n\r"; // } // try { // echo "🚀 开始执行<火箭工单>抓取任务 (多引擎智能调度)...\n\r"; -// $pageUrl = 'https://s.url99.me/ygn9zjr8'; // PageUrl 入口授权页 +// $pageUrl = 'https://s.url99.me/68vfje8m'; // PageUrl 入口授权页 // $username = ""; // 登录账号 -// $password = "123456"; // 登录密码 +// $password = "542187"; // 登录密码 // $spider = new Huojian($pageUrl, $username, $password); // $finalData = $spider->run(); @@ -96,27 +166,27 @@ require_once __DIR__ . '/Haiwang.php'; // 海王 // echo "🚨 抓取异常:" . $e->getMessage() . "\n\r"; // } -try { - echo "🚀 开始执行<海王>抓取任务 (多引擎智能调度)...\n\r"; - $pageUrl = 'https://admin.haiwangweb.com/web#/accountshow/pZsEulYrb'; // PageUrl 入口授权页 - $username = ""; // 登录账号 - $password = "9999"; // 登录密码 - $spider = new Haiwang($pageUrl, $username, $password); - $finalData = $spider->run(); +// try { +// echo "🚀 开始执行<海王>抓取任务 (多引擎智能调度)...\n\r"; +// $pageUrl = 'https://admin.haiwangweb.com/web#/accountshow/pZsEulYrb'; // PageUrl 入口授权页 +// $username = ""; // 登录账号 +// $password = "9999"; // 登录密码 +// $spider = new Haiwang($pageUrl, $username, $password); +// $finalData = $spider->run(); - echo "✅ 任务完成!统一数据如下:\n\r"; - echo "----------------------------------------\n\r"; - echo "当日新增:{$finalData->todayNewCount} 人\n\r"; - echo "在线号码:{$finalData->totalOnline} 个\n\r"; - echo "离线号码:{$finalData->totalOffline} 个\n\r"; - echo "Total:" . $finalData->total . " 个号码\n\r"; - echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r"; - echo "号码列表:\n\r"; - echo dd($finalData->numbers); +// echo "✅ 任务完成!统一数据如下:\n\r"; +// echo "----------------------------------------\n\r"; +// echo "当日新增:{$finalData->todayNewCount} 人\n\r"; +// echo "在线号码:{$finalData->totalOnline} 个\n\r"; +// echo "离线号码:{$finalData->totalOffline} 个\n\r"; +// echo "Total:" . $finalData->total . " 个号码\n\r"; +// echo "实际总共抓取:" . count($finalData->numbers) . " 个号码\n\r"; +// echo "号码列表:\n\r"; +// echo dd($finalData->numbers); -} catch (Exception $e) { - echo "🚨 抓取异常:" . $e->getMessage() . "\n\r"; -} +// } catch (Exception $e) { +// echo "🚨 抓取异常:" . $e->getMessage() . "\n\r"; +// } // try { // echo "🚀 开始执行抓取任务 (多引擎智能调度)...\n\r"; diff --git a/application/admin/command/Install/split_config.sql b/application/admin/command/Install/split_config.sql old mode 100644 new mode 100755 diff --git a/application/admin/command/Install/split_config_chatknow.sql b/application/admin/command/Install/split_config_chatknow.sql new file mode 100755 index 0000000..339c326 --- /dev/null +++ b/application/admin/command/Install/split_config_chatknow.sql @@ -0,0 +1,6 @@ +-- Chatknow SCRM 工单云控自动同步周期(分钟,0=不自动同步) +SET NAMES utf8mb4; + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_chatknow', 'split', 'Chatknow SCRM同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_chatknow' LIMIT 1); diff --git a/application/admin/command/Install/split_config_fix_ss_customer.sql b/application/admin/command/Install/split_config_fix_ss_customer.sql new file mode 100755 index 0000000..d33f09a --- /dev/null +++ b/application/admin/command/Install/split_config_fix_ss_customer.sql @@ -0,0 +1,14 @@ +-- 修复 SS 云控同步周期配置键名拼写错误:ss_custome -> ss_customer +SET NAMES utf8mb4; + +UPDATE `fa_config` +SET `name` = 'split_sync_interval_ss_customer' +WHERE `name` = 'split_sync_interval_ss_custome' + AND NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer') AS `t`); + +UPDATE `fa_config` AS `correct` +INNER JOIN `fa_config` AS `wrong` ON `wrong`.`name` = 'split_sync_interval_ss_custome' +SET `correct`.`value` = `wrong`.`value` +WHERE `correct`.`name` = 'split_sync_interval_ss_customer'; + +DELETE FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_custome'; diff --git a/application/admin/command/Install/split_link.sql b/application/admin/command/Install/split_link.sql old mode 100644 new mode 100755 diff --git a/application/admin/command/Install/split_link_auto_reply.sql b/application/admin/command/Install/split_link_auto_reply.sql old mode 100644 new mode 100755 diff --git a/application/admin/command/Install/split_link_pixel.sql b/application/admin/command/Install/split_link_pixel.sql old mode 100644 new mode 100755 diff --git a/application/admin/command/Install/split_number.sql b/application/admin/command/Install/split_number.sql old mode 100644 new mode 100755 index 939b10f..0d20557 --- a/application/admin/command/Install/split_number.sql +++ b/application/admin/command/Install/split_number.sql @@ -71,6 +71,13 @@ WHERE m.name = 'split.number' AND m.ismenu = 1 AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1) LIMIT 1; +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.number/batchoperate', '批量操作号码', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.number' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchoperate' LIMIT 1) +LIMIT 1; + INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' FROM `fa_auth_rule` m diff --git a/application/admin/command/Install/split_ticket.sql b/application/admin/command/Install/split_ticket.sql old mode 100644 new mode 100755 diff --git a/application/admin/command/Install/split_ticket_stats.sql b/application/admin/command/Install/split_ticket_stats.sql old mode 100644 new mode 100755 diff --git a/application/admin/command/SplitSyncTickets.php b/application/admin/command/SplitSyncTickets.php old mode 100644 new mode 100755 index f6f317c..4868214 --- a/application/admin/command/SplitSyncTickets.php +++ b/application/admin/command/SplitSyncTickets.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\admin\command; +use app\common\service\SplitCronLockService; use app\common\service\SplitTicketSyncLogger; use app\common\service\SplitTicketSyncService; use think\console\Command; @@ -51,8 +52,20 @@ class SplitSyncTickets extends Command return; } - $count = $service->syncDueTickets(); - $output->writeln('本次处理工单数: ' . $count . ''); + $cronLock = new SplitCronLockService(); + if (!$cronLock->acquire()) { + SplitTicketSyncLogger::log('cli', 'cron lock busy, skip'); + $output->writeln('上一轮同步仍在执行,跳过本次'); + return; + } + + try { + $count = $service->syncDueTickets(); + $output->writeln('本次处理工单数: ' . $count . ''); + } finally { + $cronLock->release(); + } + if (SplitTicketSyncLogger::isEnabled()) { $output->writeln('调试日志已写入 runtime/log/split_sync.log'); } diff --git a/application/admin/controller/split/Link.php b/application/admin/controller/split/Link.php old mode 100644 new mode 100755 index 67641e1..bfc7c91 --- a/application/admin/controller/split/Link.php +++ b/application/admin/controller/split/Link.php @@ -64,10 +64,35 @@ class Link extends Backend $this->assignconfig('ipProtectList', $this->model->getIpProtectList()); $this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList()); $this->assignconfig('statusList', $this->model->getStatusList()); + $this->assignconfig('linkFilterList', $this->buildLinkFilterList()); $this->setupPatchFrontend(); } + /** + * 分流链接列表筛选下拉(id => 链接码 - 描述) + * + * @return array + */ + private function buildLinkFilterList(): array + { + $query = $this->model->order('id', 'desc'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $query->where('admin_id', 'in', $adminIds); + } + } + $list = []; + foreach ($query->field('id,link_code,description')->select() as $row) { + $code = (string) $row['link_code']; + $desc = (string) $row['description']; + $label = $desc !== '' ? $code . ' - ' . $desc : $code; + $list[(string) $row['id']] = $label; + } + return $list; + } + /** * 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口 */ diff --git a/application/admin/controller/split/Number.php b/application/admin/controller/split/Number.php old mode 100644 new mode 100755 index 5d9179d..d73b491 --- a/application/admin/controller/split/Number.php +++ b/application/admin/controller/split/Number.php @@ -29,6 +29,9 @@ class Number extends Backend protected $dataLimit = 'personal'; + /** 关联 splitLink 预载入时需为筛选/排序字段加表别名,避免 status 等列歧义 */ + protected $relationSearch = true; + protected $modelValidate = true; protected $modelSceneValidate = true; @@ -62,6 +65,8 @@ class Number extends Backend $this->assignconfig('statusList', $this->model->getStatusList()); $this->assignconfig('manualManageList', $this->model->getManualManageList()); $this->assignconfig('platformStatusList', $this->model->getPlatformStatusList()); + $this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList()); + $this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig()); $this->setupPatchFrontend(); } @@ -111,6 +116,52 @@ class Number extends Backend return $list; } + /** + * 已有号码关联的分流链接(供列表筛选下拉) + * + * @return array + */ + private function buildNumberSplitLinkFilterList(): array + { + $numberTable = $this->model->getTable(); + $linkTable = (new LinkModel())->getTable(); + $query = Db::table($numberTable) + ->alias('n') + ->join($linkTable . ' l', 'n.split_link_id = l.id') + ->where('n.split_link_id', '>', 0) + ->group('n.split_link_id') + ->field('l.id,l.link_code,l.description') + ->order('l.id', 'desc'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $query->where('n.admin_id', 'in', $adminIds); + } + } + $list = []; + foreach ($query->select() as $row) { + $code = (string) $row['link_code']; + $desc = (string) $row['description']; + $label = $desc !== '' ? $code . ' - ' . $desc : $code; + $list[(string) $row['id']] = $label; + } + return $list; + } + + /** + * 全部分流链接(供批量操作弹窗可搜索下拉) + * + * @return array + */ + private function buildSplitLinkSelectConfig(): array + { + $list = []; + foreach ($this->buildSplitLinkList() as $row) { + $list[(string) $row['id']] = (string) $row['label']; + } + return $list; + } + private function fetchPatch(string $template): string { $patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html'; @@ -395,6 +446,85 @@ class Number extends Backend $this->success(__('Batch update success')); } + /** + * 按分流链接 + 号码文本批量开启/关闭/删除 + */ + public function batchoperate(): void + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + + $splitLinkId = (int) $this->request->post('split_link_id', 0); + $numbersText = (string) $this->request->post('numbers', ''); + $action = (string) $this->request->post('action', ''); + + if ($splitLinkId <= 0) { + $this->error(__('Please select split link')); + } + + $numberList = NumberModel::parseNumbersText($numbersText); + if ($numberList === []) { + $this->error(__('Please fill at least one number')); + } + + $allowedActions = ['enable', 'disable', 'delete']; + if (!in_array($action, $allowedActions, true)) { + $this->error(__('Invalid batch operate action')); + } + + $linkQuery = (new LinkModel())->where('id', $splitLinkId)->where('status', 'normal'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $linkQuery->where('admin_id', 'in', $adminIds); + } + } + if (!$linkQuery->find()) { + $this->error(__('No Results were found')); + } + + $query = $this->model + ->where('split_link_id', $splitLinkId) + ->where('number', 'in', $numberList); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $query->where('admin_id', 'in', $adminIds); + } + } + + $count = 0; + Db::startTrans(); + try { + if ($action === 'delete') { + $rows = $query->select(); + foreach ($rows as $row) { + if ($row->delete()) { + $count++; + } + } + } else { + $status = $action === 'enable' ? 'normal' : 'hidden'; + $manualManage = $action === 'enable' ? 0 : 1; + $count = (int) $query->update([ + 'status' => $status, + 'manual_manage' => $manualManage, + ]); + } + Db::commit(); + } catch (PDOException|Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + if ($count <= 0) { + $this->error(__('No matching numbers found')); + } + + $this->success(sprintf(__('Batch operate success'), $count)); + } + /** * 排除不可由表单提交的字段 * diff --git a/application/admin/controller/split/Ticket.php b/application/admin/controller/split/Ticket.php old mode 100644 new mode 100755 index 31fbec6..d4d456e --- a/application/admin/controller/split/Ticket.php +++ b/application/admin/controller/split/Ticket.php @@ -58,6 +58,7 @@ class Ticket extends Backend $this->assignconfig('ticketTypeList', $this->model->getTicketTypeList()); $this->assignconfig('numberTypeList', $this->model->getNumberTypeList()); $this->assignconfig('statusList', $this->model->getStatusList()); + $this->assignconfig('splitLinkFilterList', $this->buildTicketSplitLinkFilterList()); $this->assignconfig([ 'syncConfirmMsg' => __('Sync confirm'), 'syncBackgroundStartedMsg' => __('Sync background started'), @@ -142,6 +143,71 @@ class Ticket extends Backend return $list; } + /** + * 已有工单关联的分流链接(供列表筛选下拉) + * + * @return array id => label + */ + private function buildTicketSplitLinkFilterList(): array + { + $ticketTable = $this->model->getTable(); + $linkTable = (new LinkModel())->getTable(); + $query = Db::table($ticketTable) + ->alias('t') + ->join($linkTable . ' l', 't.split_link_id = l.id') + ->where('t.split_link_id', '>', 0) + ->group('t.split_link_id') + ->field('l.id,l.link_code,l.description') + ->order('l.id', 'desc'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $query->where('t.admin_id', 'in', $adminIds); + } + } + $list = []; + foreach ($query->select() as $row) { + $code = (string) $row['link_code']; + $desc = (string) $row['description']; + $label = $desc !== '' ? $code . ' - ' . $desc : $code; + $list[(string) $row['id']] = $label; + } + return $list; + } + + /** + * 按当前筛选条件汇总列表统计列 + * + * @param callable|array $where buildparams() 返回的闭包或条件数组 + * @return array + */ + private function buildListSummary($where): array + { + $stats = $this->model + ->where($where) + ->fieldRaw( + 'COALESCE(SUM(ticket_total), 0) AS sum_ticket_total,' + . ' COALESCE(SUM(complete_count), 0) AS sum_complete_count,' + . ' COALESCE(SUM(speed_per_hour), 0) AS sum_speed_per_hour,' + . ' COALESCE(AVG(CASE WHEN ticket_total > 0 THEN complete_count * 100.0 / ticket_total ELSE 0 END), 0) AS avg_ticket_progress' + ) + ->find(); + if (!$stats) { + return [ + 'ticket_total' => 0, + 'complete_count' => 0, + 'speed_per_hour' => '0.00', + 'ticket_progress_text' => '0.00%', + ]; + } + return [ + 'ticket_total' => (int) ($stats['sum_ticket_total'] ?? 0), + 'complete_count' => (int) ($stats['sum_complete_count'] ?? 0), + 'speed_per_hour' => number_format((float) ($stats['sum_speed_per_hour'] ?? 0), 2, '.', ''), + 'ticket_progress_text' => number_format((float) ($stats['avg_ticket_progress'] ?? 0), 2, '.', '') . '%', + ]; + } + private function fetchPatch(string $template): string { $patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html'; @@ -179,7 +245,11 @@ class Ticket extends Backend ->where($where) ->order($sort, $order) ->paginate($limit); - $result = ['total' => $list->total(), 'rows' => $list->items()]; + $result = [ + 'total' => $list->total(), + 'rows' => $list->items(), + 'summary' => $this->buildListSummary($where), + ]; return json($result); } @@ -331,6 +401,10 @@ class Ticket extends Backend public function add() { if (false === $this->request->isPost()) { + $copyRow = $this->buildCopyRowData(); + if ($copyRow !== null) { + $this->view->assign('row', $copyRow); + } return $this->fetchPatch('add'); } $params = $this->request->post('row/a', []); @@ -367,6 +441,60 @@ class Ticket extends Backend $this->success('', null, ['id' => (int) $this->model->id]); } + /** + * 从 copy_id 构建添加工单表单的预填数据(仅复制业务字段,不含同步统计) + * + * @return array|null + */ + private function buildCopyRowData(): ?array + { + $copyId = (int) $this->request->get('copy_id/d', 0); + if ($copyId <= 0) { + return null; + } + + $row = $this->model->get($copyId); + if (!$row) { + return null; + } + + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { + return null; + } + + $data = $row->toArray(); + $copyFields = [ + 'ticket_type', + 'ticket_name', + 'ticket_url', + 'ticket_total', + 'split_link_id', + 'number_type', + 'number_type_custom', + 'order_limit', + 'assign_ratio', + 'account', + 'password', + ]; + $copyRow = []; + foreach ($copyFields as $field) { + if (array_key_exists($field, $data)) { + $copyRow[$field] = $data[$field]; + } + } + + $copyRow['start_time'] = $data['start_time_text'] ?? ''; + $copyRow['end_time'] = $data['end_time_text'] ?? ''; + + $name = trim((string) ($copyRow['ticket_name'] ?? '')); + if ($name !== '' && mb_strpos($name, ' (副本)') === false) { + $copyRow['ticket_name'] = $name . ' (副本)'; + } + + return $copyRow; + } + /** * @param string|null $ids * @return string diff --git a/application/admin/lang/zh-cn/split/link.php b/application/admin/lang/zh-cn/split/link.php old mode 100644 new mode 100755 index 843d2b9..e23c70b --- a/application/admin/lang/zh-cn/split/link.php +++ b/application/admin/lang/zh-cn/split/link.php @@ -37,7 +37,7 @@ return [ 'System config' => '系统配置', 'Auto reply' => '自动回复', 'Reply statements' => '回复语句', - 'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联', + 'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联;跳转 WhatsApp / Telegram 时随机附加为预填消息', 'Auto reply saved' => '自动回复已保存', 'Reply statements column' => '回复语', 'NS' => 'NS', diff --git a/application/admin/lang/zh-cn/split/number.php b/application/admin/lang/zh-cn/split/number.php old mode 100644 new mode 100755 index bc90983..bec8fa0 --- a/application/admin/lang/zh-cn/split/number.php +++ b/application/admin/lang/zh-cn/split/number.php @@ -30,6 +30,18 @@ return [ 'Batch selected count' => '已选号码', 'Batch update status' => '更新状态', 'Batch update btn' => '更新状态', + 'Batch operate btn' => '批量操作', + 'Batch operate title' => '批量操作', + 'Batch operate action' => '操作', + 'Batch operate enable' => '开启状态', + 'Batch operate disable' => '关闭状态', + 'Batch operate delete' => '删除号码', + 'Batch operate success' => '批量操作成功,共处理 %d 条', + 'Invalid batch operate action' => '操作类型无效', + 'No matching numbers found' => '未找到匹配的号码', + 'Please select split link' => '请选择分流链接', + 'Numbers one per line' => '一行一个号码', + 'Batch operate delete confirm' => '确定要删除这些号码吗?', 'Please fill at least one number' => '请至少填写一个有效号码', 'All numbers already exist for this link' => '该链接下号码均已存在,未新增任何记录', 'Number already exists for this link' => '该链接下已存在相同号码', diff --git a/application/admin/lang/zh-cn/split/ticket.php b/application/admin/lang/zh-cn/split/ticket.php old mode 100644 new mode 100755 index 78d7f21..67fafa4 --- a/application/admin/lang/zh-cn/split/ticket.php +++ b/application/admin/lang/zh-cn/split/ticket.php @@ -38,6 +38,8 @@ return [ 'Sync display pending' => '待同步', 'Sync display error' => '同步异常', 'Createtime' => '创建时间', + 'Copy' => '拷贝', + 'Summary row' => '汇总', 'Section basic' => '基础信息', 'Section time rule' => '时间与规则', 'Section account' => '账号信息', @@ -51,6 +53,7 @@ return [ 'Ticket type yifafa' => '译发发云控', 'Ticket type a2c' => 'A2C云控', 'Ticket type ceo_scrm' => 'CEO SCRM', + 'Ticket type chatknow' => 'Chatknow SCRM', 'Ticket type whatshub' => 'Whatshub云控', 'Ticket type sihai' => '四海云控', 'End time must after start' => '到期时间必须晚于开始时间', diff --git a/application/admin/model/split/Link.php b/application/admin/model/split/Link.php old mode 100644 new mode 100755 diff --git a/application/admin/model/split/Number.php b/application/admin/model/split/Number.php old mode 100644 new mode 100755 index 2085f63..0231f91 --- a/application/admin/model/split/Number.php +++ b/application/admin/model/split/Number.php @@ -98,7 +98,7 @@ class Number extends Model */ public function splitLink() { - return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0); + return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1); } public function setNumberTypeCustomAttr($value): string diff --git a/application/admin/model/split/Ticket.php b/application/admin/model/split/Ticket.php old mode 100644 new mode 100755 index ae3a552..576913c --- a/application/admin/model/split/Ticket.php +++ b/application/admin/model/split/Ticket.php @@ -50,6 +50,7 @@ class Ticket extends Model 'yifafa' => __('Ticket type yifafa'), 'a2c' => __('Ticket type a2c'), 'ceo_scrm' => __('Ticket type ceo_scrm'), + 'chatknow' => __('Ticket type chatknow'), 'whatshub' => __('Ticket type whatshub'), 'sihai' => __('Ticket type sihai'), ]; diff --git a/application/admin/validate/split/Link.php b/application/admin/validate/split/Link.php old mode 100644 new mode 100755 diff --git a/application/admin/validate/split/Number.php b/application/admin/validate/split/Number.php old mode 100644 new mode 100755 diff --git a/application/admin/validate/split/Ticket.php b/application/admin/validate/split/Ticket.php old mode 100644 new mode 100755 diff --git a/application/admin/view/split/link/add.html b/application/admin/view/split/link/add.html old mode 100644 new mode 100755 diff --git a/application/admin/view/split/link/edit.html b/application/admin/view/split/link/edit.html old mode 100644 new mode 100755 diff --git a/application/admin/view/split/link/index.html b/application/admin/view/split/link/index.html old mode 100644 new mode 100755 diff --git a/application/admin/view/split/number/add.html b/application/admin/view/split/number/add.html old mode 100644 new mode 100755 diff --git a/application/admin/view/split/number/edit.html b/application/admin/view/split/number/edit.html old mode 100644 new mode 100755 diff --git a/application/admin/view/split/number/form_body.html b/application/admin/view/split/number/form_body.html old mode 100644 new mode 100755 diff --git a/application/admin/view/split/number/index.html b/application/admin/view/split/number/index.html old mode 100644 new mode 100755 index c8f4766..106b43b --- a/application/admin/view/split/number/index.html +++ b/application/admin/view/split/number/index.html @@ -18,6 +18,7 @@ {:__('Edit')} {:__('Delete')} {:__('Batch update btn')} + {:__('Batch operate btn')}
+
+ {:__('Summary row')} + {:__('Ticket_total')}0 + {:__('Complete_count')}0 + {:__('Ticket_progress')}0.00% + {:__('Speed_per_hour')}0.00 +
*/ private const COUNTRIES = [ - 'CN' => '中国', - 'HK' => '中国香港', - 'MO' => '中国澳门', - 'TW' => '中国台湾', - 'US' => '美国', - 'CA' => '加拿大', - 'GB' => '英国', - 'DE' => '德国', - 'FR' => '法国', - 'IT' => '意大利', - 'ES' => '西班牙', - 'NL' => '荷兰', - 'BE' => '比利时', - 'CH' => '瑞士', - 'AT' => '奥地利', - 'SE' => '瑞典', - 'NO' => '挪威', - 'DK' => '丹麦', - 'FI' => '芬兰', - 'IE' => '爱尔兰', - 'PT' => '葡萄牙', - 'PL' => '波兰', - 'CZ' => '捷克', - 'HU' => '匈牙利', - 'RO' => '罗马尼亚', - 'GR' => '希腊', - 'RU' => '俄罗斯', - 'UA' => '乌克兰', - 'TR' => '土耳其', - 'IL' => '以色列', - 'SA' => '沙特阿拉伯', - 'AE' => '阿联酋', - 'QA' => '卡塔尔', - 'KW' => '科威特', - 'IN' => '印度', - 'PK' => '巴基斯坦', - 'BD' => '孟加拉国', - 'TH' => '泰国', - 'VN' => '越南', - 'MY' => '马来西亚', - 'SG' => '新加坡', - 'ID' => '印度尼西亚', - 'PH' => '菲律宾', - 'JP' => '日本', - 'KR' => '韩国', - 'AU' => '澳大利亚', - 'NZ' => '新西兰', - 'BR' => '巴西', - 'MX' => '墨西哥', + 'AD' => '安道尔', + 'AE' => '阿拉伯联合酋长国', + 'AF' => '阿富汗', + 'AG' => '安提瓜和巴布达', + 'AI' => '安圭拉', + 'AL' => '阿尔巴尼亚', + 'AM' => '亚美尼亚', + 'AO' => '安哥拉', + 'AQ' => '南极洲', 'AR' => '阿根廷', + 'AS' => '美属萨摩亚', + 'AT' => '奥地利', + 'AU' => '澳大利亚', + 'AW' => '阿鲁巴', + 'AX' => '奥兰群岛', + 'AZ' => '阿塞拜疆', + 'BA' => '波斯尼亚和黑塞哥维那', + 'BB' => '巴巴多斯', + 'BD' => '孟加拉国', + 'BE' => '比利时', + 'BF' => '布基纳法索', + 'BG' => '保加利亚', + 'BH' => '巴林', + 'BI' => '布隆迪', + 'BJ' => '贝宁', + 'BL' => '圣巴泰勒米', + 'BM' => '百慕大', + 'BN' => '文莱', + 'BO' => '玻利维亚', + 'BQ' => '荷兰加勒比区', + 'BR' => '巴西', + 'BS' => '巴哈马', + 'BT' => '不丹', + 'BV' => '布韦岛', + 'BW' => '博茨瓦纳', + 'BY' => '白俄罗斯', + 'BZ' => '伯利兹', + 'CA' => '加拿大', + 'CC' => '科科斯(基林)群岛', + 'CD' => '刚果(金)', + 'CF' => '中非共和国', + 'CG' => '刚果(布)', + 'CH' => '瑞士', + 'CI' => '科特迪瓦', + 'CK' => '库克群岛', 'CL' => '智利', + 'CM' => '喀麦隆', + 'CN' => '中国', 'CO' => '哥伦比亚', - 'PE' => '秘鲁', - 'ZA' => '南非', + 'CR' => '哥斯达黎加', + 'CU' => '古巴', + 'CV' => '佛得角', + 'CW' => '库拉索', + 'CX' => '圣诞岛', + 'CY' => '塞浦路斯', + 'CZ' => '捷克', + 'DE' => '德国', + 'DJ' => '吉布提', + 'DK' => '丹麦', + 'DM' => '多米尼克', + 'DO' => '多米尼加共和国', + 'DZ' => '阿尔及利亚', + 'EC' => '厄瓜多尔', + 'EE' => '爱沙尼亚', 'EG' => '埃及', - 'NG' => '尼日利亚', + 'EH' => '西撒哈拉', + 'ER' => '厄立特里亚', + 'ES' => '西班牙', + 'ET' => '埃塞俄比亚', + 'FI' => '芬兰', + 'FJ' => '斐济', + 'FK' => '福克兰群岛(马尔维纳斯)', + 'FM' => '密克罗尼西亚联邦', + 'FO' => '法罗群岛', + 'FR' => '法国', + 'GA' => '加蓬', + 'GB' => '英国', + 'GD' => '格林纳达', + 'GE' => '格鲁吉亚', + 'GF' => '法属圭亚那', + 'GG' => '根西岛', + 'GH' => '加纳', + 'GI' => '直布罗陀', + 'GL' => '格陵兰', + 'GM' => '冈比亚', + 'GN' => '几内亚', + 'GP' => '瓜德罗普', + 'GQ' => '赤道几内亚', + 'GR' => '希腊', + 'GS' => '南乔治亚和南桑威奇群岛', + 'GT' => '危地马拉', + 'GU' => '关岛', + 'GW' => '几内亚比绍', + 'GY' => '圭亚那', + 'HK' => '香港', + 'HM' => '赫德岛和麦克唐纳群岛', + 'HN' => '洪都拉斯', + 'HR' => '克罗地亚', + 'HT' => '海地', + 'HU' => '匈牙利', + 'ID' => '印度尼西亚', + 'IE' => '爱尔兰', + 'IL' => '以色列', + 'IM' => '马恩岛', + 'IN' => '印度', + 'IO' => '英属印度洋领地', + 'IQ' => '伊拉克', + 'IR' => '伊朗', + 'IS' => '冰岛', + 'IT' => '意大利', + 'JE' => '泽西岛', + 'JM' => '牙买加', + 'JO' => '约旦', + 'JP' => '日本', 'KE' => '肯尼亚', + 'KG' => '吉尔吉斯斯坦', + 'KH' => '柬埔寨', + 'KI' => '基里巴斯', + 'KM' => '科摩罗', + 'KN' => '圣基茨和尼维斯', + 'KP' => '朝鲜', + 'KR' => '韩国', + 'KW' => '科威特', + 'KY' => '开曼群岛', + 'KZ' => '哈萨克斯坦', + 'LA' => '老挝', + 'LB' => '黎巴嫩', + 'LC' => '圣卢西亚', + 'LI' => '列支敦士登', + 'LK' => '斯里兰卡', + 'LR' => '利比里亚', + 'LS' => '莱索托', + 'LT' => '立陶宛', + 'LU' => '卢森堡', + 'LV' => '拉脱维亚', + 'LY' => '利比亚', + 'MA' => '摩洛哥', + 'MC' => '摩纳哥', + 'MD' => '摩尔多瓦', + 'ME' => '黑山', + 'MF' => '法属圣马丁', + 'MG' => '马达加斯加', + 'MH' => '马绍尔群岛', + 'MK' => '北马其顿', + 'ML' => '马里', + 'MM' => '缅甸', + 'MN' => '蒙古', + 'MO' => '澳门', + 'MP' => '北马里亚纳群岛', + 'MQ' => '马提尼克', + 'MR' => '毛里塔尼亚', + 'MS' => '蒙特塞拉特', + 'MT' => '马耳他', + 'MU' => '毛里求斯', + 'MV' => '马尔代夫', + 'MW' => '马拉维', + 'MX' => '墨西哥', + 'MY' => '马来西亚', + 'MZ' => '莫桑比克', + 'NA' => '纳米比亚', + 'NC' => '新喀里多尼亚', + 'NE' => '尼日尔', + 'NF' => '诺福克岛', + 'NG' => '尼日利亚', + 'NI' => '尼加拉瓜', + 'NL' => '荷兰', + 'NO' => '挪威', + 'NP' => '尼泊尔', + 'NR' => '瑙鲁', + 'NU' => '纽埃', + 'NZ' => '新西兰', + 'OM' => '阿曼', + 'PA' => '巴拿马', + 'PE' => '秘鲁', + 'PF' => '法属波利尼西亚', + 'PG' => '巴布亚新几内亚', + 'PH' => '菲律宾', + 'PK' => '巴基斯坦', + 'PL' => '波兰', + 'PM' => '圣皮埃尔和密克隆', + 'PN' => '皮特凯恩群岛', + 'PR' => '波多黎各', + 'PS' => '巴勒斯坦', + 'PT' => '葡萄牙', + 'PW' => '帕劳', + 'PY' => '巴拉圭', + 'QA' => '卡塔尔', + 'RE' => '留尼汪', + 'RO' => '罗马尼亚', + 'RS' => '塞尔维亚', + 'RU' => '俄罗斯', + 'RW' => '卢旺达', + 'SA' => '沙特阿拉伯', + 'SB' => '所罗门群岛', + 'SC' => '塞舌尔', + 'SD' => '苏丹', + 'SE' => '瑞典', + 'SG' => '新加坡', + 'SH' => '圣赫勒拿、阿森松和特里斯坦-达库尼亚', + 'SI' => '斯洛文尼亚', + 'SJ' => '斯瓦尔巴和扬马延', + 'SK' => '斯洛伐克', + 'SL' => '塞拉利昂', + 'SM' => '圣马力诺', + 'SN' => '塞内加尔', + 'SO' => '索马里', + 'SR' => '苏里南', + 'SS' => '南苏丹', + 'ST' => '圣多美和普林西比', + 'SV' => '萨尔瓦多', + 'SX' => '荷属圣马丁', + 'SY' => '叙利亚', + 'SZ' => '斯威士兰(斯瓦帝尼)', + 'TC' => '特克斯和凯科斯群岛', + 'TD' => '乍得', + 'TF' => '法属南部领地', + 'TG' => '多哥', + 'TH' => '泰国', + 'TJ' => '塔吉克斯坦', + 'TK' => '托克劳', + 'TL' => '东帝汶', + 'TM' => '土库曼斯坦', + 'TN' => '突尼斯', + 'TO' => '汤加', + 'TR' => '土耳其', + 'TT' => '特立尼达和多巴哥', + 'TV' => '图瓦卢', + 'TW' => '台湾', + 'TZ' => '坦桑尼亚', + 'UA' => '乌克兰', + 'UG' => '乌干达', + 'UM' => '美国本土外小岛屿', + 'US' => '美国', + 'UY' => '乌拉圭', + 'UZ' => '乌兹别克斯坦', + 'VA' => '梵蒂冈', + 'VC' => '圣文森特和格林纳丁斯', + 'VE' => '委内瑞拉', + 'VG' => '英属维尔京群岛', + 'VI' => '美属维尔京群岛', + 'VN' => '越南', + 'VU' => '瓦努阿图', + 'WF' => '瓦利斯和富图纳', + 'WS' => '萨摩亚', + 'YE' => '也门', + 'YT' => '马约特', + 'ZA' => '南非', + 'ZM' => '赞比亚', + 'ZW' => '津巴布韦' ]; /** diff --git a/application/common/library/scrm/AbstractScrmSpider.php b/application/common/library/scrm/AbstractScrmSpider.php old mode 100644 new mode 100755 index 008ed30..902252a --- a/application/common/library/scrm/AbstractScrmSpider.php +++ b/application/common/library/scrm/AbstractScrmSpider.php @@ -67,22 +67,36 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface $apiUrlsToIntercept[] = $countApi; } + $antiBot = $config['antiBot'] ?? null; + $initResult = $this->requestNode('/api/auth-and-intercept', [ 'pageUrl' => $config['pageUrl'], 'apiUrls' => $apiUrlsToIntercept, 'authActions' => $config['authActions'] ?? [], + 'antiBot' => $antiBot, ]); if (empty($initResult['success'])) { + $cfCode = (string) ($initResult['code'] ?? ''); SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [ - 'error' => $initResult['error'] ?? '未知', + 'error' => $initResult['error'] ?? '未知', + 'code' => $cfCode !== '' ? $cfCode : null, + 'challengeType' => $initResult['challengeType'] ?? null, + 'stage' => $initResult['stage'] ?? null, + 'cf' => $initResult['cf'] ?? null, ]); - throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知')); + $errMsg = (string) ($initResult['error'] ?? '未知'); + if ($cfCode === 'CF_TURNSTILE_FAILED') { + throw new Exception('Cloudflare Turnstile 验证失败: ' . ($initResult['stage'] ?? 'timeout')); + } + throw new Exception('初始化失败: ' . $errMsg); } $interceptedApis = $initResult['interceptedApis']; + $finalPageUrl = (string) ($initResult['finalPageUrl'] ?? ($config['pageUrl'] ?? '')); SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [ - 'intercepted' => array_keys($interceptedApis), + 'intercepted' => array_keys($interceptedApis), + 'finalPageUrl' => $finalPageUrl, ]); $cookies = $initResult['cookies']; @@ -121,6 +135,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface 'method' => $config['listMethod'] ?? 'GET', ]], 'cookies' => $cookies, + 'antiBot' => $antiBot, ], 120); if (!empty($fetchResult['success'])) { @@ -138,12 +153,14 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface $uiResult = $this->requestNode('/api/ui-pagination', [ 'apiUrl' => $listApi, 'pageUrl' => $config['pageUrl'], + 'finalPageUrl' => $finalPageUrl, 'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '', 'waitMs' => $uiConfig['waitMs'] ?? 2000, 'clicksToPerform' => $clicksToPerform, 'cookies' => $cookies, 'firstPageData' => $firstPageData, 'authActions' => $config['authActions'] ?? [], + 'antiBot' => $antiBot, ], 1200); if (!empty($uiResult['success']) && !empty($uiResult['data'])) { @@ -170,13 +187,43 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface * @return array */ protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array + { + $maxAttempts = 3; + $lastDecoded = []; + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $lastDecoded = $this->doRequestNode($endpoint, $payload, $timeout, $attempt); + if (!empty($lastDecoded['success'])) { + return $lastDecoded; + } + $error = (string) ($lastDecoded['error'] ?? ''); + if (!$this->shouldRetryNodeResponse($lastDecoded, $error) || $attempt >= $maxAttempts) { + return $lastDecoded; + } + $delaySeconds = $attempt; + SplitTicketSyncLogger::log('node_retry', 'retry ' . $endpoint, [ + 'attempt' => $attempt + 1, + 'error' => $error, + 'delay' => $delaySeconds, + ]); + sleep($delaySeconds); + } + return $lastDecoded; + } + + /** + * @param array $payload + * @return array + */ + private function doRequestNode(string $endpoint, array $payload, int $timeout, int $attempt): array { $url = $this->nodeHost . $endpoint; $started = microtime(true); + $logPayload = SplitTicketSyncLogger::isEnabled() ? $payload : self::summarizeNodePayload($payload); SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [ 'url' => $url, 'timeout' => $timeout, - 'payload' => $payload, + 'attempt' => $attempt, + 'payload' => $logPayload, ]); $ch = curl_init($url); @@ -184,6 +231,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); $response = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); @@ -194,6 +242,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [ 'httpCode' => $httpCode, 'elapsedMs' => $elapsedMs, + 'attempt' => $attempt, 'error' => $err, ]); throw new Exception($err); @@ -204,9 +253,62 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([ 'httpCode' => $httpCode, 'elapsedMs' => $elapsedMs, + 'attempt' => $attempt, 'responseSize' => strlen((string) $response), ], $summary)); - return is_array($decoded) ? $decoded : []; + if (!is_array($decoded)) { + return ['success' => false, 'error' => 'Node 返回非 JSON', 'httpCode' => $httpCode]; + } + if ($httpCode === 503) { + $decoded['success'] = false; + $decoded['error'] = (string) ($decoded['error'] ?? '服务繁忙'); + $decoded['httpCode'] = 503; + } + return $decoded; + } + + /** + * @param array $decoded + */ + private function shouldRetryNodeResponse(array $decoded, string $error): bool + { + if ((int) ($decoded['httpCode'] ?? 0) === 503) { + return true; + } + $lower = mb_strtolower($error, 'UTF-8'); + foreach (['排队超时', 'queue_timeout', 'timeout', 'net::', 'navigation', 'connection'] as $needle) { + if ($needle !== '' && mb_strpos($lower, mb_strtolower($needle, 'UTF-8')) !== false) { + return true; + } + } + return false; + } + + /** + * @param array $payload + * @return array + */ + private static function summarizeNodePayload(array $payload): array + { + $summary = $payload; + if (isset($summary['tasks']) && is_array($summary['tasks'])) { + $summary['tasks'] = array_map(static function ($task) { + if (!is_array($task)) { + return $task; + } + $copy = $task; + if (isset($copy['paramList']) && is_array($copy['paramList'])) { + $copy['paramList_count'] = count($copy['paramList']); + unset($copy['paramList']); + } + return $copy; + }, $summary['tasks']); + } + if (isset($summary['cookies']) && is_array($summary['cookies'])) { + $summary['cookies_count'] = count($summary['cookies']); + unset($summary['cookies']); + } + return $summary; } /** diff --git a/application/common/library/scrm/ScrmSpiderInterface.php b/application/common/library/scrm/ScrmSpiderInterface.php old mode 100644 new mode 100755 diff --git a/application/common/library/scrm/UnifiedScrmData.php b/application/common/library/scrm/UnifiedScrmData.php old mode 100644 new mode 100755 diff --git a/application/common/library/scrm/spider/A2cSpider.php b/application/common/library/scrm/spider/A2cSpider.php old mode 100644 new mode 100755 diff --git a/application/common/library/scrm/spider/ChatknowSpider.php b/application/common/library/scrm/spider/ChatknowSpider.php new file mode 100755 index 0000000..704341a --- /dev/null +++ b/application/common/library/scrm/spider/ChatknowSpider.php @@ -0,0 +1,110 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + /** @return array */ + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'GET', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'wait', 'ms' => 4000], + ], + ]; + } + + /** + * @param array|null $listFirstPageData + * @param array|null $countData + */ + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['total'] ?? 0); + $this->unifiedData->total = $total; + $this->unifiedData->todayNewCount = (int) ($listFirstPageData['data']['today_num'] ?? 0); + + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + /** @return array */ + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + /** @return array */ + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.tabs-content .arco-pagination-item-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + + foreach ($allListPagesData as $pageRaw) { + if (!is_array($pageRaw)) { + continue; + } + $records = $pageRaw['rows'] ?? []; + if (!is_array($records)) { + continue; + } + foreach ($records as $item) { + if (!is_array($item) || empty($item['username'])) { + continue; + } + $number = (string) $item['username']; + $isOnline = isset($item['state']) && (int) $item['state'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['today_num'] ?? 0)); + } + } + + return $unifiedData; + } +} diff --git a/application/common/library/scrm/spider/HaiwangSpider.php b/application/common/library/scrm/spider/HaiwangSpider.php old mode 100644 new mode 100755 diff --git a/application/common/library/scrm/spider/HuojianSpider.php b/application/common/library/scrm/spider/HuojianSpider.php old mode 100644 new mode 100755 diff --git a/application/common/library/scrm/spider/SsCustomerSpider.php b/application/common/library/scrm/spider/SsCustomerSpider.php old mode 100644 new mode 100755 diff --git a/application/common/library/scrm/spider/WhatshubSpider.php b/application/common/library/scrm/spider/WhatshubSpider.php new file mode 100755 index 0000000..771d170 --- /dev/null +++ b/application/common/library/scrm/spider/WhatshubSpider.php @@ -0,0 +1,138 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + /** @return array */ + protected function getSpiderConfig(): array + { + $host = (string) parse_url($this->pageUrl, PHP_URL_HOST); + + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'detailApi' => self::API_DETAILS, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + // Element Plus 密码弹窗:仅注入可见 input + ['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password], + ['type' => 'wait', 'ms' => 500], + ['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'], + ['type' => 'wait', 'ms' => 2200], + ], + // Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用 + 'antiBot' => [ + 'enabled' => true, + 'profile' => 'real', + 'turnstile' => true, + 'solverFallback' => true, + 'sessionKey' => 'whatshub:' . $host, + 'challengeTimeoutMs' => 60000, + ], + ]; + } + + /** + * @param array|null $listFirstPageData + * @param array|null $countData + */ + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $defaultPerPage = self::DEFAULT_PER_PAGE_COUNT; + $this->unifiedData->total = (int) ($listFirstPageData['data']['total'] ?? 0); + + if ($this->unifiedData->total <= $defaultPerPage) { + return 1; + } + + return (int) ceil($this->unifiedData->total / $defaultPerPage); + } + + /** @return array */ + protected function buildListPageParams(int $page): array + { + return ['pageNum' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + /** @return array */ + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.vxe-pager--next-btn', + 'waitMs' => 1500, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + + if (is_array($detailData)) { + $unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0); + } + + foreach ($allListPagesData as $pageRaw) { + if (!is_array($pageRaw)) { + continue; + } + $records = $pageRaw['data']['rows'] ?? []; + if (!is_array($records)) { + continue; + } + foreach ($records as $item) { + if (!is_array($item) || empty($item['account'])) { + continue; + } + $number = (string) $item['account']; + $isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['dayNewFans'] ?? 0)); + } + } + + $unifiedData->total = count($unifiedData->numbers); + + return $unifiedData; + } +} diff --git a/application/common/library/scrm/spider/XingheSpider.php b/application/common/library/scrm/spider/XingheSpider.php old mode 100644 new mode 100755 index 27fb2a6..258b8cd --- a/application/common/library/scrm/spider/XingheSpider.php +++ b/application/common/library/scrm/spider/XingheSpider.php @@ -4,83 +4,152 @@ declare(strict_types=1); namespace app\common\library\scrm\spider; -use app\common\library\scrm\AbstractScrmSpider; +use app\common\library\scrm\ScrmSpiderInterface; use app\common\library\scrm\UnifiedScrmData; +use RuntimeException; /** - * 星河云控蜘蛛 + * 星河云控蜘蛛(纯 PHP cURL 抓取,不依赖 Node Puppeteer) + * + * 流程:访问带 token 的授权页建立 Cookie 会话 → 分页请求列表 API → 清洗为 UnifiedScrmData */ -class XingheSpider extends AbstractScrmSpider +class XingheSpider implements ScrmSpiderInterface { - private const API_LIST = '/share/share/api_yinliu_count.html'; + private const API_PATH = '/share/share/api_yinliu_count.html'; private const DEFAULT_PER_PAGE_COUNT = 10; + private const CURL_TIMEOUT_SECONDS = 15; + + /** @var resource|\CurlHandle|null */ + private $ch = null; + + /** @var string|null 临时 Cookie 文件路径,仅在 run() 成功后赋值 */ + private ?string $cookieFile = null; + private string $pageUrl; private string $account; private string $password; + private string $baseUrl; + private UnifiedScrmData $unifiedData; + /** + * @param string $pageUrl 带 token 的授权页地址 + * @param string $account 账号(星河云控当前未使用,保留与工厂签名一致) + * @param string $password 密码(星河云控当前未使用,保留与工厂签名一致) + * @param string $nodeHost Node 地址(星河云控不使用,忽略) + */ public function __construct( string $pageUrl, string $account = '', string $password = '', - string $nodeHost = 'http://127.0.0.1:3001' + string $nodeHost = '' ) { - parent::__construct($nodeHost); + unset($nodeHost); + $this->pageUrl = $pageUrl; $this->account = $account; $this->password = $password; + $this->baseUrl = $this->resolveBaseUrl($pageUrl); $this->unifiedData = new UnifiedScrmData(); } - protected function getSpiderConfig(): array + /** + * 执行抓取并返回统一数据 + * + * @throws RuntimeException + */ + public function run(): UnifiedScrmData { - return [ - 'pageUrl' => $this->pageUrl, - 'listApi' => self::API_LIST, - 'listMethod' => 'GET', - 'paginationMode' => self::MODE_FETCH, - 'authActions' => [ - ['type' => 'wait', 'ms' => 2000], - ], - ]; - } - - protected function extractListTotalPages($listFirstPageData, $countData = null) - { - $total = (int) ($listFirstPageData['count'] ?? 0); - $this->unifiedData->total = $total; - $this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0); - if ($total <= self::DEFAULT_PER_PAGE_COUNT) { - return 1; + $cookieFile = tempnam(sys_get_temp_dir(), 'xinghe_spider_cookie_'); + if ($cookieFile === false) { + throw new RuntimeException('无法在系统临时目录创建 Cookie 文件'); + } + + $this->cookieFile = $cookieFile; + $this->ch = curl_init(); + if ($this->ch === false) { + throw new RuntimeException('cURL 初始化失败'); + } + + try { + curl_setopt_array($this->ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_TIMEOUT => self::CURL_TIMEOUT_SECONDS, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', + CURLOPT_COOKIEJAR => $this->cookieFile, + CURLOPT_COOKIEFILE => $this->cookieFile, + ]); + + $this->authenticate(); + + $firstPageData = $this->fetchApiData($this->buildApiUrl(1)); + $this->unifiedData->total = (int) ($firstPageData['count'] ?? 0); + $this->unifiedData->todayNewCount = (int) ($firstPageData['totalRow']['day_sum'] ?? 0); + + $totalPages = (int) ceil($this->unifiedData->total / self::DEFAULT_PER_PAGE_COUNT); + $allListPagesData = [$firstPageData['data'] ?? []]; + + for ($page = 2; $page <= $totalPages; $page++) { + $pageData = $this->fetchApiData($this->buildApiUrl($page)); + $allListPagesData[] = $pageData['data'] ?? []; + } + + return $this->parseToUnifiedData($allListPagesData); + } finally { + $this->cleanup(); } - return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); } - protected function buildListPageParams(int $page): array + /** + * 访问授权页面,建立会话 Cookie + */ + private function authenticate(): void { - return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + $this->sendRequest($this->pageUrl); } - protected function getUiPaginationConfig(): array + /** + * 请求 API 并解析 JSON + * + * @return array + */ + private function fetchApiData(string $apiPath): array { - return [ - 'nextBtnSelector' => '.layui-laypage-next', - 'waitMs' => 2000, - ]; + $response = $this->sendRequest($this->baseUrl . $apiPath); + $data = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException( + 'JSON 解析失败: ' . json_last_error_msg() . '。原始响应: ' . mb_substr($response, 0, 500, 'UTF-8') + ); + } + + if (!is_array($data)) { + throw new RuntimeException('API 返回数据格式无效'); + } + + return $data; } - protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + /** + * @param list $allListPagesData + */ + private function parseToUnifiedData(array $allListPagesData): UnifiedScrmData { $unifiedData = $this->unifiedData; - foreach ($allListPagesData as $pageRaw) { - $records = $pageRaw['data'] ?? []; + + foreach ($allListPagesData as $records) { + if (!is_array($records)) { + continue; + } foreach ($records as $item) { - if (empty($item['user'])) { + if (!is_array($item) || empty($item['user'])) { continue; } $number = (string) $item['user']; @@ -88,6 +157,88 @@ class XingheSpider extends AbstractScrmSpider $unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0)); } } + return $unifiedData; } + + /** + * 发送 cURL 请求 + */ + private function sendRequest(string $url): string + { + if ($this->ch === null) { + throw new RuntimeException('cURL 句柄未初始化'); + } + + curl_setopt($this->ch, CURLOPT_URL, $url); + $response = curl_exec($this->ch); + + if ($response === false) { + throw new RuntimeException(sprintf('请求失败 [%s]: %s', $url, curl_error($this->ch))); + } + + $httpCode = (int) curl_getinfo($this->ch, CURLINFO_HTTP_CODE); + if ($httpCode >= 400) { + throw new RuntimeException(sprintf('HTTP 请求异常 [%s],状态码: %d', $url, $httpCode)); + } + + return (string) $response; + } + + /** + * 构建分页 API 路径(相对 baseUrl) + */ + private function buildApiUrl(int $page): string + { + return sprintf( + '%s?page=%d&limit=%d&id=&class_id=&is_repet=1&start_time=&end_time=', + self::API_PATH, + $page, + self::DEFAULT_PER_PAGE_COUNT + ); + } + + /** + * 从授权页 URL 解析站点根地址 + */ + private function resolveBaseUrl(string $pageUrl): string + { + $parsedUrl = parse_url($pageUrl); + if ($parsedUrl === false || empty($parsedUrl['host'])) { + throw new RuntimeException('工单链接格式无效,无法解析域名'); + } + + $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : 'http://'; + $port = isset($parsedUrl['port']) ? $parsedUrl['port'] : ''; + if($port !== '' && $port !== 80 && $port !== 443) { + $port = ':' . $port; + } + return $scheme . $parsedUrl['host'] . $port; + } + + /** + * 释放 cURL 资源并删除临时 Cookie 文件 + */ + private function cleanup(): void + { + if ($this->ch !== null) { + if (is_resource($this->ch) || $this->ch instanceof \CurlHandle) { + curl_close($this->ch); + } + $this->ch = null; + } + + if ($this->cookieFile !== null && $this->cookieFile !== '' && is_file($this->cookieFile)) { + @unlink($this->cookieFile); + } + $this->cookieFile = null; + } + + /** + * 兜底清理:防止 run() 异常退出时资源泄漏 + */ + public function __destruct() + { + $this->cleanup(); + } } diff --git a/application/common/library/scrm/spider/XingheSpider.php.bak b/application/common/library/scrm/spider/XingheSpider.php.bak new file mode 100755 index 0000000..27fb2a6 --- /dev/null +++ b/application/common/library/scrm/spider/XingheSpider.php.bak @@ -0,0 +1,93 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'GET', + 'paginationMode' => self::MODE_FETCH, + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['count'] ?? 0); + $this->unifiedData->total = $total; + $this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0); + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.layui-laypage-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data'] ?? []; + foreach ($records as $item) { + if (empty($item['user'])) { + continue; + } + $number = (string) $item['user']; + $isOnline = isset($item['online']) && (int) $item['online'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0)); + } + } + return $unifiedData; + } +} diff --git a/application/common/service/DomainDetectRateLimitService.php b/application/common/service/DomainDetectRateLimitService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitAutoReplyService.php b/application/common/service/SplitAutoReplyService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitCronLockService.php b/application/common/service/SplitCronLockService.php new file mode 100755 index 0000000..e442b3e --- /dev/null +++ b/application/common/service/SplitCronLockService.php @@ -0,0 +1,64 @@ +lockPath(); + if ($this->isStaleLock($path)) { + @unlink($path); + } + if (is_file($path)) { + return false; + } + $payload = json_encode([ + 'pid' => getmypid(), + 'time' => time(), + ], JSON_UNESCAPED_UNICODE); + $written = @file_put_contents($path, $payload, LOCK_EX); + return $written !== false; + } + + /** + * 释放全局 cron 锁 + */ + public function release(): void + { + $path = $this->lockPath(); + if (is_file($path)) { + @unlink($path); + } + } + + private function lockPath(): string + { + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); + $dir = $runtime . 'split_ticket_sync/'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + return $dir . self::LOCK_FILE; + } + + private function isStaleLock(string $path): bool + { + if (!is_file($path)) { + return false; + } + $mtime = (int) @filemtime($path); + $ttl = SplitSyncConfigService::getCronLockTtlSeconds(); + return $mtime > 0 && (time() - $mtime) > $ttl; + } +} diff --git a/application/common/service/SplitFriendUrlBuilder.php b/application/common/service/SplitFriendUrlBuilder.php old mode 100644 new mode 100755 index bf5c2a4..6182949 --- a/application/common/service/SplitFriendUrlBuilder.php +++ b/application/common/service/SplitFriendUrlBuilder.php @@ -12,13 +12,13 @@ class SplitFriendUrlBuilder /** * 构建跳转 URL;无法构建时返回空字符串 * - * @param string $whatsAppReplyText 仅 WhatsApp 类型使用,预填消息文案(urlencode 在内部处理) + * @param string $replyText WhatsApp / Telegram 预填消息文案(内部 rawurlencode) */ public static function build( string $numberType, string $number, string $numberTypeCustom = '', - string $whatsAppReplyText = '' + string $replyText = '' ): string { $number = trim($number); if ($number === '') { @@ -27,9 +27,9 @@ class SplitFriendUrlBuilder switch ($numberType) { case 'whatsapp': - return self::buildWhatsApp($number, $whatsAppReplyText); + return self::buildWhatsApp($number, $replyText); case 'telegram': - return self::buildTelegram($number); + return self::buildTelegram($number, $replyText); case 'line': return self::buildLine($number); case 'custom': @@ -59,16 +59,22 @@ class SplitFriendUrlBuilder } /** - * Telegram:https://t.me/+ 号码(去掉前导 +) + * Telegram:https://t.me/+ 号码(去掉前导 +),可选 ?text= 预填消息(须 URL 编码) */ - private static function buildTelegram(string $number): string + private static function buildTelegram(string $number, string $replyText = ''): string { $id = ltrim($number, '+'); if ($id === '') { return ''; } - return 'https://t.me/+' . $id; + $url = 'https://t.me/+' . $id; + $replyText = trim($replyText); + if ($replyText !== '') { + $url .= '?text=' . rawurlencode($replyText); + } + + return $url; } /** diff --git a/application/common/service/SplitGeoIpService.php b/application/common/service/SplitGeoIpService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitIpProtectService.php b/application/common/service/SplitIpProtectService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitLinkCodeService.php b/application/common/service/SplitLinkCodeService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitNodeHealthService.php b/application/common/service/SplitNodeHealthService.php new file mode 100755 index 0000000..9e815f5 --- /dev/null +++ b/application/common/service/SplitNodeHealthService.php @@ -0,0 +1,96 @@ +|null + */ + public static function fetchStats(): ?array + { + $url = SplitSyncConfigService::getNodeHost() . '/api/stats'; + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2); + curl_setopt($ch, CURLOPT_TIMEOUT, 3); + $response = curl_exec($ch); + if (curl_errno($ch)) { + curl_close($ch); + return null; + } + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($httpCode !== 200) { + return null; + } + $decoded = json_decode((string) $response, true); + return is_array($decoded) ? $decoded : null; + } + + /** + * 当前是否适合向 Node 提交新 Browser 任务 + */ + public static function canAcceptWork(): bool + { + $stats = self::fetchStats(); + if ($stats === null) { + return true; + } + if (!empty($stats['shuttingDown'])) { + return false; + } + $queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : []; + $config = is_array($stats['config'] ?? null) ? $stats['config'] : []; + $queued = (int) ($queue['queued'] ?? 0); + $active = (int) ($queue['active'] ?? 0); + $max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))); + $threshold = SplitSyncConfigService::getNodeQueueSkipThreshold(); + if ($queued >= $threshold) { + return false; + } + return $active < $max; + } + + /** + * @return array{queued:int,active:int,max:int} + */ + public static function getQueueSnapshot(): array + { + $stats = self::fetchStats(); + if ($stats === null) { + return ['queued' => 0, 'active' => 0, 'max' => 0]; + } + $queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : []; + $config = is_array($stats['config'] ?? null) ? $stats['config'] : []; + return [ + 'queued' => (int) ($queue['queued'] ?? 0), + 'active' => (int) ($queue['active'] ?? 0), + 'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))), + ]; + } + + /** + * Node 侧 Real Browser / Captcha 是否就绪(供同步失败日志可读性) + * + * @return array{realBrowserReady:bool,captchaConfigured:bool} + */ + public static function getAntiBotSnapshot(): array + { + $stats = self::fetchStats(); + if ($stats === null) { + return ['realBrowserReady' => false, 'captchaConfigured' => false]; + } + return [ + 'realBrowserReady' => !empty($stats['realBrowserReady']), + 'captchaConfigured' => !empty($stats['captchaConfigured']), + ]; + } +} diff --git a/application/common/service/SplitNumberWeighService.php b/application/common/service/SplitNumberWeighService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitPixelBrowserRenderer.php b/application/common/service/SplitPixelBrowserRenderer.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitPixelConfigService.php b/application/common/service/SplitPixelConfigService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitPixelPostbackService.php b/application/common/service/SplitPixelPostbackService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitPlatformDomainService.php b/application/common/service/SplitPlatformDomainService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitRedirectService.php b/application/common/service/SplitRedirectService.php old mode 100644 new mode 100755 index a7a68f4..6913fde --- a/application/common/service/SplitRedirectService.php +++ b/application/common/service/SplitRedirectService.php @@ -72,16 +72,16 @@ class SplitRedirectService ? (string) ($picked['number_type_custom'] ?? '') : (string) $picked->getAttr('number_type_custom'); - $whatsAppReplyText = ''; - if ($numberType === 'whatsapp') { - $whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply')); + $replyText = ''; + if (in_array($numberType, ['whatsapp', 'telegram'], true)) { + $replyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply')); } $redirectUrl = SplitFriendUrlBuilder::build( $numberType, $numberValue, $numberCustom, - $whatsAppReplyText + $replyText ); if ($redirectUrl === '') { return null; diff --git a/application/common/service/SplitRoundRobinStore.php b/application/common/service/SplitRoundRobinStore.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitScrmSpiderFactory.php b/application/common/service/SplitScrmSpiderFactory.php old mode 100644 new mode 100755 index 9ab061a..479251e --- a/application/common/service/SplitScrmSpiderFactory.php +++ b/application/common/service/SplitScrmSpiderFactory.php @@ -6,9 +6,11 @@ namespace app\common\service; use app\common\library\scrm\ScrmSpiderInterface; use app\common\library\scrm\spider\A2cSpider; +use app\common\library\scrm\spider\ChatknowSpider; use app\common\library\scrm\spider\HaiwangSpider; use app\common\library\scrm\spider\HuojianSpider; use app\common\library\scrm\spider\SsCustomerSpider; +use app\common\library\scrm\spider\WhatshubSpider; use app\common\library\scrm\spider\XingheSpider; /** @@ -25,6 +27,8 @@ class SplitScrmSpiderFactory 'huojian' => HuojianSpider::class, 'xinghe' => XingheSpider::class, 'ss_customer' => SsCustomerSpider::class, + 'chatknow' => ChatknowSpider::class, + 'whatshub' => WhatshubSpider::class, // ceo_scrm 等未实现类型:新增 spider 类后在此注册 ]; @@ -45,6 +49,16 @@ class SplitScrmSpiderFactory return self::resolveClass($ticketType) !== null; } + /** + * 已注册且已实现蜘蛛的类型列表 + * + * @return list + */ + public static function listSupportedTypes(): array + { + return array_keys(self::MAP); + } + /** * @return ScrmSpiderInterface|null */ diff --git a/application/common/service/SplitSyncConfigService.php b/application/common/service/SplitSyncConfigService.php old mode 100644 new mode 100755 index de0cb3d..3bf0d86 --- a/application/common/service/SplitSyncConfigService.php +++ b/application/common/service/SplitSyncConfigService.php @@ -50,6 +50,42 @@ class SplitSyncConfigService return max(0, (int) $value); } + /** + * 单次 cron 最多处理几条到期工单,避免一分钟内打满 Node Browser 槽位 + */ + public static function getMaxTicketsPerCronRun(): int + { + $value = self::getConfigValue('split_sync_max_per_cron'); + if ($value === '') { + return 2; + } + return max(1, min(20, (int) $value)); + } + + /** + * 全局 cron 锁过期秒数,防止异常退出后永久占锁 + */ + public static function getCronLockTtlSeconds(): int + { + $value = self::getConfigValue('split_sync_cron_lock_ttl'); + if ($value === '') { + return 1800; + } + return max(300, (int) $value); + } + + /** + * Node 排队数达到该阈值时,本轮 cron 不再提交新任务 + */ + public static function getNodeQueueSkipThreshold(): int + { + $value = self::getConfigValue('split_sync_node_queue_threshold'); + if ($value === '') { + return 2; + } + return max(0, (int) $value); + } + private static function getConfigValue(string $name): string { $site = Config::get('site.' . $name); diff --git a/application/common/service/SplitTicketNumberSyncService.php b/application/common/service/SplitTicketNumberSyncService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitTicketRuleService.php b/application/common/service/SplitTicketRuleService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitTicketSyncLockService.php b/application/common/service/SplitTicketSyncLockService.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitTicketSyncLogger.php b/application/common/service/SplitTicketSyncLogger.php old mode 100644 new mode 100755 diff --git a/application/common/service/SplitTicketSyncService.php b/application/common/service/SplitTicketSyncService.php old mode 100644 new mode 100755 index 71d5ed1..9d7850e --- a/application/common/service/SplitTicketSyncService.php +++ b/application/common/service/SplitTicketSyncService.php @@ -30,9 +30,10 @@ class SplitTicketSyncService /** * 同步单条工单 * + * @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入) * @return array{success:bool,message:string,skipped?:bool} */ - public function syncOne(int $ticketId, bool $force = false): array + public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array { $ticket = Ticket::get($ticketId); if (!$ticket) { @@ -50,7 +51,7 @@ class SplitTicketSyncService 'nodeHost' => SplitSyncConfigService::getNodeHost(), ]); - if (!$force) { + if (!$force && !$dueValidated) { $skip = $this->shouldSkip($ticket); if ($skip !== null) { SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]); @@ -76,23 +77,47 @@ class SplitTicketSyncService } /** - * 扫描到期工单并同步 + * 扫描到期工单并同步(限流 + Node 队列感知) */ public function syncDueTickets(): int { - $count = 0; + $maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun(); + $autoSyncTypes = $this->resolveAutoSyncTicketTypes(); + if ($autoSyncTypes === []) { + SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']); + return 0; + } + + if (!SplitNodeHealthService::canAcceptWork()) { + SplitTicketSyncLogger::log('cron', 'scan skip', [ + 'reason' => 'node queue busy', + 'queue' => SplitNodeHealthService::getQueueSnapshot(), + ]); + return 0; + } + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); - $query = Ticket::where('status', 'normal'); + $query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes); if ($failThreshold > 0) { $query->where('sync_fail_count', '<', $failThreshold); } - $list = $query->select(); + // 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单 + $list = $query->order('sync_time', 'asc')->order('id', 'asc') + ->limit($maxPerRun * 5) + ->select(); SplitTicketSyncLogger::log('cron', 'scan start', [ 'candidateCount' => count($list), + 'maxPerRun' => $maxPerRun, + 'autoSyncTypes' => $autoSyncTypes, + 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), ]); + $count = 0; foreach ($list as $ticket) { + if ($count >= $maxPerRun) { + break; + } $skip = $this->shouldSkip($ticket); if ($skip !== null) { SplitTicketSyncLogger::log('cron', 'candidate skipped', [ @@ -102,7 +127,14 @@ class SplitTicketSyncService ]); continue; } - $result = $this->syncOne((int) $ticket['id'], false); + if (!SplitNodeHealthService::canAcceptWork()) { + SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [ + 'processed' => $count, + 'queue' => SplitNodeHealthService::getQueueSnapshot(), + ]); + break; + } + $result = $this->syncOne((int) $ticket['id'], false, true); if (!empty($result['skipped'])) { continue; } @@ -113,6 +145,22 @@ class SplitTicketSyncService return $count; } + /** + * 已配置自动同步周期且已实现蜘蛛的工单类型 + * + * @return list + */ + private function resolveAutoSyncTicketTypes(): array + { + $types = []; + foreach (SplitScrmSpiderFactory::listSupportedTypes() as $ticketType) { + if (SplitSyncConfigService::getIntervalMinutes($ticketType) > 0) { + $types[] = $ticketType; + } + } + return $types; + } + /** * @return array{success:bool,message:string} */ diff --git a/application/common/service/SplitVisitPageService.php b/application/common/service/SplitVisitPageService.php old mode 100644 new mode 100755 diff --git a/application/config.php b/application/config.php index 5750507..f29bae1 100755 --- a/application/config.php +++ b/application/config.php @@ -279,17 +279,17 @@ return [ //FastAdmin配置 'fastadmin' => [ //是否开启前台会员中心 - 'usercenter' => true, + 'usercenter' => false, //会员注册验证码类型email/mobile/wechat/text/false 'user_register_captcha' => 'text', //登录验证码 - 'login_captcha' => true, + 'login_captcha' => false, //登录失败超过10次则1天后重试 'login_failure_retry' => true, //是否同一账号同一时间只能在一个地方登录 'login_unique' => false, //是否开启IP变动检测 - 'loginip_check' => true, + 'loginip_check' => false, //登录页默认背景图 'login_background' => "", //是否启用多级菜单导航 diff --git a/application/extra/site.php b/application/extra/site.php index 970e1a6..f4238a5 100755 --- a/application/extra/site.php +++ b/application/extra/site.php @@ -1,10 +1,10 @@ 'LINK-S 多功能打粉', + 'name' => 'link管理系统', 'beian' => '', 'cdnurl' => '', - 'version' => '1.0.1', + 'version' => '1.0.14', 'timezone' => 'Asia/Shanghai', 'forbiddenip' => '', 'languages' => @@ -42,18 +42,19 @@ return array ( 'category2' => 'Category2', 'custom' => 'Custom', ), - 'split_platform_domain' => 'links.test', + 'split_platform_domain' => 'flowerbells.top', 'split_scrm_node_host' => 'http://127.0.0.1:3001', 'split_sync_interval_a2c' => '3', 'split_sync_interval_haiwang' => '3', 'split_sync_interval_huojian' => '3', 'split_sync_interval_xinghe' => '3', - 'split_sync_interval_ss_custome' => '3', + 'split_sync_interval_ss_customer' => '3', 'split_sync_interval_ceo_scrm' => '0', 'split_sync_interval_taiji' => '0', 'split_sync_interval_ss_channel' => '0', 'split_sync_interval_yifafa' => '0', - 'split_sync_interval_whatshub' => '0', + 'split_sync_interval_whatshub' => '5', 'split_sync_interval_sihai' => '0', - 'split_sync_fail_pause_threshold' => '5', + 'split_sync_fail_pause_threshold' => '6', + 'split_sync_interval_chatknow' => '5', ); diff --git a/application/index/controller/Index.php b/application/index/controller/Index.php index 452ff98..41eea87 100755 --- a/application/index/controller/Index.php +++ b/application/index/controller/Index.php @@ -13,7 +13,8 @@ class Index extends Frontend public function index() { - return $this->view->fetch(); + return 404; + // return $this->view->fetch(); } } diff --git a/application/index/controller/S.php b/application/index/controller/S.php old mode 100644 new mode 100755 diff --git a/application/index/controller/Split.php b/application/index/controller/Split.php old mode 100644 new mode 100755 diff --git a/application/index/view/split/not_found.html b/application/index/view/split/not_found.html old mode 100644 new mode 100755 diff --git a/application/index/view/split/visit.html b/application/index/view/split/visit.html old mode 100644 new mode 100755 diff --git a/deploy-domain-detect-rate-limit.sh b/deploy-domain-detect-rate-limit.sh old mode 100644 new mode 100755 diff --git a/deploy_web.php b/deploy_web.php old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_config.sql b/patches/application/admin/command/Install/split_config.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_config_chatknow.sql b/patches/application/admin/command/Install/split_config_chatknow.sql new file mode 100755 index 0000000..339c326 --- /dev/null +++ b/patches/application/admin/command/Install/split_config_chatknow.sql @@ -0,0 +1,6 @@ +-- Chatknow SCRM 工单云控自动同步周期(分钟,0=不自动同步) +SET NAMES utf8mb4; + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_chatknow', 'split', 'Chatknow SCRM同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_chatknow' LIMIT 1); diff --git a/patches/application/admin/command/Install/split_config_fix_ss_customer.sql b/patches/application/admin/command/Install/split_config_fix_ss_customer.sql new file mode 100755 index 0000000..d33f09a --- /dev/null +++ b/patches/application/admin/command/Install/split_config_fix_ss_customer.sql @@ -0,0 +1,14 @@ +-- 修复 SS 云控同步周期配置键名拼写错误:ss_custome -> ss_customer +SET NAMES utf8mb4; + +UPDATE `fa_config` +SET `name` = 'split_sync_interval_ss_customer' +WHERE `name` = 'split_sync_interval_ss_custome' + AND NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer') AS `t`); + +UPDATE `fa_config` AS `correct` +INNER JOIN `fa_config` AS `wrong` ON `wrong`.`name` = 'split_sync_interval_ss_custome' +SET `correct`.`value` = `wrong`.`value` +WHERE `correct`.`name` = 'split_sync_interval_ss_customer'; + +DELETE FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_custome'; diff --git a/patches/application/admin/command/Install/split_config_sync.sql b/patches/application/admin/command/Install/split_config_sync.sql old mode 100644 new mode 100755 index f7c6837..6fd68d1 --- a/patches/application/admin/command/Install/split_config_sync.sql +++ b/patches/application/admin/command/Install/split_config_sync.sql @@ -29,6 +29,10 @@ INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `va SELECT 'split_sync_interval_ss_customer', 'split', 'SS云控(Customer)同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer' LIMIT 1); +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_interval_chatknow', 'split', 'Chatknow SCRM同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_chatknow' LIMIT 1); + INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) SELECT 'split_sync_interval_ceo_scrm', 'split', 'CEO SCRM同步周期(分钟)', '0 表示不自动同步(蜘蛛未实现)', 'number', '', '0', '', '', '', NULL FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ceo_scrm' LIMIT 1); @@ -52,3 +56,28 @@ FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) SELECT 'split_sync_interval_sihai', 'split', '四海云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_max_per_cron', 'split', '单次定时同步上限', '每分钟 cron 最多处理几条到期工单,防止打满 Node Browser 槽位', 'number', '', '2', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_max_per_cron' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_cron_lock_ttl', 'split', '定时同步全局锁TTL(秒)', '异常退出后自动释放全局锁,避免后续 cron 永久跳过', 'number', '', '1800', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_cron_lock_ttl' LIMIT 1); + +INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`) +SELECT 'split_sync_node_queue_threshold', 'split', 'Node排队跳过阈值', '排队数达到该值时本轮 cron 不再提交新任务', 'number', '', '2', '', '', '', NULL +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_node_queue_threshold' LIMIT 1); + +-- 修复历史拼写错误:split_sync_interval_ss_custome -> split_sync_interval_ss_customer +UPDATE `fa_config` +SET `name` = 'split_sync_interval_ss_customer' +WHERE `name` = 'split_sync_interval_ss_custome' + AND NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer') AS `t`); + +UPDATE `fa_config` AS `correct` +INNER JOIN `fa_config` AS `wrong` ON `wrong`.`name` = 'split_sync_interval_ss_custome' +SET `correct`.`value` = `wrong`.`value` +WHERE `correct`.`name` = 'split_sync_interval_ss_customer'; + +DELETE FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_custome'; diff --git a/patches/application/admin/command/Install/split_link.sql b/patches/application/admin/command/Install/split_link.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_link_auto_reply.sql b/patches/application/admin/command/Install/split_link_auto_reply.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_link_pixel.sql b/patches/application/admin/command/Install/split_link_pixel.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_mydomain_remove.sql b/patches/application/admin/command/Install/split_mydomain_remove.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_number.sql b/patches/application/admin/command/Install/split_number.sql old mode 100644 new mode 100755 index 939b10f..0d20557 --- a/patches/application/admin/command/Install/split_number.sql +++ b/patches/application/admin/command/Install/split_number.sql @@ -71,6 +71,13 @@ WHERE m.name = 'split.number' AND m.ismenu = 1 AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1) LIMIT 1; +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', m.id, 'split.number/batchoperate', '批量操作号码', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` m +WHERE m.name = 'split.number' AND m.ismenu = 1 + AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchoperate' LIMIT 1) +LIMIT 1; + INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' FROM `fa_auth_rule` m diff --git a/patches/application/admin/command/Install/split_number_validate.php b/patches/application/admin/command/Install/split_number_validate.php old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_number_weigh.sql b/patches/application/admin/command/Install/split_number_weigh.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_ticket.sql b/patches/application/admin/command/Install/split_ticket.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_ticket_add_status.sql b/patches/application/admin/command/Install/split_ticket_add_status.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_ticket_stats.sql b/patches/application/admin/command/Install/split_ticket_stats.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_ticket_sync.sql b/patches/application/admin/command/Install/split_ticket_sync.sql old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/Install/split_ticket_validate.php b/patches/application/admin/command/Install/split_ticket_validate.php old mode 100644 new mode 100755 diff --git a/patches/application/admin/command/SplitSyncTickets.php b/patches/application/admin/command/SplitSyncTickets.php old mode 100644 new mode 100755 index f6f317c..4868214 --- a/patches/application/admin/command/SplitSyncTickets.php +++ b/patches/application/admin/command/SplitSyncTickets.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\admin\command; +use app\common\service\SplitCronLockService; use app\common\service\SplitTicketSyncLogger; use app\common\service\SplitTicketSyncService; use think\console\Command; @@ -51,8 +52,20 @@ class SplitSyncTickets extends Command return; } - $count = $service->syncDueTickets(); - $output->writeln('本次处理工单数: ' . $count . ''); + $cronLock = new SplitCronLockService(); + if (!$cronLock->acquire()) { + SplitTicketSyncLogger::log('cli', 'cron lock busy, skip'); + $output->writeln('上一轮同步仍在执行,跳过本次'); + return; + } + + try { + $count = $service->syncDueTickets(); + $output->writeln('本次处理工单数: ' . $count . ''); + } finally { + $cronLock->release(); + } + if (SplitTicketSyncLogger::isEnabled()) { $output->writeln('调试日志已写入 runtime/log/split_sync.log'); } diff --git a/patches/application/admin/controller/split/Link.php b/patches/application/admin/controller/split/Link.php old mode 100644 new mode 100755 index 67641e1..bfc7c91 --- a/patches/application/admin/controller/split/Link.php +++ b/patches/application/admin/controller/split/Link.php @@ -64,10 +64,35 @@ class Link extends Backend $this->assignconfig('ipProtectList', $this->model->getIpProtectList()); $this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList()); $this->assignconfig('statusList', $this->model->getStatusList()); + $this->assignconfig('linkFilterList', $this->buildLinkFilterList()); $this->setupPatchFrontend(); } + /** + * 分流链接列表筛选下拉(id => 链接码 - 描述) + * + * @return array + */ + private function buildLinkFilterList(): array + { + $query = $this->model->order('id', 'desc'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $query->where('admin_id', 'in', $adminIds); + } + } + $list = []; + foreach ($query->field('id,link_code,description')->select() as $row) { + $code = (string) $row['link_code']; + $desc = (string) $row['description']; + $label = $desc !== '' ? $code . ' - ' . $desc : $code; + $list[(string) $row['id']] = $label; + } + return $list; + } + /** * 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口 */ diff --git a/patches/application/admin/controller/split/Number.php b/patches/application/admin/controller/split/Number.php old mode 100644 new mode 100755 index 5d9179d..d73b491 --- a/patches/application/admin/controller/split/Number.php +++ b/patches/application/admin/controller/split/Number.php @@ -29,6 +29,9 @@ class Number extends Backend protected $dataLimit = 'personal'; + /** 关联 splitLink 预载入时需为筛选/排序字段加表别名,避免 status 等列歧义 */ + protected $relationSearch = true; + protected $modelValidate = true; protected $modelSceneValidate = true; @@ -62,6 +65,8 @@ class Number extends Backend $this->assignconfig('statusList', $this->model->getStatusList()); $this->assignconfig('manualManageList', $this->model->getManualManageList()); $this->assignconfig('platformStatusList', $this->model->getPlatformStatusList()); + $this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList()); + $this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig()); $this->setupPatchFrontend(); } @@ -111,6 +116,52 @@ class Number extends Backend return $list; } + /** + * 已有号码关联的分流链接(供列表筛选下拉) + * + * @return array + */ + private function buildNumberSplitLinkFilterList(): array + { + $numberTable = $this->model->getTable(); + $linkTable = (new LinkModel())->getTable(); + $query = Db::table($numberTable) + ->alias('n') + ->join($linkTable . ' l', 'n.split_link_id = l.id') + ->where('n.split_link_id', '>', 0) + ->group('n.split_link_id') + ->field('l.id,l.link_code,l.description') + ->order('l.id', 'desc'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $query->where('n.admin_id', 'in', $adminIds); + } + } + $list = []; + foreach ($query->select() as $row) { + $code = (string) $row['link_code']; + $desc = (string) $row['description']; + $label = $desc !== '' ? $code . ' - ' . $desc : $code; + $list[(string) $row['id']] = $label; + } + return $list; + } + + /** + * 全部分流链接(供批量操作弹窗可搜索下拉) + * + * @return array + */ + private function buildSplitLinkSelectConfig(): array + { + $list = []; + foreach ($this->buildSplitLinkList() as $row) { + $list[(string) $row['id']] = (string) $row['label']; + } + return $list; + } + private function fetchPatch(string $template): string { $patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html'; @@ -395,6 +446,85 @@ class Number extends Backend $this->success(__('Batch update success')); } + /** + * 按分流链接 + 号码文本批量开启/关闭/删除 + */ + public function batchoperate(): void + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + + $splitLinkId = (int) $this->request->post('split_link_id', 0); + $numbersText = (string) $this->request->post('numbers', ''); + $action = (string) $this->request->post('action', ''); + + if ($splitLinkId <= 0) { + $this->error(__('Please select split link')); + } + + $numberList = NumberModel::parseNumbersText($numbersText); + if ($numberList === []) { + $this->error(__('Please fill at least one number')); + } + + $allowedActions = ['enable', 'disable', 'delete']; + if (!in_array($action, $allowedActions, true)) { + $this->error(__('Invalid batch operate action')); + } + + $linkQuery = (new LinkModel())->where('id', $splitLinkId)->where('status', 'normal'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $linkQuery->where('admin_id', 'in', $adminIds); + } + } + if (!$linkQuery->find()) { + $this->error(__('No Results were found')); + } + + $query = $this->model + ->where('split_link_id', $splitLinkId) + ->where('number', 'in', $numberList); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $query->where('admin_id', 'in', $adminIds); + } + } + + $count = 0; + Db::startTrans(); + try { + if ($action === 'delete') { + $rows = $query->select(); + foreach ($rows as $row) { + if ($row->delete()) { + $count++; + } + } + } else { + $status = $action === 'enable' ? 'normal' : 'hidden'; + $manualManage = $action === 'enable' ? 0 : 1; + $count = (int) $query->update([ + 'status' => $status, + 'manual_manage' => $manualManage, + ]); + } + Db::commit(); + } catch (PDOException|Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + if ($count <= 0) { + $this->error(__('No matching numbers found')); + } + + $this->success(sprintf(__('Batch operate success'), $count)); + } + /** * 排除不可由表单提交的字段 * diff --git a/patches/application/admin/controller/split/Ticket.php b/patches/application/admin/controller/split/Ticket.php old mode 100644 new mode 100755 index 31fbec6..d4d456e --- a/patches/application/admin/controller/split/Ticket.php +++ b/patches/application/admin/controller/split/Ticket.php @@ -58,6 +58,7 @@ class Ticket extends Backend $this->assignconfig('ticketTypeList', $this->model->getTicketTypeList()); $this->assignconfig('numberTypeList', $this->model->getNumberTypeList()); $this->assignconfig('statusList', $this->model->getStatusList()); + $this->assignconfig('splitLinkFilterList', $this->buildTicketSplitLinkFilterList()); $this->assignconfig([ 'syncConfirmMsg' => __('Sync confirm'), 'syncBackgroundStartedMsg' => __('Sync background started'), @@ -142,6 +143,71 @@ class Ticket extends Backend return $list; } + /** + * 已有工单关联的分流链接(供列表筛选下拉) + * + * @return array id => label + */ + private function buildTicketSplitLinkFilterList(): array + { + $ticketTable = $this->model->getTable(); + $linkTable = (new LinkModel())->getTable(); + $query = Db::table($ticketTable) + ->alias('t') + ->join($linkTable . ' l', 't.split_link_id = l.id') + ->where('t.split_link_id', '>', 0) + ->group('t.split_link_id') + ->field('l.id,l.link_code,l.description') + ->order('l.id', 'desc'); + if ($this->dataLimit) { + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds)) { + $query->where('t.admin_id', 'in', $adminIds); + } + } + $list = []; + foreach ($query->select() as $row) { + $code = (string) $row['link_code']; + $desc = (string) $row['description']; + $label = $desc !== '' ? $code . ' - ' . $desc : $code; + $list[(string) $row['id']] = $label; + } + return $list; + } + + /** + * 按当前筛选条件汇总列表统计列 + * + * @param callable|array $where buildparams() 返回的闭包或条件数组 + * @return array + */ + private function buildListSummary($where): array + { + $stats = $this->model + ->where($where) + ->fieldRaw( + 'COALESCE(SUM(ticket_total), 0) AS sum_ticket_total,' + . ' COALESCE(SUM(complete_count), 0) AS sum_complete_count,' + . ' COALESCE(SUM(speed_per_hour), 0) AS sum_speed_per_hour,' + . ' COALESCE(AVG(CASE WHEN ticket_total > 0 THEN complete_count * 100.0 / ticket_total ELSE 0 END), 0) AS avg_ticket_progress' + ) + ->find(); + if (!$stats) { + return [ + 'ticket_total' => 0, + 'complete_count' => 0, + 'speed_per_hour' => '0.00', + 'ticket_progress_text' => '0.00%', + ]; + } + return [ + 'ticket_total' => (int) ($stats['sum_ticket_total'] ?? 0), + 'complete_count' => (int) ($stats['sum_complete_count'] ?? 0), + 'speed_per_hour' => number_format((float) ($stats['sum_speed_per_hour'] ?? 0), 2, '.', ''), + 'ticket_progress_text' => number_format((float) ($stats['avg_ticket_progress'] ?? 0), 2, '.', '') . '%', + ]; + } + private function fetchPatch(string $template): string { $patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html'; @@ -179,7 +245,11 @@ class Ticket extends Backend ->where($where) ->order($sort, $order) ->paginate($limit); - $result = ['total' => $list->total(), 'rows' => $list->items()]; + $result = [ + 'total' => $list->total(), + 'rows' => $list->items(), + 'summary' => $this->buildListSummary($where), + ]; return json($result); } @@ -331,6 +401,10 @@ class Ticket extends Backend public function add() { if (false === $this->request->isPost()) { + $copyRow = $this->buildCopyRowData(); + if ($copyRow !== null) { + $this->view->assign('row', $copyRow); + } return $this->fetchPatch('add'); } $params = $this->request->post('row/a', []); @@ -367,6 +441,60 @@ class Ticket extends Backend $this->success('', null, ['id' => (int) $this->model->id]); } + /** + * 从 copy_id 构建添加工单表单的预填数据(仅复制业务字段,不含同步统计) + * + * @return array|null + */ + private function buildCopyRowData(): ?array + { + $copyId = (int) $this->request->get('copy_id/d', 0); + if ($copyId <= 0) { + return null; + } + + $row = $this->model->get($copyId); + if (!$row) { + return null; + } + + $adminIds = $this->getDataLimitAdminIds(); + if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { + return null; + } + + $data = $row->toArray(); + $copyFields = [ + 'ticket_type', + 'ticket_name', + 'ticket_url', + 'ticket_total', + 'split_link_id', + 'number_type', + 'number_type_custom', + 'order_limit', + 'assign_ratio', + 'account', + 'password', + ]; + $copyRow = []; + foreach ($copyFields as $field) { + if (array_key_exists($field, $data)) { + $copyRow[$field] = $data[$field]; + } + } + + $copyRow['start_time'] = $data['start_time_text'] ?? ''; + $copyRow['end_time'] = $data['end_time_text'] ?? ''; + + $name = trim((string) ($copyRow['ticket_name'] ?? '')); + if ($name !== '' && mb_strpos($name, ' (副本)') === false) { + $copyRow['ticket_name'] = $name . ' (副本)'; + } + + return $copyRow; + } + /** * @param string|null $ids * @return string diff --git a/patches/application/admin/lang/zh-cn/domain.php b/patches/application/admin/lang/zh-cn/domain.php old mode 100644 new mode 100755 diff --git a/patches/application/admin/lang/zh-cn/split/link.php b/patches/application/admin/lang/zh-cn/split/link.php old mode 100644 new mode 100755 index 843d2b9..e23c70b --- a/patches/application/admin/lang/zh-cn/split/link.php +++ b/patches/application/admin/lang/zh-cn/split/link.php @@ -37,7 +37,7 @@ return [ 'System config' => '系统配置', 'Auto reply' => '自动回复', 'Reply statements' => '回复语句', - 'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联', + 'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联;跳转 WhatsApp / Telegram 时随机附加为预填消息', 'Auto reply saved' => '自动回复已保存', 'Reply statements column' => '回复语', 'NS' => 'NS', diff --git a/patches/application/admin/lang/zh-cn/split/number.php b/patches/application/admin/lang/zh-cn/split/number.php old mode 100644 new mode 100755 index bc90983..bec8fa0 --- a/patches/application/admin/lang/zh-cn/split/number.php +++ b/patches/application/admin/lang/zh-cn/split/number.php @@ -30,6 +30,18 @@ return [ 'Batch selected count' => '已选号码', 'Batch update status' => '更新状态', 'Batch update btn' => '更新状态', + 'Batch operate btn' => '批量操作', + 'Batch operate title' => '批量操作', + 'Batch operate action' => '操作', + 'Batch operate enable' => '开启状态', + 'Batch operate disable' => '关闭状态', + 'Batch operate delete' => '删除号码', + 'Batch operate success' => '批量操作成功,共处理 %d 条', + 'Invalid batch operate action' => '操作类型无效', + 'No matching numbers found' => '未找到匹配的号码', + 'Please select split link' => '请选择分流链接', + 'Numbers one per line' => '一行一个号码', + 'Batch operate delete confirm' => '确定要删除这些号码吗?', 'Please fill at least one number' => '请至少填写一个有效号码', 'All numbers already exist for this link' => '该链接下号码均已存在,未新增任何记录', 'Number already exists for this link' => '该链接下已存在相同号码', diff --git a/patches/application/admin/lang/zh-cn/split/ticket.php b/patches/application/admin/lang/zh-cn/split/ticket.php old mode 100644 new mode 100755 index 78d7f21..67fafa4 --- a/patches/application/admin/lang/zh-cn/split/ticket.php +++ b/patches/application/admin/lang/zh-cn/split/ticket.php @@ -38,6 +38,8 @@ return [ 'Sync display pending' => '待同步', 'Sync display error' => '同步异常', 'Createtime' => '创建时间', + 'Copy' => '拷贝', + 'Summary row' => '汇总', 'Section basic' => '基础信息', 'Section time rule' => '时间与规则', 'Section account' => '账号信息', @@ -51,6 +53,7 @@ return [ 'Ticket type yifafa' => '译发发云控', 'Ticket type a2c' => 'A2C云控', 'Ticket type ceo_scrm' => 'CEO SCRM', + 'Ticket type chatknow' => 'Chatknow SCRM', 'Ticket type whatshub' => 'Whatshub云控', 'Ticket type sihai' => '四海云控', 'End time must after start' => '到期时间必须晚于开始时间', diff --git a/patches/application/admin/model/split/Link.php b/patches/application/admin/model/split/Link.php old mode 100644 new mode 100755 diff --git a/patches/application/admin/model/split/Number.php b/patches/application/admin/model/split/Number.php old mode 100644 new mode 100755 index 2085f63..0231f91 --- a/patches/application/admin/model/split/Number.php +++ b/patches/application/admin/model/split/Number.php @@ -98,7 +98,7 @@ class Number extends Model */ public function splitLink() { - return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0); + return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1); } public function setNumberTypeCustomAttr($value): string diff --git a/patches/application/admin/model/split/Ticket.php b/patches/application/admin/model/split/Ticket.php old mode 100644 new mode 100755 index ae3a552..576913c --- a/patches/application/admin/model/split/Ticket.php +++ b/patches/application/admin/model/split/Ticket.php @@ -50,6 +50,7 @@ class Ticket extends Model 'yifafa' => __('Ticket type yifafa'), 'a2c' => __('Ticket type a2c'), 'ceo_scrm' => __('Ticket type ceo_scrm'), + 'chatknow' => __('Ticket type chatknow'), 'whatshub' => __('Ticket type whatshub'), 'sihai' => __('Ticket type sihai'), ]; diff --git a/patches/application/admin/validate/split/Link.php b/patches/application/admin/validate/split/Link.php old mode 100644 new mode 100755 diff --git a/patches/application/admin/view/split/link/add.html b/patches/application/admin/view/split/link/add.html old mode 100644 new mode 100755 diff --git a/patches/application/admin/view/split/link/edit.html b/patches/application/admin/view/split/link/edit.html old mode 100644 new mode 100755 diff --git a/patches/application/admin/view/split/link/index.html b/patches/application/admin/view/split/link/index.html old mode 100644 new mode 100755 diff --git a/patches/application/admin/view/split/number/add.html b/patches/application/admin/view/split/number/add.html old mode 100644 new mode 100755 diff --git a/patches/application/admin/view/split/number/edit.html b/patches/application/admin/view/split/number/edit.html old mode 100644 new mode 100755 diff --git a/patches/application/admin/view/split/number/form_body.html b/patches/application/admin/view/split/number/form_body.html old mode 100644 new mode 100755 diff --git a/patches/application/admin/view/split/number/index.html b/patches/application/admin/view/split/number/index.html old mode 100644 new mode 100755 index c8f4766..106b43b --- a/patches/application/admin/view/split/number/index.html +++ b/patches/application/admin/view/split/number/index.html @@ -18,6 +18,7 @@ {:__('Edit')} {:__('Delete')} {:__('Batch update btn')} + {:__('Batch operate btn')}
+
+ {:__('Summary row')} + {:__('Ticket_total')}0 + {:__('Complete_count')}0 + {:__('Ticket_progress')}0.00% + {:__('Speed_per_hour')}0.00 +
requestNode('/api/auth-and-intercept', [ 'pageUrl' => $config['pageUrl'], 'apiUrls' => $apiUrlsToIntercept, 'authActions' => $config['authActions'] ?? [], + 'antiBot' => $antiBot, ]); if (empty($initResult['success'])) { + $cfCode = (string) ($initResult['code'] ?? ''); SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [ - 'error' => $initResult['error'] ?? '未知', + 'error' => $initResult['error'] ?? '未知', + 'code' => $cfCode !== '' ? $cfCode : null, + 'challengeType' => $initResult['challengeType'] ?? null, + 'stage' => $initResult['stage'] ?? null, + 'cf' => $initResult['cf'] ?? null, ]); - throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知')); + $errMsg = (string) ($initResult['error'] ?? '未知'); + if ($cfCode === 'CF_TURNSTILE_FAILED') { + throw new Exception('Cloudflare Turnstile 验证失败: ' . ($initResult['stage'] ?? 'timeout')); + } + throw new Exception('初始化失败: ' . $errMsg); } $interceptedApis = $initResult['interceptedApis']; + $finalPageUrl = (string) ($initResult['finalPageUrl'] ?? ($config['pageUrl'] ?? '')); SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [ - 'intercepted' => array_keys($interceptedApis), + 'intercepted' => array_keys($interceptedApis), + 'finalPageUrl' => $finalPageUrl, ]); $cookies = $initResult['cookies']; @@ -121,6 +135,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface 'method' => $config['listMethod'] ?? 'GET', ]], 'cookies' => $cookies, + 'antiBot' => $antiBot, ], 120); if (!empty($fetchResult['success'])) { @@ -138,12 +153,14 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface $uiResult = $this->requestNode('/api/ui-pagination', [ 'apiUrl' => $listApi, 'pageUrl' => $config['pageUrl'], + 'finalPageUrl' => $finalPageUrl, 'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '', 'waitMs' => $uiConfig['waitMs'] ?? 2000, 'clicksToPerform' => $clicksToPerform, 'cookies' => $cookies, 'firstPageData' => $firstPageData, 'authActions' => $config['authActions'] ?? [], + 'antiBot' => $antiBot, ], 1200); if (!empty($uiResult['success']) && !empty($uiResult['data'])) { @@ -170,13 +187,43 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface * @return array */ protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array + { + $maxAttempts = 3; + $lastDecoded = []; + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $lastDecoded = $this->doRequestNode($endpoint, $payload, $timeout, $attempt); + if (!empty($lastDecoded['success'])) { + return $lastDecoded; + } + $error = (string) ($lastDecoded['error'] ?? ''); + if (!$this->shouldRetryNodeResponse($lastDecoded, $error) || $attempt >= $maxAttempts) { + return $lastDecoded; + } + $delaySeconds = $attempt; + SplitTicketSyncLogger::log('node_retry', 'retry ' . $endpoint, [ + 'attempt' => $attempt + 1, + 'error' => $error, + 'delay' => $delaySeconds, + ]); + sleep($delaySeconds); + } + return $lastDecoded; + } + + /** + * @param array $payload + * @return array + */ + private function doRequestNode(string $endpoint, array $payload, int $timeout, int $attempt): array { $url = $this->nodeHost . $endpoint; $started = microtime(true); + $logPayload = SplitTicketSyncLogger::isEnabled() ? $payload : self::summarizeNodePayload($payload); SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [ 'url' => $url, 'timeout' => $timeout, - 'payload' => $payload, + 'attempt' => $attempt, + 'payload' => $logPayload, ]); $ch = curl_init($url); @@ -184,6 +231,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); $response = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); @@ -194,6 +242,7 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [ 'httpCode' => $httpCode, 'elapsedMs' => $elapsedMs, + 'attempt' => $attempt, 'error' => $err, ]); throw new Exception($err); @@ -204,9 +253,62 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([ 'httpCode' => $httpCode, 'elapsedMs' => $elapsedMs, + 'attempt' => $attempt, 'responseSize' => strlen((string) $response), ], $summary)); - return is_array($decoded) ? $decoded : []; + if (!is_array($decoded)) { + return ['success' => false, 'error' => 'Node 返回非 JSON', 'httpCode' => $httpCode]; + } + if ($httpCode === 503) { + $decoded['success'] = false; + $decoded['error'] = (string) ($decoded['error'] ?? '服务繁忙'); + $decoded['httpCode'] = 503; + } + return $decoded; + } + + /** + * @param array $decoded + */ + private function shouldRetryNodeResponse(array $decoded, string $error): bool + { + if ((int) ($decoded['httpCode'] ?? 0) === 503) { + return true; + } + $lower = mb_strtolower($error, 'UTF-8'); + foreach (['排队超时', 'queue_timeout', 'timeout', 'net::', 'navigation', 'connection'] as $needle) { + if ($needle !== '' && mb_strpos($lower, mb_strtolower($needle, 'UTF-8')) !== false) { + return true; + } + } + return false; + } + + /** + * @param array $payload + * @return array + */ + private static function summarizeNodePayload(array $payload): array + { + $summary = $payload; + if (isset($summary['tasks']) && is_array($summary['tasks'])) { + $summary['tasks'] = array_map(static function ($task) { + if (!is_array($task)) { + return $task; + } + $copy = $task; + if (isset($copy['paramList']) && is_array($copy['paramList'])) { + $copy['paramList_count'] = count($copy['paramList']); + unset($copy['paramList']); + } + return $copy; + }, $summary['tasks']); + } + if (isset($summary['cookies']) && is_array($summary['cookies'])) { + $summary['cookies_count'] = count($summary['cookies']); + unset($summary['cookies']); + } + return $summary; } /** diff --git a/patches/application/common/library/scrm/ScrmSpiderInterface.php b/patches/application/common/library/scrm/ScrmSpiderInterface.php old mode 100644 new mode 100755 diff --git a/patches/application/common/library/scrm/UnifiedScrmData.php b/patches/application/common/library/scrm/UnifiedScrmData.php old mode 100644 new mode 100755 diff --git a/patches/application/common/library/scrm/spider/A2cSpider.php b/patches/application/common/library/scrm/spider/A2cSpider.php old mode 100644 new mode 100755 diff --git a/patches/application/common/library/scrm/spider/ChatknowSpider.php b/patches/application/common/library/scrm/spider/ChatknowSpider.php new file mode 100755 index 0000000..704341a --- /dev/null +++ b/patches/application/common/library/scrm/spider/ChatknowSpider.php @@ -0,0 +1,110 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + /** @return array */ + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'GET', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + ['type' => 'wait', 'ms' => 4000], + ], + ]; + } + + /** + * @param array|null $listFirstPageData + * @param array|null $countData + */ + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['total'] ?? 0); + $this->unifiedData->total = $total; + $this->unifiedData->todayNewCount = (int) ($listFirstPageData['data']['today_num'] ?? 0); + + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + /** @return array */ + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + /** @return array */ + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.tabs-content .arco-pagination-item-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + + foreach ($allListPagesData as $pageRaw) { + if (!is_array($pageRaw)) { + continue; + } + $records = $pageRaw['rows'] ?? []; + if (!is_array($records)) { + continue; + } + foreach ($records as $item) { + if (!is_array($item) || empty($item['username'])) { + continue; + } + $number = (string) $item['username']; + $isOnline = isset($item['state']) && (int) $item['state'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['today_num'] ?? 0)); + } + } + + return $unifiedData; + } +} diff --git a/patches/application/common/library/scrm/spider/HaiwangSpider.php b/patches/application/common/library/scrm/spider/HaiwangSpider.php old mode 100644 new mode 100755 diff --git a/patches/application/common/library/scrm/spider/HuojianSpider.php b/patches/application/common/library/scrm/spider/HuojianSpider.php old mode 100644 new mode 100755 diff --git a/patches/application/common/library/scrm/spider/SsCustomerSpider.php b/patches/application/common/library/scrm/spider/SsCustomerSpider.php old mode 100644 new mode 100755 diff --git a/patches/application/common/library/scrm/spider/WhatshubSpider.php b/patches/application/common/library/scrm/spider/WhatshubSpider.php new file mode 100755 index 0000000..771d170 --- /dev/null +++ b/patches/application/common/library/scrm/spider/WhatshubSpider.php @@ -0,0 +1,138 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + /** @return array */ + protected function getSpiderConfig(): array + { + $host = (string) parse_url($this->pageUrl, PHP_URL_HOST); + + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'detailApi' => self::API_DETAILS, + 'listMethod' => 'POST', + 'paginationMode' => self::MODE_UI, + 'authActions' => [ + // Element Plus 密码弹窗:仅注入可见 input + ['type' => 'vue_fill', 'selector' => '.el-input__inner', 'value' => $this->password], + ['type' => 'wait', 'ms' => 500], + ['type' => 'vue_click', 'selector' => '.vxe-button-group .theme--primary'], + ['type' => 'wait', 'ms' => 2200], + ], + // Whatshub 专用:Real Browser + Turnstile + Captcha API 兜底 + 会话复用 + 'antiBot' => [ + 'enabled' => true, + 'profile' => 'real', + 'turnstile' => true, + 'solverFallback' => true, + 'sessionKey' => 'whatshub:' . $host, + 'challengeTimeoutMs' => 60000, + ], + ]; + } + + /** + * @param array|null $listFirstPageData + * @param array|null $countData + */ + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $defaultPerPage = self::DEFAULT_PER_PAGE_COUNT; + $this->unifiedData->total = (int) ($listFirstPageData['data']['total'] ?? 0); + + if ($this->unifiedData->total <= $defaultPerPage) { + return 1; + } + + return (int) ceil($this->unifiedData->total / $defaultPerPage); + } + + /** @return array */ + protected function buildListPageParams(int $page): array + { + return ['pageNum' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT]; + } + + /** @return array */ + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.vxe-pager--next-btn', + 'waitMs' => 1500, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + + if (is_array($detailData)) { + $unifiedData->todayNewCount = (int) ($detailData['data']['dayNewFans'] ?? 0); + } + + foreach ($allListPagesData as $pageRaw) { + if (!is_array($pageRaw)) { + continue; + } + $records = $pageRaw['data']['rows'] ?? []; + if (!is_array($records)) { + continue; + } + foreach ($records as $item) { + if (!is_array($item) || empty($item['account'])) { + continue; + } + $number = (string) $item['account']; + $isOnline = isset($item['isOnline']) && (int) $item['isOnline'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['dayNewFans'] ?? 0)); + } + } + + $unifiedData->total = count($unifiedData->numbers); + + return $unifiedData; + } +} diff --git a/patches/application/common/library/scrm/spider/XingheSpider.php b/patches/application/common/library/scrm/spider/XingheSpider.php old mode 100644 new mode 100755 index 27fb2a6..258b8cd --- a/patches/application/common/library/scrm/spider/XingheSpider.php +++ b/patches/application/common/library/scrm/spider/XingheSpider.php @@ -4,83 +4,152 @@ declare(strict_types=1); namespace app\common\library\scrm\spider; -use app\common\library\scrm\AbstractScrmSpider; +use app\common\library\scrm\ScrmSpiderInterface; use app\common\library\scrm\UnifiedScrmData; +use RuntimeException; /** - * 星河云控蜘蛛 + * 星河云控蜘蛛(纯 PHP cURL 抓取,不依赖 Node Puppeteer) + * + * 流程:访问带 token 的授权页建立 Cookie 会话 → 分页请求列表 API → 清洗为 UnifiedScrmData */ -class XingheSpider extends AbstractScrmSpider +class XingheSpider implements ScrmSpiderInterface { - private const API_LIST = '/share/share/api_yinliu_count.html'; + private const API_PATH = '/share/share/api_yinliu_count.html'; private const DEFAULT_PER_PAGE_COUNT = 10; + private const CURL_TIMEOUT_SECONDS = 15; + + /** @var resource|\CurlHandle|null */ + private $ch = null; + + /** @var string|null 临时 Cookie 文件路径,仅在 run() 成功后赋值 */ + private ?string $cookieFile = null; + private string $pageUrl; private string $account; private string $password; + private string $baseUrl; + private UnifiedScrmData $unifiedData; + /** + * @param string $pageUrl 带 token 的授权页地址 + * @param string $account 账号(星河云控当前未使用,保留与工厂签名一致) + * @param string $password 密码(星河云控当前未使用,保留与工厂签名一致) + * @param string $nodeHost Node 地址(星河云控不使用,忽略) + */ public function __construct( string $pageUrl, string $account = '', string $password = '', - string $nodeHost = 'http://127.0.0.1:3001' + string $nodeHost = '' ) { - parent::__construct($nodeHost); + unset($nodeHost); + $this->pageUrl = $pageUrl; $this->account = $account; $this->password = $password; + $this->baseUrl = $this->resolveBaseUrl($pageUrl); $this->unifiedData = new UnifiedScrmData(); } - protected function getSpiderConfig(): array + /** + * 执行抓取并返回统一数据 + * + * @throws RuntimeException + */ + public function run(): UnifiedScrmData { - return [ - 'pageUrl' => $this->pageUrl, - 'listApi' => self::API_LIST, - 'listMethod' => 'GET', - 'paginationMode' => self::MODE_FETCH, - 'authActions' => [ - ['type' => 'wait', 'ms' => 2000], - ], - ]; - } - - protected function extractListTotalPages($listFirstPageData, $countData = null) - { - $total = (int) ($listFirstPageData['count'] ?? 0); - $this->unifiedData->total = $total; - $this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0); - if ($total <= self::DEFAULT_PER_PAGE_COUNT) { - return 1; + $cookieFile = tempnam(sys_get_temp_dir(), 'xinghe_spider_cookie_'); + if ($cookieFile === false) { + throw new RuntimeException('无法在系统临时目录创建 Cookie 文件'); + } + + $this->cookieFile = $cookieFile; + $this->ch = curl_init(); + if ($this->ch === false) { + throw new RuntimeException('cURL 初始化失败'); + } + + try { + curl_setopt_array($this->ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_TIMEOUT => self::CURL_TIMEOUT_SECONDS, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', + CURLOPT_COOKIEJAR => $this->cookieFile, + CURLOPT_COOKIEFILE => $this->cookieFile, + ]); + + $this->authenticate(); + + $firstPageData = $this->fetchApiData($this->buildApiUrl(1)); + $this->unifiedData->total = (int) ($firstPageData['count'] ?? 0); + $this->unifiedData->todayNewCount = (int) ($firstPageData['totalRow']['day_sum'] ?? 0); + + $totalPages = (int) ceil($this->unifiedData->total / self::DEFAULT_PER_PAGE_COUNT); + $allListPagesData = [$firstPageData['data'] ?? []]; + + for ($page = 2; $page <= $totalPages; $page++) { + $pageData = $this->fetchApiData($this->buildApiUrl($page)); + $allListPagesData[] = $pageData['data'] ?? []; + } + + return $this->parseToUnifiedData($allListPagesData); + } finally { + $this->cleanup(); } - return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); } - protected function buildListPageParams(int $page): array + /** + * 访问授权页面,建立会话 Cookie + */ + private function authenticate(): void { - return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + $this->sendRequest($this->pageUrl); } - protected function getUiPaginationConfig(): array + /** + * 请求 API 并解析 JSON + * + * @return array + */ + private function fetchApiData(string $apiPath): array { - return [ - 'nextBtnSelector' => '.layui-laypage-next', - 'waitMs' => 2000, - ]; + $response = $this->sendRequest($this->baseUrl . $apiPath); + $data = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException( + 'JSON 解析失败: ' . json_last_error_msg() . '。原始响应: ' . mb_substr($response, 0, 500, 'UTF-8') + ); + } + + if (!is_array($data)) { + throw new RuntimeException('API 返回数据格式无效'); + } + + return $data; } - protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + /** + * @param list $allListPagesData + */ + private function parseToUnifiedData(array $allListPagesData): UnifiedScrmData { $unifiedData = $this->unifiedData; - foreach ($allListPagesData as $pageRaw) { - $records = $pageRaw['data'] ?? []; + + foreach ($allListPagesData as $records) { + if (!is_array($records)) { + continue; + } foreach ($records as $item) { - if (empty($item['user'])) { + if (!is_array($item) || empty($item['user'])) { continue; } $number = (string) $item['user']; @@ -88,6 +157,88 @@ class XingheSpider extends AbstractScrmSpider $unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0)); } } + return $unifiedData; } + + /** + * 发送 cURL 请求 + */ + private function sendRequest(string $url): string + { + if ($this->ch === null) { + throw new RuntimeException('cURL 句柄未初始化'); + } + + curl_setopt($this->ch, CURLOPT_URL, $url); + $response = curl_exec($this->ch); + + if ($response === false) { + throw new RuntimeException(sprintf('请求失败 [%s]: %s', $url, curl_error($this->ch))); + } + + $httpCode = (int) curl_getinfo($this->ch, CURLINFO_HTTP_CODE); + if ($httpCode >= 400) { + throw new RuntimeException(sprintf('HTTP 请求异常 [%s],状态码: %d', $url, $httpCode)); + } + + return (string) $response; + } + + /** + * 构建分页 API 路径(相对 baseUrl) + */ + private function buildApiUrl(int $page): string + { + return sprintf( + '%s?page=%d&limit=%d&id=&class_id=&is_repet=1&start_time=&end_time=', + self::API_PATH, + $page, + self::DEFAULT_PER_PAGE_COUNT + ); + } + + /** + * 从授权页 URL 解析站点根地址 + */ + private function resolveBaseUrl(string $pageUrl): string + { + $parsedUrl = parse_url($pageUrl); + if ($parsedUrl === false || empty($parsedUrl['host'])) { + throw new RuntimeException('工单链接格式无效,无法解析域名'); + } + + $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : 'http://'; + $port = isset($parsedUrl['port']) ? $parsedUrl['port'] : ''; + if($port !== '' && $port !== 80 && $port !== 443) { + $port = ':' . $port; + } + return $scheme . $parsedUrl['host'] . $port; + } + + /** + * 释放 cURL 资源并删除临时 Cookie 文件 + */ + private function cleanup(): void + { + if ($this->ch !== null) { + if (is_resource($this->ch) || $this->ch instanceof \CurlHandle) { + curl_close($this->ch); + } + $this->ch = null; + } + + if ($this->cookieFile !== null && $this->cookieFile !== '' && is_file($this->cookieFile)) { + @unlink($this->cookieFile); + } + $this->cookieFile = null; + } + + /** + * 兜底清理:防止 run() 异常退出时资源泄漏 + */ + public function __destruct() + { + $this->cleanup(); + } } diff --git a/patches/application/common/library/scrm/spider/XingheSpider.php.bak b/patches/application/common/library/scrm/spider/XingheSpider.php.bak new file mode 100755 index 0000000..27fb2a6 --- /dev/null +++ b/patches/application/common/library/scrm/spider/XingheSpider.php.bak @@ -0,0 +1,93 @@ +pageUrl = $pageUrl; + $this->account = $account; + $this->password = $password; + $this->unifiedData = new UnifiedScrmData(); + } + + protected function getSpiderConfig(): array + { + return [ + 'pageUrl' => $this->pageUrl, + 'listApi' => self::API_LIST, + 'listMethod' => 'GET', + 'paginationMode' => self::MODE_FETCH, + 'authActions' => [ + ['type' => 'wait', 'ms' => 2000], + ], + ]; + } + + protected function extractListTotalPages($listFirstPageData, $countData = null) + { + $total = (int) ($listFirstPageData['count'] ?? 0); + $this->unifiedData->total = $total; + $this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0); + if ($total <= self::DEFAULT_PER_PAGE_COUNT) { + return 1; + } + return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT); + } + + protected function buildListPageParams(int $page): array + { + return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT]; + } + + protected function getUiPaginationConfig(): array + { + return [ + 'nextBtnSelector' => '.layui-laypage-next', + 'waitMs' => 2000, + ]; + } + + protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData + { + $unifiedData = $this->unifiedData; + foreach ($allListPagesData as $pageRaw) { + $records = $pageRaw['data'] ?? []; + foreach ($records as $item) { + if (empty($item['user'])) { + continue; + } + $number = (string) $item['user']; + $isOnline = isset($item['online']) && (int) $item['online'] === 1; + $unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0)); + } + } + return $unifiedData; + } +} diff --git a/patches/application/common/model/Config.php b/patches/application/common/model/Config.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/CloudflareService.php b/patches/application/common/service/CloudflareService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/DomainDetectRateLimitService.php b/patches/application/common/service/DomainDetectRateLimitService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitAutoReplyService.php b/patches/application/common/service/SplitAutoReplyService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitCronLockService.php b/patches/application/common/service/SplitCronLockService.php new file mode 100755 index 0000000..e442b3e --- /dev/null +++ b/patches/application/common/service/SplitCronLockService.php @@ -0,0 +1,64 @@ +lockPath(); + if ($this->isStaleLock($path)) { + @unlink($path); + } + if (is_file($path)) { + return false; + } + $payload = json_encode([ + 'pid' => getmypid(), + 'time' => time(), + ], JSON_UNESCAPED_UNICODE); + $written = @file_put_contents($path, $payload, LOCK_EX); + return $written !== false; + } + + /** + * 释放全局 cron 锁 + */ + public function release(): void + { + $path = $this->lockPath(); + if (is_file($path)) { + @unlink($path); + } + } + + private function lockPath(): string + { + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); + $dir = $runtime . 'split_ticket_sync/'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + return $dir . self::LOCK_FILE; + } + + private function isStaleLock(string $path): bool + { + if (!is_file($path)) { + return false; + } + $mtime = (int) @filemtime($path); + $ttl = SplitSyncConfigService::getCronLockTtlSeconds(); + return $mtime > 0 && (time() - $mtime) > $ttl; + } +} diff --git a/patches/application/common/service/SplitFriendUrlBuilder.php b/patches/application/common/service/SplitFriendUrlBuilder.php old mode 100644 new mode 100755 index bf5c2a4..6182949 --- a/patches/application/common/service/SplitFriendUrlBuilder.php +++ b/patches/application/common/service/SplitFriendUrlBuilder.php @@ -12,13 +12,13 @@ class SplitFriendUrlBuilder /** * 构建跳转 URL;无法构建时返回空字符串 * - * @param string $whatsAppReplyText 仅 WhatsApp 类型使用,预填消息文案(urlencode 在内部处理) + * @param string $replyText WhatsApp / Telegram 预填消息文案(内部 rawurlencode) */ public static function build( string $numberType, string $number, string $numberTypeCustom = '', - string $whatsAppReplyText = '' + string $replyText = '' ): string { $number = trim($number); if ($number === '') { @@ -27,9 +27,9 @@ class SplitFriendUrlBuilder switch ($numberType) { case 'whatsapp': - return self::buildWhatsApp($number, $whatsAppReplyText); + return self::buildWhatsApp($number, $replyText); case 'telegram': - return self::buildTelegram($number); + return self::buildTelegram($number, $replyText); case 'line': return self::buildLine($number); case 'custom': @@ -59,16 +59,22 @@ class SplitFriendUrlBuilder } /** - * Telegram:https://t.me/+ 号码(去掉前导 +) + * Telegram:https://t.me/+ 号码(去掉前导 +),可选 ?text= 预填消息(须 URL 编码) */ - private static function buildTelegram(string $number): string + private static function buildTelegram(string $number, string $replyText = ''): string { $id = ltrim($number, '+'); if ($id === '') { return ''; } - return 'https://t.me/+' . $id; + $url = 'https://t.me/+' . $id; + $replyText = trim($replyText); + if ($replyText !== '') { + $url .= '?text=' . rawurlencode($replyText); + } + + return $url; } /** diff --git a/patches/application/common/service/SplitGeoIpService.php b/patches/application/common/service/SplitGeoIpService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitIpProtectService.php b/patches/application/common/service/SplitIpProtectService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitLinkCodeService.php b/patches/application/common/service/SplitLinkCodeService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitNodeHealthService.php b/patches/application/common/service/SplitNodeHealthService.php new file mode 100755 index 0000000..9e815f5 --- /dev/null +++ b/patches/application/common/service/SplitNodeHealthService.php @@ -0,0 +1,96 @@ +|null + */ + public static function fetchStats(): ?array + { + $url = SplitSyncConfigService::getNodeHost() . '/api/stats'; + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2); + curl_setopt($ch, CURLOPT_TIMEOUT, 3); + $response = curl_exec($ch); + if (curl_errno($ch)) { + curl_close($ch); + return null; + } + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($httpCode !== 200) { + return null; + } + $decoded = json_decode((string) $response, true); + return is_array($decoded) ? $decoded : null; + } + + /** + * 当前是否适合向 Node 提交新 Browser 任务 + */ + public static function canAcceptWork(): bool + { + $stats = self::fetchStats(); + if ($stats === null) { + return true; + } + if (!empty($stats['shuttingDown'])) { + return false; + } + $queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : []; + $config = is_array($stats['config'] ?? null) ? $stats['config'] : []; + $queued = (int) ($queue['queued'] ?? 0); + $active = (int) ($queue['active'] ?? 0); + $max = max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))); + $threshold = SplitSyncConfigService::getNodeQueueSkipThreshold(); + if ($queued >= $threshold) { + return false; + } + return $active < $max; + } + + /** + * @return array{queued:int,active:int,max:int} + */ + public static function getQueueSnapshot(): array + { + $stats = self::fetchStats(); + if ($stats === null) { + return ['queued' => 0, 'active' => 0, 'max' => 0]; + } + $queue = is_array($stats['queue'] ?? null) ? $stats['queue'] : []; + $config = is_array($stats['config'] ?? null) ? $stats['config'] : []; + return [ + 'queued' => (int) ($queue['queued'] ?? 0), + 'active' => (int) ($queue['active'] ?? 0), + 'max' => max(1, (int) ($config['maxConcurrentBrowsers'] ?? ($queue['max'] ?? 4))), + ]; + } + + /** + * Node 侧 Real Browser / Captcha 是否就绪(供同步失败日志可读性) + * + * @return array{realBrowserReady:bool,captchaConfigured:bool} + */ + public static function getAntiBotSnapshot(): array + { + $stats = self::fetchStats(); + if ($stats === null) { + return ['realBrowserReady' => false, 'captchaConfigured' => false]; + } + return [ + 'realBrowserReady' => !empty($stats['realBrowserReady']), + 'captchaConfigured' => !empty($stats['captchaConfigured']), + ]; + } +} diff --git a/patches/application/common/service/SplitNumberWeighService.php b/patches/application/common/service/SplitNumberWeighService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitPixelBrowserRenderer.php b/patches/application/common/service/SplitPixelBrowserRenderer.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitPixelConfigService.php b/patches/application/common/service/SplitPixelConfigService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitPixelPostbackService.php b/patches/application/common/service/SplitPixelPostbackService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitPlatformDomainService.php b/patches/application/common/service/SplitPlatformDomainService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitRedirectService.php b/patches/application/common/service/SplitRedirectService.php old mode 100644 new mode 100755 index a7a68f4..6913fde --- a/patches/application/common/service/SplitRedirectService.php +++ b/patches/application/common/service/SplitRedirectService.php @@ -72,16 +72,16 @@ class SplitRedirectService ? (string) ($picked['number_type_custom'] ?? '') : (string) $picked->getAttr('number_type_custom'); - $whatsAppReplyText = ''; - if ($numberType === 'whatsapp') { - $whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply')); + $replyText = ''; + if (in_array($numberType, ['whatsapp', 'telegram'], true)) { + $replyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply')); } $redirectUrl = SplitFriendUrlBuilder::build( $numberType, $numberValue, $numberCustom, - $whatsAppReplyText + $replyText ); if ($redirectUrl === '') { return null; diff --git a/patches/application/common/service/SplitRoundRobinStore.php b/patches/application/common/service/SplitRoundRobinStore.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitScrmSpiderFactory.php b/patches/application/common/service/SplitScrmSpiderFactory.php old mode 100644 new mode 100755 index 9ab061a..479251e --- a/patches/application/common/service/SplitScrmSpiderFactory.php +++ b/patches/application/common/service/SplitScrmSpiderFactory.php @@ -6,9 +6,11 @@ namespace app\common\service; use app\common\library\scrm\ScrmSpiderInterface; use app\common\library\scrm\spider\A2cSpider; +use app\common\library\scrm\spider\ChatknowSpider; use app\common\library\scrm\spider\HaiwangSpider; use app\common\library\scrm\spider\HuojianSpider; use app\common\library\scrm\spider\SsCustomerSpider; +use app\common\library\scrm\spider\WhatshubSpider; use app\common\library\scrm\spider\XingheSpider; /** @@ -25,6 +27,8 @@ class SplitScrmSpiderFactory 'huojian' => HuojianSpider::class, 'xinghe' => XingheSpider::class, 'ss_customer' => SsCustomerSpider::class, + 'chatknow' => ChatknowSpider::class, + 'whatshub' => WhatshubSpider::class, // ceo_scrm 等未实现类型:新增 spider 类后在此注册 ]; @@ -45,6 +49,16 @@ class SplitScrmSpiderFactory return self::resolveClass($ticketType) !== null; } + /** + * 已注册且已实现蜘蛛的类型列表 + * + * @return list + */ + public static function listSupportedTypes(): array + { + return array_keys(self::MAP); + } + /** * @return ScrmSpiderInterface|null */ diff --git a/patches/application/common/service/SplitSyncConfigService.php b/patches/application/common/service/SplitSyncConfigService.php old mode 100644 new mode 100755 index de0cb3d..3bf0d86 --- a/patches/application/common/service/SplitSyncConfigService.php +++ b/patches/application/common/service/SplitSyncConfigService.php @@ -50,6 +50,42 @@ class SplitSyncConfigService return max(0, (int) $value); } + /** + * 单次 cron 最多处理几条到期工单,避免一分钟内打满 Node Browser 槽位 + */ + public static function getMaxTicketsPerCronRun(): int + { + $value = self::getConfigValue('split_sync_max_per_cron'); + if ($value === '') { + return 2; + } + return max(1, min(20, (int) $value)); + } + + /** + * 全局 cron 锁过期秒数,防止异常退出后永久占锁 + */ + public static function getCronLockTtlSeconds(): int + { + $value = self::getConfigValue('split_sync_cron_lock_ttl'); + if ($value === '') { + return 1800; + } + return max(300, (int) $value); + } + + /** + * Node 排队数达到该阈值时,本轮 cron 不再提交新任务 + */ + public static function getNodeQueueSkipThreshold(): int + { + $value = self::getConfigValue('split_sync_node_queue_threshold'); + if ($value === '') { + return 2; + } + return max(0, (int) $value); + } + private static function getConfigValue(string $name): string { $site = Config::get('site.' . $name); diff --git a/patches/application/common/service/SplitTicketNumberSyncService.php b/patches/application/common/service/SplitTicketNumberSyncService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitTicketRuleService.php b/patches/application/common/service/SplitTicketRuleService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitTicketSyncLockService.php b/patches/application/common/service/SplitTicketSyncLockService.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitTicketSyncLogger.php b/patches/application/common/service/SplitTicketSyncLogger.php old mode 100644 new mode 100755 diff --git a/patches/application/common/service/SplitTicketSyncService.php b/patches/application/common/service/SplitTicketSyncService.php old mode 100644 new mode 100755 index 71d5ed1..9d7850e --- a/patches/application/common/service/SplitTicketSyncService.php +++ b/patches/application/common/service/SplitTicketSyncService.php @@ -30,9 +30,10 @@ class SplitTicketSyncService /** * 同步单条工单 * + * @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入) * @return array{success:bool,message:string,skipped?:bool} */ - public function syncOne(int $ticketId, bool $force = false): array + public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array { $ticket = Ticket::get($ticketId); if (!$ticket) { @@ -50,7 +51,7 @@ class SplitTicketSyncService 'nodeHost' => SplitSyncConfigService::getNodeHost(), ]); - if (!$force) { + if (!$force && !$dueValidated) { $skip = $this->shouldSkip($ticket); if ($skip !== null) { SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]); @@ -76,23 +77,47 @@ class SplitTicketSyncService } /** - * 扫描到期工单并同步 + * 扫描到期工单并同步(限流 + Node 队列感知) */ public function syncDueTickets(): int { - $count = 0; + $maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun(); + $autoSyncTypes = $this->resolveAutoSyncTicketTypes(); + if ($autoSyncTypes === []) { + SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']); + return 0; + } + + if (!SplitNodeHealthService::canAcceptWork()) { + SplitTicketSyncLogger::log('cron', 'scan skip', [ + 'reason' => 'node queue busy', + 'queue' => SplitNodeHealthService::getQueueSnapshot(), + ]); + return 0; + } + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); - $query = Ticket::where('status', 'normal'); + $query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes); if ($failThreshold > 0) { $query->where('sync_fail_count', '<', $failThreshold); } - $list = $query->select(); + // 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单 + $list = $query->order('sync_time', 'asc')->order('id', 'asc') + ->limit($maxPerRun * 5) + ->select(); SplitTicketSyncLogger::log('cron', 'scan start', [ 'candidateCount' => count($list), + 'maxPerRun' => $maxPerRun, + 'autoSyncTypes' => $autoSyncTypes, + 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), ]); + $count = 0; foreach ($list as $ticket) { + if ($count >= $maxPerRun) { + break; + } $skip = $this->shouldSkip($ticket); if ($skip !== null) { SplitTicketSyncLogger::log('cron', 'candidate skipped', [ @@ -102,7 +127,14 @@ class SplitTicketSyncService ]); continue; } - $result = $this->syncOne((int) $ticket['id'], false); + if (!SplitNodeHealthService::canAcceptWork()) { + SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [ + 'processed' => $count, + 'queue' => SplitNodeHealthService::getQueueSnapshot(), + ]); + break; + } + $result = $this->syncOne((int) $ticket['id'], false, true); if (!empty($result['skipped'])) { continue; } @@ -113,6 +145,22 @@ class SplitTicketSyncService return $count; } + /** + * 已配置自动同步周期且已实现蜘蛛的工单类型 + * + * @return list + */ + private function resolveAutoSyncTicketTypes(): array + { + $types = []; + foreach (SplitScrmSpiderFactory::listSupportedTypes() as $ticketType) { + if (SplitSyncConfigService::getIntervalMinutes($ticketType) > 0) { + $types[] = $ticketType; + } + } + return $types; + } + /** * @return array{success:bool,message:string} */ diff --git a/patches/application/common/service/SplitVisitPageService.php b/patches/application/common/service/SplitVisitPageService.php old mode 100644 new mode 100755 diff --git a/patches/application/extra/cloudflare.php b/patches/application/extra/cloudflare.php old mode 100644 new mode 100755 diff --git a/patches/application/index/controller/S.php b/patches/application/index/controller/S.php old mode 100644 new mode 100755 diff --git a/patches/application/index/controller/Split.php b/patches/application/index/controller/Split.php old mode 100644 new mode 100755 diff --git a/patches/application/index/view/split/not_found.html b/patches/application/index/view/split/not_found.html old mode 100644 new mode 100755 diff --git a/patches/application/index/view/split/visit.html b/patches/application/index/view/split/visit.html old mode 100644 new mode 100755 diff --git a/patches/application/route_split_visit.php b/patches/application/route_split_visit.php old mode 100644 new mode 100755 diff --git a/patches/public/assets/js/backend/domain.js b/patches/public/assets/js/backend/domain.js old mode 100644 new mode 100755 diff --git a/patches/public/assets/js/backend/split/link.js b/patches/public/assets/js/backend/split/link.js old mode 100644 new mode 100755 index c8e78ff..a65c8a3 --- a/patches/public/assets/js/backend/split/link.js +++ b/patches/public/assets/js/backend/split/link.js @@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); var table = $("#table"); + var searchSelect = Controller.api.searchSelectColumn; + var searchSelectMeta = Controller.api.searchSelectMeta; table.bootstrapTable({ url: $.fn.bootstrapTable.defaults.extend.index_url, @@ -25,11 +27,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin columns: [ [ {checkbox: true}, + searchSelect('id', __('Link_code'), Config.linkFilterList || {}, true), {field: 'countries_text', title: __('Countries'), operate: false, formatter: Table.api.formatter.content}, { field: 'link_code', title: __('Link_code'), - operate: 'LIKE', + operate: false, formatter: Controller.api.formatter.linkCode }, {field: 'description', title: __('Description'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content}, @@ -39,9 +42,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin operate: false, formatter: Controller.api.formatter.autoReplyText }, - {field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status}, - {field: 'random_shuffle', title: __('Random_shuffle'), searchList: Config.randomShuffleList, formatter: Table.api.formatter.status}, - {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.status}, + $.extend( + {field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status}, + searchSelectMeta(false) + ), + $.extend( + {field: 'random_shuffle', title: __('Random_shuffle'), searchList: Config.randomShuffleList, formatter: Table.api.formatter.status}, + searchSelectMeta(false) + ), + $.extend( + {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.status}, + searchSelectMeta(false) + ), {field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true}, { field: 'operate', @@ -117,6 +129,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindAutoReplyPreviewTips(table); + table.on('post-common-search.bs.table', function (e, tbl) { + Controller.api.initCommonSearchSelectpicker(tbl); + }); + Table.api.bindevent(table); }, add: function () { @@ -127,6 +143,40 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + searchSelectColumn: function (field, title, searchList, liveSearch) { + return { + field: field, + title: title, + visible: false, + searchList: searchList || {}, + operate: '=', + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectMeta: function (liveSearch) { + return { + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectExtend: function (liveSearch) { + var text = __('Please select'); + if (!text || text === 'Please select' || text === 'Please Select') { + text = '请选择'; + } + var ext = 'data-none-selected-text="' + text + '" title="' + text + '"'; + if (liveSearch) { + ext += ' data-live-search="true"'; + } + return ext; + }, + initCommonSearchSelectpicker: function (tbl) { + var $form = $('form.form-commonsearch', tbl.$container); + if ($form.length) { + Form.events.selectpicker($form); + } + }, /** * 规范化后台跨模块跳转 URL,避免在 split.link 页面内相对解析成 split.link/domain * diff --git a/patches/public/assets/js/backend/split/number.js b/patches/public/assets/js/backend/split/number.js old mode 100644 new mode 100755 index 55a942e..e6d771b --- a/patches/public/assets/js/backend/split/number.js +++ b/patches/public/assets/js/backend/split/number.js @@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); var table = $("#table"); + var searchSelect = Controller.api.searchSelectColumn; + var searchSelectMeta = Controller.api.searchSelectMeta; table.bootstrapTable({ url: $.fn.bootstrapTable.defaults.extend.index_url, @@ -22,9 +24,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin sortOrder: 'desc', fixedColumns: true, fixedRightNumber: 1, + searchFormVisible: true, columns: [ [ {checkbox: true}, + searchSelect('split_link_id', __('Link_url'), Config.splitLinkFilterList || {}, true), {field: 'id', title: __('Id'), sortable: true}, { field: 'link_url_text', @@ -34,28 +38,20 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, {field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'}, {field: 'number', title: __('Number'), operate: 'LIKE'}, - { - field: 'number_type', - title: __('Number_type'), - searchList: Config.numberTypeList, - formatter: Table.api.formatter.normal - }, + $.extend( + {field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal}, + searchSelectMeta(false) + ), {field: 'visit_count', title: __('Visit_count'), operate: false, sortable: true}, {field: 'inbound_count', title: __('Inbound_count'), operate: false, sortable: true}, - { - field: 'platform_status', - title: __('Platform_status'), - searchList: Config.platformStatusList, - formatter: Controller.api.formatter.platformStatus - }, - { - field: 'status', - title: __('Status'), - searchList: Config.statusList, - formatter: Table.api.formatter.toggle, - yes: 'normal', - no: 'hidden' - }, + $.extend( + {field: 'platform_status', title: __('Platform_status'), searchList: Config.platformStatusList, formatter: Controller.api.formatter.platformStatus}, + searchSelectMeta(false) + ), + $.extend( + {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'}, + searchSelectMeta(false) + ), { field: 'createtime', title: __('Createtime'), @@ -95,6 +91,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.openBatchUpdateModal(ids, table); }); + $('.btn-batch-operate').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + Controller.api.openBatchOperateModal(table); + }); + + table.on('post-common-search.bs.table', function (e, tbl) { + Controller.api.initCommonSearchSelectpicker(tbl); + }); + Table.api.bindevent(table); }, add: function () { @@ -104,6 +110,41 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + searchSelectColumn: function (field, title, searchList, liveSearch) { + return { + field: field, + title: title, + visible: false, + searchList: searchList || {}, + operate: '=', + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectMeta: function (liveSearch) { + return { + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectExtend: function (liveSearch) { + var text = __('Please select'); + if (!text || text === 'Please select' || text === 'Please Select') { + text = '请选择'; + } + var ext = 'data-none-selected-text="' + text + '" title="' + text + '"'; + if (liveSearch) { + ext += ' data-live-search="true"'; + } + return ext; + }, + initCommonSearchSelectpicker: function (tbl) { + var $form = $('form.form-commonsearch', tbl.$container); + if ($form.length) { + Form.events.selectpicker($form); + } + }, + formatter: { platformStatus: function (value, row) { var text = row.platform_status_text != null && row.platform_status_text !== '' @@ -214,6 +255,101 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin return false; } }); + }, + openBatchOperateModal: function (table) { + var pleaseSelect = __('Please select'); + if (!pleaseSelect || pleaseSelect === 'Please select' || pleaseSelect === 'Please Select') { + pleaseSelect = '请选择'; + } + var linkOptions = ['']; + $.each(Config.splitLinkSelectList || {}, function (id, label) { + linkOptions.push(''); + }); + var html = [ + '
', + '
', + ' ', + ' ', + '
', + '
', + ' ', + ' ', + '
', + '
', + ' ', + ' ', + '
', + '
', + ' ', + ' ', + '
', + '
' + ].join(''); + + var layerIndex = Layer.open({ + type: 1, + title: __('Batch operate title'), + area: ['520px', 'auto'], + shadeClose: false, + content: html, + success: function (layero, index) { + Form.events.selectpicker(layero); + layero.find('.btn-batch-operate-cancel').on('click', function () { + Layer.close(index); + }); + layero.find('.btn-batch-operate-confirm').on('click', function () { + var splitLinkId = $.trim(layero.find('#batch-operate-split-link').val()); + var numbers = $.trim(layero.find('#batch-operate-numbers').val()); + var action = $.trim(layero.find('#batch-operate-action').val()); + + if (!splitLinkId) { + Toastr.warning(__('Please select split link')); + return false; + } + if (!numbers) { + Toastr.warning(__('Please fill at least one number')); + return false; + } + if (!action) { + Toastr.error(__('Invalid batch operate action')); + return false; + } + + var submit = function () { + Fast.api.ajax({ + url: 'split.number/batchoperate', + type: 'post', + data: { + split_link_id: splitLinkId, + numbers: numbers, + action: action + } + }, function (data, ret) { + Layer.close(index); + table.bootstrapTable('refresh'); + Toastr.success(ret.msg || __('Batch operate success')); + }); + }; + + if (action === 'delete') { + Layer.confirm(__('Batch operate delete confirm'), {icon: 3, title: __('Warning')}, function (confirmIndex) { + Layer.close(confirmIndex); + submit(); + }); + return false; + } + + submit(); + return false; + }); + } + }); } } }; diff --git a/patches/public/assets/js/backend/split/ticket.js b/patches/public/assets/js/backend/split/ticket.js old mode 100644 new mode 100755 index 01bb5fa..3d11d61 --- a/patches/public/assets/js/backend/split/ticket.js +++ b/patches/public/assets/js/backend/split/ticket.js @@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); var table = $("#table"); + var searchSelect = Controller.api.searchSelectColumn; + var searchSelectMeta = Controller.api.searchSelectMeta; table.bootstrapTable({ url: $.fn.bootstrapTable.defaults.extend.index_url, @@ -25,6 +27,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin columns: [ [ {checkbox: true}, + searchSelect('split_link_id', __('Split_link_id'), Config.splitLinkFilterList || {}, true), { field: 'ticket_type', title: __('Ticket_type'), @@ -39,6 +42,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin operate: false, formatter: Controller.api.formatter.splitLinkCode }, + { + field: 'ticket_url', + title: __('Ticket_url'), + operate: 'LIKE', + formatter: Controller.api.formatter.ticketUrlLink + }, { field: 'start_time_text', title: __('Start_time'), @@ -89,14 +98,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin operate: false, formatter: Controller.api.formatter.syncDisplay }, - { - field: 'status', - title: __('Status'), - searchList: Config.statusList, - formatter: Table.api.formatter.toggle, - yes: 'normal', - no: 'hidden' - }, + $.extend( + {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'}, + searchSelectMeta(false) + ), { field: 'createtime', title: __('Createtime'), @@ -110,18 +115,27 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin field: 'operate', title: __('Operate'), table: table, - events: Table.api.events.operate, - formatter: Table.api.formatter.operate + events: Controller.api.events.operate, + formatter: Controller.api.formatter.operate } ] ] }); + table.on('post-common-search.bs.table', function (e, tbl) { + Controller.api.initCommonSearchSelectpicker(tbl); + }); + Table.api.bindevent(table); Controller.api.syncingTicketIds = []; window.__splitTicketPendingPostAddSyncIds = window.__splitTicketPendingPostAddSyncIds || []; - table.on('load-success.bs.table', function () { + table.on('click', '.split-ticket-url-group input, .split-ticket-url-group a', function (e) { + e.stopPropagation(); + }); + + table.on('load-success.bs.table', function (e, res) { + Controller.api.renderSummaryRow(res); var pendingIds = window.__splitTicketPendingPostAddSyncIds; if (!pendingIds || !pendingIds.length) { return; @@ -185,9 +199,67 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + searchSelectColumn: function (field, title, searchList, liveSearch) { + return { + field: field, + title: title, + visible: false, + searchList: searchList || {}, + operate: '=', + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectMeta: function (liveSearch) { + return { + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectExtend: function (liveSearch) { + var text = __('Please select'); + if (!text || text === 'Please select' || text === 'Please Select') { + text = '请选择'; + } + var ext = 'data-none-selected-text="' + text + '" title="' + text + '"'; + if (liveSearch) { + ext += ' data-live-search="true"'; + } + return ext; + }, + initCommonSearchSelectpicker: function (tbl) { + var $form = $('form.form-commonsearch', tbl.$container); + if ($form.length) { + Form.events.selectpicker($form); + } + }, + /** @type {number[]} 正在手动同步的工单 ID */ syncingTicketIds: [], + /** + * 渲染筛选结果汇总行(全量筛选数据,非当前页) + * + * @param {object} res 列表接口响应 + */ + renderSummaryRow: function (res) { + var $bar = $('#split-ticket-summary'); + if (!$bar.length) { + return; + } + var total = res && res.total ? parseInt(res.total, 10) : 0; + var summary = res && res.summary ? res.summary : null; + if (!summary || total <= 0) { + $bar.addClass('hide'); + return; + } + $bar.removeClass('hide'); + $bar.find('[data-sum="ticket_total"]').text(summary.ticket_total != null ? summary.ticket_total : 0); + $bar.find('[data-sum="complete_count"]').text(summary.complete_count != null ? summary.complete_count : 0); + $bar.find('[data-sum="ticket_progress_text"]').text(summary.ticket_progress_text || '0.00%'); + $bar.find('[data-sum="speed_per_hour"]').text(summary.speed_per_hour != null ? summary.speed_per_hour : '0.00'); + }, + /** * 后台同步:标记「同步中」并请求 sync 接口 * @@ -243,6 +315,30 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, formatter: { + /** + * 操作列:编辑 → 拷贝 → 删除 + */ + operate: function (value, row, index) { + var column = this; + var table = this.table; + var options = table.bootstrapTable('getOptions'); + var buttons = []; + if (options.extend.edit_url) { + buttons.push($.extend({}, Table.button.edit, {url: options.extend.edit_url})); + } + buttons.push({ + name: 'copy', + icon: 'fa fa-copy', + title: __('Copy'), + classname: 'btn btn-xs btn-info btn-copyone', + url: 'javascript:;', + extend: 'data-toggle="tooltip" data-container="body"', + }); + if (options.extend.del_url) { + buttons.push($.extend({}, Table.button.del)); + } + return Table.api.buttonlink(column, buttons, value, row, index, 'operate'); + }, /** * 工单类型:纯文本展示,无链接/标签样式 */ @@ -267,6 +363,27 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin return '' + safe + ''; }, + /** + * 工单链接:只读 input + 外链图标按钮 + */ + ticketUrlLink: function (value) { + value = value == null ? '' : String(value).trim(); + if (value === '') { + return '-'; + } + var href = value; + if (!/^https?:\/\//i.test(href)) { + href = 'https://' + href; + } + var safeValue = Fast.api.escape(value); + var safeHref = Fast.api.escape(href); + return '
' + + '' + + '' + + '' + + '' + + '
'; + }, speedPerHour: function (value) { var num = parseFloat(value); if (isNaN(num)) { @@ -308,6 +425,22 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin + ''; } }, + events: { + operate: $.extend({}, Table.api.events.operate, { + 'click .btn-copyone': function (e, value, row, index) { + e.stopPropagation(); + e.preventDefault(); + var table = $(this).closest('table'); + var options = table.bootstrapTable('getOptions'); + var id = row[options.pk]; + Fast.api.open( + Fast.api.fixurl('split.ticket/add?copy_id=' + id), + __('Copy'), + $(this).data() || {} + ); + } + }) + }, bindevent: function () { Form.api.bindevent($('form[role=form]')); Controller.api.fixSelectPlaceholder(); diff --git a/patches/puppeteer-api/.runtime/xdg-config/google-chrome-for-testing/Crash Reports/settings.dat b/patches/puppeteer-api/.runtime/xdg-config/google-chrome-for-testing/Crash Reports/settings.dat new file mode 100755 index 0000000000000000000000000000000000000000..b306e15a9f2a83ceef1a475ab5fa05b4a705f1cb GIT binary patch literal 40 fcmXR;32 { + if (req.isInterceptResolutionHandled()) { + return; + } + if (blockHeavyResources) { + const resourceType = req.resourceType(); + if (['image', 'media', 'font'].includes(resourceType)) { + req.abort(); + return; + } + } + const originalUrl = req.url(); + const rewrittenUrl = rewriteHttpsToHttpForLiteralIp(originalUrl); + if (rewrittenUrl !== originalUrl) { + const continueOptions = { url: rewrittenUrl }; + if (shareToken) { + const headers = { ...req.headers() }; + const existingCookie = headers.cookie || headers.Cookie || ''; + if (!existingCookie.includes('share_token=')) { + headers.cookie = existingCookie + ? `${existingCookie}; share_token=${shareToken}` + : `share_token=${shareToken}`; + } + continueOptions.headers = headers; + } + req.continue(continueOptions); + return; + } + req.continue(); + }); +} + +module.exports = { + rewriteHttpsToHttpForLiteralIp, + attachHttpIpRewriteInterceptor, +}; diff --git a/patches/puppeteer-api/lib/browser-factory.js b/patches/puppeteer-api/lib/browser-factory.js new file mode 100755 index 0000000..4fa831b --- /dev/null +++ b/patches/puppeteer-api/lib/browser-factory.js @@ -0,0 +1,160 @@ +/** + * BrowserFactory:standard / real 双轨启动 + 独立并发限流 + */ +const { + DEFAULT_UA, + MAX_CONCURRENT_BROWSERS, + MAX_CONCURRENT_BROWSERS_REAL, + QUEUE_TIMEOUT_MS, +} = require('./constants'); +const { BrowserConcurrencyLimiter } = require('./concurrency'); +const { launchStandardBrowser } = require('./launch-standard'); +const { launchRealBrowser, isRealBrowserAvailable } = require('./launch-real-browser'); + +const standardLimiter = new BrowserConcurrencyLimiter(MAX_CONCURRENT_BROWSERS); +const realLimiter = new BrowserConcurrencyLimiter(MAX_CONCURRENT_BROWSERS_REAL); + +/** + * @typedef {Object} AntiBotConfig + * @property {boolean} [enabled] + * @property {'standard'|'real'} [profile] + * @property {boolean} [turnstile] + * @property {boolean} [solverFallback] + * @property {string} [sessionKey] + * @property {number} [challengeTimeoutMs] + */ + +/** + * 规范化 antiBot 配置(未传则走 standard) + * @param {AntiBotConfig|null|undefined} antiBot + * @returns {AntiBotConfig} + */ +function normalizeAntiBot(antiBot) { + if (!antiBot || !antiBot.enabled) { + return { enabled: false, profile: 'standard' }; + } + return { + enabled: true, + profile: antiBot.profile === 'real' ? 'real' : 'standard', + turnstile: antiBot.turnstile !== false, + solverFallback: antiBot.solverFallback !== false, + sessionKey: antiBot.sessionKey || '', + challengeTimeoutMs: antiBot.challengeTimeoutMs || 60000, + }; +} + +/** + * 按 profile 获取限流器 + * @param {'standard'|'real'} profile + */ +function getLimiter(profile) { + return profile === 'real' ? realLimiter : standardLimiter; +} + +/** + * 创建 Browser 会话 + * @param {{ profile?: 'standard'|'real', extraArgs?: string[] }} options + * @returns {Promise<{ browser: import('puppeteer').Browser, page: import('puppeteer').Page|null, profile: string, cleanup: () => Promise }>} + */ +async function createBrowserSession(options = {}) { + const profile = options.profile === 'real' ? 'real' : 'standard'; + const extraArgs = options.extraArgs || []; + + if (profile === 'real') { + const { browser, page } = await launchRealBrowser(extraArgs); + return { + browser, + page, + profile: 'real', + cleanup: async () => { + try { + await browser.close(); + } catch (_) { + /* ignore */ + } + }, + }; + } + + const browser = await launchStandardBrowser(extraArgs); + return { + browser, + page: null, + profile: 'standard', + cleanup: async () => { + try { + await browser.close(); + } catch (_) { + /* ignore */ + } + }, + }; +} + +/** + * 在并发槽位内执行 Browser 任务 + * @template T + * @param {string} taskName + * @param {'standard'|'real'} profile + * @param {() => Promise} fn + * @returns {Promise} + */ +async function runWithProfileLimit(taskName, profile, fn) { + const limiter = getLimiter(profile); + try { + return await limiter.run(taskName, fn); + } catch (err) { + if (err.message === 'QUEUE_TIMEOUT') err.code = 'QUEUE_TIMEOUT'; + throw err; + } +} + +/** + * 创建 Page 并设置 UA / viewport / cookies + * @param {import('puppeteer').Browser} browser + * @param {import('puppeteer').Page|null} existingPage + * @param {{ userAgent?: string, viewport?: object, cookies?: Array }} [options] + */ +async function createManagedPage(browser, existingPage, options = {}) { + const page = existingPage || await browser.newPage(); + page.setDefaultNavigationTimeout(30000); + page.setDefaultTimeout(15000); + + const userAgent = options.userAgent || DEFAULT_UA; + await page.setUserAgent(userAgent); + + if (options.viewport) { + await page.setViewport(options.viewport); + } + if (options.cookies && options.cookies.length > 0) { + await page.setCookie(...options.cookies); + } + + page.on('error', (err) => console.error('[Page Error]', err.message)); + page.on('pageerror', (err) => console.error('[Page JS Error]', err.message)); + + return page; +} + +/** + * @returns {{ standard: object, real: object, realBrowserReady: boolean }} + */ +function getFactoryStats() { + return { + standard: standardLimiter.getStats(), + real: realLimiter.getStats(), + realBrowserReady: isRealBrowserAvailable(), + }; +} + +module.exports = { + normalizeAntiBot, + getLimiter, + createBrowserSession, + runWithProfileLimit, + createManagedPage, + getFactoryStats, + standardLimiter, + realLimiter, + QUEUE_TIMEOUT_MS, +}; diff --git a/patches/puppeteer-api/lib/captcha-solver.js b/patches/puppeteer-api/lib/captcha-solver.js new file mode 100755 index 0000000..779cd4f --- /dev/null +++ b/patches/puppeteer-api/lib/captcha-solver.js @@ -0,0 +1,153 @@ +/** + * 付费 Captcha API 兜底:CapSolver / 2Captcha 统一 Turnstile 求解接口 + */ +const { + CAPTCHA_PROVIDER, + CAPTCHA_API_KEY, + CAPTCHA_MAX_WAIT_MS, +} = require('./constants'); + +/** + * @param {string} url + * @param {Record} [headers] + * @returns {Promise} + */ +async function httpJsonPost(url, body, headers = {}) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + const text = await res.text(); + try { + return JSON.parse(text); + } catch (_) { + throw new Error(`Captcha API 返回非 JSON: ${text.slice(0, 200)}`); + } +} + +/** + * @param {string} url + * @returns {Promise} + */ +async function httpJsonGet(url) { + const res = await fetch(url); + const text = await res.text(); + try { + return JSON.parse(text); + } catch (_) { + throw new Error(`Captcha API 返回非 JSON: ${text.slice(0, 200)}`); + } +} + +/** + * CapSolver Turnstile 求解 + * @param {{ pageUrl: string, sitekey: string, apiKey: string }} params + * @returns {Promise} + */ +async function solveViaCapSolver({ pageUrl, sitekey, apiKey }) { + const create = await httpJsonPost('https://api.capsolver.com/createTask', { + clientKey: apiKey, + task: { + type: 'AntiTurnstileTaskProxyLess', + websiteURL: pageUrl, + websiteKey: sitekey, + }, + }); + + if (create.errorId !== 0 || !create.taskId) { + throw new Error(`CapSolver createTask 失败: ${create.errorDescription || create.errorCode || 'unknown'}`); + } + + const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 3000)); + const result = await httpJsonPost('https://api.capsolver.com/getTaskResult', { + clientKey: apiKey, + taskId: create.taskId, + }); + + if (result.status === 'ready' && result.solution && result.solution.token) { + return result.solution.token; + } + if (result.errorId !== 0) { + throw new Error(`CapSolver getTaskResult 失败: ${result.errorDescription || result.errorCode}`); + } + } + throw new Error('CapSolver 求解超时'); +} + +/** + * 2Captcha Turnstile 求解 + * @param {{ pageUrl: string, sitekey: string, apiKey: string }} params + * @returns {Promise} + */ +async function solveVia2Captcha({ pageUrl, sitekey, apiKey }) { + const params = new URLSearchParams({ + key: apiKey, + method: 'turnstile', + sitekey, + pageurl: pageUrl, + json: '1', + }); + + const create = await httpJsonGet(`https://2captcha.com/in.php?${params.toString()}`); + if (create.status !== 1 || !create.request) { + throw new Error(`2Captcha in.php 失败: ${create.request || create.error_text || 'unknown'}`); + } + + const taskId = create.request; + const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 5000)); + const result = await httpJsonGet( + `https://2captcha.com/res.php?key=${encodeURIComponent(apiKey)}&action=get&id=${encodeURIComponent(taskId)}&json=1` + ); + if (result.status === 1 && result.request) { + return result.request; + } + if (result.request && result.request !== 'CAPCHA_NOT_READY') { + throw new Error(`2Captcha res.php 失败: ${result.request}`); + } + } + throw new Error('2Captcha 求解超时'); +} + +/** + * 统一 Turnstile 求解入口 + * @param {{ pageUrl: string, sitekey: string, provider?: string, apiKey?: string }} params + * @returns {Promise<{ token: string, provider: string }>} + */ +async function solveTurnstile({ pageUrl, sitekey, provider, apiKey }) { + const resolvedProvider = (provider || CAPTCHA_PROVIDER).toLowerCase(); + const resolvedKey = apiKey || CAPTCHA_API_KEY; + + if (!sitekey) { + throw new Error('无法提取 Turnstile sitekey'); + } + if (!resolvedKey) { + throw new Error('未配置 CAPTCHA_API_KEY,无法使用付费 Captcha 兜底'); + } + + let token; + if (resolvedProvider === '2captcha') { + token = await solveVia2Captcha({ pageUrl, sitekey, apiKey: resolvedKey }); + } else { + token = await solveViaCapSolver({ pageUrl, sitekey, apiKey: resolvedKey }); + } + + return { token, provider: resolvedProvider }; +} + +/** + * Captcha API 是否已配置 + * @returns {boolean} + */ +function isCaptchaConfigured() { + return CAPTCHA_API_KEY.length > 0; +} + +module.exports = { + solveTurnstile, + isCaptchaConfigured, +}; diff --git a/patches/puppeteer-api/lib/cf-detector.js b/patches/puppeteer-api/lib/cf-detector.js new file mode 100755 index 0000000..31c1794 --- /dev/null +++ b/patches/puppeteer-api/lib/cf-detector.js @@ -0,0 +1,97 @@ +/** + * Cloudflare / Turnstile 挑战页检测 + */ + +/** + * @typedef {Object} CloudflareState + * @property {boolean} blocked 是否处于 CF 挑战中 + * @property {string|null} challengeType turnstile|js_challenge|unknown|null + * @property {boolean} hasCfClearance 是否已有 cf_clearance cookie + * @property {string} url 当前页面 URL + * @property {string} title 页面 title + */ + +/** + * 检测页面是否被 Cloudflare 拦截 + * @param {import('puppeteer').Page} page + * @returns {Promise} + */ +async function detectCloudflare(page) { + const url = page.url(); + const title = await page.title().catch(() => ''); + + const cookies = await page.cookies().catch(() => []); + const hasCfClearance = cookies.some((c) => c.name === 'cf_clearance'); + + const domSignals = await page.evaluate(() => { + const hasTurnstileInput = !!document.querySelector('[name="cf-turnstile-response"]'); + const hasTurnstileWidget = !!document.querySelector('.cf-turnstile, [data-sitekey]'); + const hasChallengeRunning = !!document.querySelector('#challenge-running, #cf-challenge-running'); + const titleText = document.title || ''; + const bodyText = (document.body && document.body.innerText) ? document.body.innerText.slice(0, 500) : ''; + return { + hasTurnstileInput, + hasTurnstileWidget, + hasChallengeRunning, + titleHasJustAMoment: titleText.includes('Just a moment'), + bodyHasJustAMoment: bodyText.includes('Just a moment') || bodyText.includes('Checking your browser'), + }; + }).catch(() => ({ + hasTurnstileInput: false, + hasTurnstileWidget: false, + hasChallengeRunning: false, + titleHasJustAMoment: false, + bodyHasJustAMoment: false, + })); + + const urlChallenge = url.includes('/cdn-cgi/challenge-platform') || url.includes('/cdn-cgi/challenge'); + + let challengeType = null; + if (domSignals.hasTurnstileInput || domSignals.hasTurnstileWidget) { + challengeType = 'turnstile'; + } else if (urlChallenge || domSignals.titleHasJustAMoment || domSignals.bodyHasJustAMoment) { + challengeType = 'js_challenge'; + } + + const blocked = !hasCfClearance && ( + urlChallenge + || domSignals.titleHasJustAMoment + || domSignals.bodyHasJustAMoment + || domSignals.hasTurnstileInput + || domSignals.hasTurnstileWidget + || domSignals.hasChallengeRunning + || title.includes('Just a moment') + ); + + return { + blocked, + challengeType, + hasCfClearance, + url, + title, + }; +} + +/** + * 检测 Turnstile 是否已通过 + * @param {import('puppeteer').Page} page + * @returns {Promise} + */ +async function isTurnstileSolved(page) { + const cookies = await page.cookies().catch(() => []); + if (cookies.some((c) => c.name === 'cf_clearance')) { + return true; + } + + const tokenLen = await page.evaluate(() => { + const input = document.querySelector('[name="cf-turnstile-response"]'); + return input && input.value ? input.value.length : 0; + }).catch(() => 0); + + return tokenLen > 20; +} + +module.exports = { + detectCloudflare, + isTurnstileSolved, +}; diff --git a/patches/puppeteer-api/lib/cf-handler.js b/patches/puppeteer-api/lib/cf-handler.js new file mode 100755 index 0000000..73d87eb --- /dev/null +++ b/patches/puppeteer-api/lib/cf-handler.js @@ -0,0 +1,121 @@ +/** + * Cloudflare 挑战统一处理:检测 → 内置等待 → Captcha API 兜底 + */ +const { detectCloudflare } = require('./cf-detector'); +const { waitForTurnstile, extractTurnstileSitekey, injectTurnstileToken } = require('./turnstile-handler'); +const { solveTurnstile, isCaptchaConfigured } = require('./captcha-solver'); + +/** + * @typedef {Object} CfHandleResult + * @property {boolean} success + * @property {string|null} code + * @property {string|null} challengeType + * @property {string|null} stage + * @property {boolean} solverUsed + * @property {number} elapsedMs + * @property {boolean} cfDetected + */ + +/** + * 处理 Cloudflare / Turnstile 挑战(在 authActions 之前调用) + * @param {import('puppeteer').Page} page + * @param {{ turnstile?: boolean, solverFallback?: boolean, challengeTimeoutMs?: number }} antiBot + * @param {(page: import('puppeteer').Page) => Promise} [waitForUrlSettled] + * @returns {Promise} + */ +async function handleCloudflareChallenge(page, antiBot, waitForUrlSettled) { + const started = Date.now(); + const timeoutMs = antiBot.challengeTimeoutMs || 60000; + + let cfState = await detectCloudflare(page); + if (!cfState.blocked) { + return { + success: true, + code: null, + challengeType: null, + stage: null, + solverUsed: false, + elapsedMs: Date.now() - started, + cfDetected: false, + }; + } + + console.log(`[CF] 检测到挑战 type=${cfState.challengeType} url=${cfState.url}`); + + if (antiBot.turnstile !== false) { + const builtIn = await waitForTurnstile(page, { timeoutMs, useBuiltInClick: true }); + if (builtIn.success) { + if (waitForUrlSettled) { + await waitForUrlSettled(page).catch(() => {}); + } + cfState = await detectCloudflare(page); + if (!cfState.blocked) { + return { + success: true, + code: null, + challengeType: cfState.challengeType, + stage: 'built_in', + solverUsed: false, + elapsedMs: Date.now() - started, + cfDetected: true, + }; + } + } + } + + if (antiBot.solverFallback !== false && isCaptchaConfigured()) { + try { + const sitekey = await extractTurnstileSitekey(page); + const pageUrl = page.url(); + const { token, provider } = await solveTurnstile({ pageUrl, sitekey }); + console.log(`[CF] Captcha API (${provider}) 返回 token,注入页面`); + await injectTurnstileToken(page, token); + + const apiWaitMs = Math.min(timeoutMs, 30000); + await waitForTurnstile(page, { timeoutMs: apiWaitMs }); + + if (waitForUrlSettled) { + await waitForUrlSettled(page).catch(() => {}); + } + + cfState = await detectCloudflare(page); + if (!cfState.blocked) { + return { + success: true, + code: null, + challengeType: 'turnstile', + stage: 'api', + solverUsed: true, + elapsedMs: Date.now() - started, + cfDetected: true, + }; + } + } catch (apiErr) { + console.error('[CF] Captcha API 兜底失败:', apiErr.message); + return { + success: false, + code: 'CF_TURNSTILE_FAILED', + challengeType: cfState.challengeType || 'turnstile', + stage: 'api', + solverUsed: true, + elapsedMs: Date.now() - started, + cfDetected: true, + }; + } + } + + return { + success: false, + code: 'CF_TURNSTILE_FAILED', + challengeType: cfState.challengeType || 'unknown', + stage: antiBot.solverFallback !== false && !isCaptchaConfigured() ? 'built_in' : 'timeout', + solverUsed: false, + elapsedMs: Date.now() - started, + cfDetected: true, + }; +} + +module.exports = { + handleCloudflareChallenge, + isCaptchaConfigured, +}; diff --git a/patches/puppeteer-api/lib/concurrency.js b/patches/puppeteer-api/lib/concurrency.js new file mode 100755 index 0000000..094c7a2 --- /dev/null +++ b/patches/puppeteer-api/lib/concurrency.js @@ -0,0 +1,82 @@ +/** + * Browser 并发槽位限流器:standard / real 各自独立队列 + */ +const { QUEUE_TIMEOUT_MS } = require('./constants'); + +class BrowserConcurrencyLimiter { + /** + * @param {number} max 最大并发 Browser 数 + */ + constructor(max) { + this.max = max; + this.active = 0; + this.waiters = []; + } + + /** @returns {{ max: number, active: number, queued: number }} */ + getStats() { + return { max: this.max, active: this.active, queued: this.waiters.length }; + } + + /** + * @param {string} taskName + * @param {number} [timeoutMs] + */ + async acquire(taskName, timeoutMs = QUEUE_TIMEOUT_MS) { + if (this.active < this.max) { + this.active++; + console.log(`[并发槽位] ${taskName} 立即获取 (${this.active}/${this.max})`); + return; + } + console.log(`[并发槽位] ${taskName} 排队等待 (队列 ${this.waiters.length + 1})`); + await new Promise((resolve, reject) => { + const entry = { + resolve: () => { + this.active++; + console.log(`[并发槽位] ${taskName} 出队执行 (${this.active}/${this.max})`); + resolve(); + }, + reject, + }; + if (timeoutMs > 0) { + entry.timer = setTimeout(() => { + const idx = this.waiters.indexOf(entry); + if (idx >= 0) { + this.waiters.splice(idx, 1); + reject(new Error('QUEUE_TIMEOUT')); + } + }, timeoutMs); + } + this.waiters.push(entry); + }); + } + + /** @param {string} taskName */ + release(taskName) { + this.active = Math.max(0, this.active - 1); + const next = this.waiters.shift(); + if (next) { + if (next.timer) clearTimeout(next.timer); + next.resolve(); + } else { + console.log(`[并发槽位] ${taskName} 释放 (${this.active}/${this.max})`); + } + } + + /** + * @template T + * @param {string} taskName + * @param {() => Promise} fn + * @returns {Promise} + */ + async run(taskName, fn) { + await this.acquire(taskName); + try { + return await fn(); + } finally { + this.release(taskName); + } + } +} + +module.exports = { BrowserConcurrencyLimiter }; diff --git a/patches/puppeteer-api/lib/constants.js b/patches/puppeteer-api/lib/constants.js new file mode 100755 index 0000000..a259a28 --- /dev/null +++ b/patches/puppeteer-api/lib/constants.js @@ -0,0 +1,140 @@ +/** + * puppeteer-api 共享常量与 Chrome 启动环境工具 + */ +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-extra'); + +/** 默认 Chrome 可执行文件路径(生产环境可通过环境变量覆盖) */ +const DEFAULT_CHROME_EXECUTABLE = '/www/wwwroot/puppeteer-api/.cache/puppeteer/chrome/linux-149.0.7827.22/chrome-linux64/chrome'; + +/** 默认 User-Agent,与 standard / real 双轨保持一致 */ +const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +/** Chrome 启动基础参数:多实例 headless 场景下复用 */ +const BASE_LAUNCH_ARGS = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--disable-extensions', + '--disable-background-networking', + '--disable-software-rasterizer', + '--no-first-run', + '--no-default-browser-check', + '--disable-breakpad', + '--disable-crash-reporter', +]; + +/** puppeteer-api 专用运行时目录(与 server.js 同级 .runtime) */ +const RUNTIME_DIR = path.join(__dirname, '..', '.runtime'); + +/** + * 确保 .runtime 子目录存在且 www 用户可写 + * @param {string} name + * @returns {string} + */ +function ensureRuntimeSubdir(name) { + const dir = path.join(RUNTIME_DIR, name); + fs.mkdirSync(dir, { recursive: true, mode: 0o777 }); + return dir; +} + +/** + * 解析 Chrome 可执行文件路径 + * @returns {string} + */ +function resolveChromeExecutable() { + const candidates = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + process.env.CHROME_EXECUTABLE, + DEFAULT_CHROME_EXECUTABLE, + ].filter(Boolean); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + try { + const bundled = puppeteer.executablePath(); + if (bundled && fs.existsSync(bundled)) { + return bundled; + } + } catch (_) { + /* puppeteer 未内置浏览器时忽略 */ + } + + return candidates[0] || DEFAULT_CHROME_EXECUTABLE; +} + +/** + * 生产 Linux 常注入无效 DBUS 地址;删除而非设为 /dev/null + * @returns {NodeJS.ProcessEnv} + */ +function buildBrowserLaunchEnv() { + const env = { ...process.env }; + delete env.DBUS_SESSION_BUS_ADDRESS; + delete env.DBUS_SYSTEM_BUS_ADDRESS; + + const xdgRuntime = ensureRuntimeSubdir('xdg-runtime'); + env.XDG_RUNTIME_DIR = xdgRuntime; + env.XDG_CONFIG_HOME = ensureRuntimeSubdir('xdg-config'); + env.XDG_CACHE_HOME = ensureRuntimeSubdir('xdg-cache'); + env.TMPDIR = ensureRuntimeSubdir('tmp'); + + return env; +} + +/** + * 校验 Chrome 可执行文件存在 + * @returns {string} + */ +function assertChromeExecutable() { + const executablePath = resolveChromeExecutable(); + if (!fs.existsSync(executablePath)) { + throw new Error( + `Chrome 可执行文件不存在: ${executablePath}。请在 puppeteer-api 目录执行: npx puppeteer browsers install chrome` + ); + } + return executablePath; +} + +/** 并发与超时配置(可通过环境变量覆盖) */ +const MAX_CONCURRENT_BROWSERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_BROWSERS || '4', 10)); +const MAX_CONCURRENT_BROWSERS_REAL = Math.max(1, parseInt(process.env.MAX_CONCURRENT_BROWSERS_REAL || '2', 10)); +const QUEUE_TIMEOUT_MS = Math.max(5000, parseInt(process.env.QUEUE_TIMEOUT_MS || '120000', 10)); +const API_INTERCEPT_TIMEOUT_MS = Math.max(10000, parseInt(process.env.API_INTERCEPT_TIMEOUT_MS || '30000', 10)); +const API_INTERCEPT_TIMEOUT_REAL_MS = Math.max( + API_INTERCEPT_TIMEOUT_MS, + parseInt(process.env.API_INTERCEPT_TIMEOUT_REAL_MS || '90000', 10) +); + +/** Captcha API 配置 */ +const CAPTCHA_PROVIDER = (process.env.CAPTCHA_PROVIDER || 'capsolver').toLowerCase(); +const CAPTCHA_API_KEY = process.env.CAPTCHA_API_KEY || ''; +const CAPTCHA_MAX_WAIT_MS = Math.max(30000, parseInt(process.env.CAPTCHA_MAX_WAIT_MS || '120000', 10)); + +/** 会话 Cookie 持久化 TTL(毫秒) */ +const SESSION_TTL_MS = Math.max(60000, parseInt(process.env.SESSION_TTL_MS || '1800000', 10)); + +module.exports = { + DEFAULT_CHROME_EXECUTABLE, + DEFAULT_UA, + BASE_LAUNCH_ARGS, + RUNTIME_DIR, + ensureRuntimeSubdir, + resolveChromeExecutable, + buildBrowserLaunchEnv, + assertChromeExecutable, + MAX_CONCURRENT_BROWSERS, + MAX_CONCURRENT_BROWSERS_REAL, + QUEUE_TIMEOUT_MS, + API_INTERCEPT_TIMEOUT_MS, + API_INTERCEPT_TIMEOUT_REAL_MS, + CAPTCHA_PROVIDER, + CAPTCHA_API_KEY, + CAPTCHA_MAX_WAIT_MS, + SESSION_TTL_MS, +}; diff --git a/patches/puppeteer-api/lib/launch-real-browser.js b/patches/puppeteer-api/lib/launch-real-browser.js new file mode 100755 index 0000000..ee8c4d3 --- /dev/null +++ b/patches/puppeteer-api/lib/launch-real-browser.js @@ -0,0 +1,70 @@ +/** + * Real Browser 启动:puppeteer-real-browser(抗 Cloudflare Turnstile) + * 注意:不与 puppeteer-extra 混用在同一 Browser 实例 + */ +const { + BASE_LAUNCH_ARGS, + resolveChromeExecutable, +} = require('./constants'); + +/** @type {boolean|null} */ +let realBrowserModuleChecked = null; + +/** @type {boolean} */ +let realBrowserAvailable = false; + +/** + * 检测 puppeteer-real-browser 是否已安装 + * @returns {boolean} + */ +function isRealBrowserAvailable() { + if (realBrowserModuleChecked !== null) { + return realBrowserAvailable; + } + try { + require.resolve('puppeteer-real-browser'); + realBrowserAvailable = true; + } catch (_) { + realBrowserAvailable = false; + } + realBrowserModuleChecked = true; + return realBrowserAvailable; +} + +/** + * 启动 real profile Browser(headless:false + Xvfb + turnstile 内置点击) + * @param {string[]} [extraArgs] + * @returns {Promise<{ browser: import('puppeteer').Browser, page: import('puppeteer').Page }>} + */ +async function launchRealBrowser(extraArgs = []) { + if (!isRealBrowserAvailable()) { + throw new Error( + 'puppeteer-real-browser 未安装。请在 puppeteer-api 目录执行: npm install puppeteer-real-browser@1.4.4' + ); + } + + const { connect } = require('puppeteer-real-browser'); + const chromePath = resolveChromeExecutable(); + + const result = await connect({ + headless: false, + turnstile: true, + disableXvfb: false, + customConfig: { chromePath }, + args: [...BASE_LAUNCH_ARGS, '--mute-audio', ...extraArgs], + }); + + const browser = result.browser; + const page = result.page; + + if (!browser || !page) { + throw new Error('puppeteer-real-browser connect 未返回 browser/page'); + } + + return { browser, page }; +} + +module.exports = { + isRealBrowserAvailable, + launchRealBrowser, +}; diff --git a/patches/puppeteer-api/lib/launch-standard.js b/patches/puppeteer-api/lib/launch-standard.js new file mode 100755 index 0000000..3ff0108 --- /dev/null +++ b/patches/puppeteer-api/lib/launch-standard.js @@ -0,0 +1,58 @@ +/** + * Standard Browser 启动:puppeteer-extra + StealthPlugin + */ +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-extra'); +const StealthPlugin = require('puppeteer-extra-plugin-stealth'); +const { + BASE_LAUNCH_ARGS, + assertChromeExecutable, + buildBrowserLaunchEnv, + ensureRuntimeSubdir, +} = require('./constants'); + +// Stealth 必须在 launch 之前注册 +puppeteer.use(StealthPlugin()); + +/** + * 启动 standard profile Browser + * @param {string[]} [extraArgs] + * @returns {Promise} + */ +async function launchStandardBrowser(extraArgs = []) { + const executablePath = assertChromeExecutable(); + const userDataDir = fs.mkdtempSync(path.join(ensureRuntimeSubdir('chrome-profiles'), 'run-')); + + try { + const browser = await puppeteer.launch({ + executablePath, + headless: 'new', + args: [...BASE_LAUNCH_ARGS, ...extraArgs], + env: buildBrowserLaunchEnv(), + userDataDir, + }); + const originalClose = browser.close.bind(browser); + browser.close = async () => { + try { + await originalClose(); + } finally { + try { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } catch (_) { + /* 清理失败不影响主流程 */ + } + } + }; + return browser; + } catch (err) { + try { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } catch (_) { + /* ignore */ + } + throw err; + } +} + +module.exports = { launchStandardBrowser }; diff --git a/patches/puppeteer-api/lib/session-store.js b/patches/puppeteer-api/lib/session-store.js new file mode 100755 index 0000000..8c303c5 --- /dev/null +++ b/patches/puppeteer-api/lib/session-store.js @@ -0,0 +1,121 @@ +/** + * 会话 Cookie 持久化:按 sessionKey 保存 cf_clearance 等,降低重复过盾概率 + */ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { RUNTIME_DIR, SESSION_TTL_MS } = require('./constants'); + +const SESSIONS_DIR = path.join(RUNTIME_DIR, 'sessions'); + +/** + * 确保 sessions 目录存在 + */ +function ensureSessionsDir() { + fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o777 }); +} + +/** + * sessionKey 转安全文件名 + * @param {string} sessionKey + * @returns {string} + */ +function keyToFilename(sessionKey) { + const hash = crypto.createHash('md5').update(sessionKey).digest('hex'); + return `${hash}.json`; +} + +/** + * @typedef {Object} StoredSession + * @property {string} sessionKey + * @property {number} savedAt + * @property {number} expiresAt + * @property {Array<{name:string,value:string,domain?:string,path?:string}>} cookies + */ + +/** + * 加载已保存的会话 cookies + * @param {string} sessionKey + * @returns {StoredSession|null} + */ +function loadSession(sessionKey) { + if (!sessionKey) return null; + ensureSessionsDir(); + const filePath = path.join(SESSIONS_DIR, keyToFilename(sessionKey)); + if (!fs.existsSync(filePath)) return null; + + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (!raw || !Array.isArray(raw.cookies)) return null; + if (raw.expiresAt && Date.now() > raw.expiresAt) { + fs.unlinkSync(filePath); + return null; + } + return raw; + } catch (_) { + return null; + } +} + +/** + * 保存会话 cookies(优先保留 CF 相关 cookie) + * @param {string} sessionKey + * @param {Array<{name:string,value:string,domain?:string,path?:string}>} cookies + * @param {number} [ttlMs] + */ +function saveSession(sessionKey, cookies, ttlMs = SESSION_TTL_MS) { + if (!sessionKey || !Array.isArray(cookies) || cookies.length === 0) return; + ensureSessionsDir(); + + const cfRelated = cookies.filter((c) => + ['cf_clearance', '__cf_bm', 'cf_chl_2'].includes(c.name) + || c.name.startsWith('__cf') + ); + + const toSave = cfRelated.length > 0 ? cfRelated : cookies; + const now = Date.now(); + const payload = { + sessionKey, + savedAt: now, + expiresAt: now + ttlMs, + cookies: toSave.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path || '/', + })), + }; + + const filePath = path.join(SESSIONS_DIR, keyToFilename(sessionKey)); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 0), { mode: 0o666 }); +} + +/** + * 将会话 cookies 转为 puppeteer setCookie 格式 + * @param {StoredSession|null} session + * @param {string} pageUrl + * @returns {Array<{name:string,value:string,domain?:string,path?:string,url?:string}>} + */ +function sessionCookiesForPage(session, pageUrl) { + if (!session || !Array.isArray(session.cookies)) return []; + let hostname = ''; + try { + hostname = new URL(pageUrl).hostname; + } catch (_) { + return session.cookies; + } + + return session.cookies.map((c) => { + const cookie = { ...c }; + if (!cookie.domain && hostname) { + cookie.url = `https://${hostname}/`; + } + return cookie; + }); +} + +module.exports = { + loadSession, + saveSession, + sessionCookiesForPage, +}; diff --git a/patches/puppeteer-api/lib/turnstile-handler.js b/patches/puppeteer-api/lib/turnstile-handler.js new file mode 100755 index 0000000..206e801 --- /dev/null +++ b/patches/puppeteer-api/lib/turnstile-handler.js @@ -0,0 +1,90 @@ +/** + * Turnstile 内置求解:等待 widget 自动完成 + token 轮询 + */ +const { detectCloudflare, isTurnstileSolved } = require('./cf-detector'); + +/** + * @typedef {Object} TurnstileResult + * @property {boolean} success + * @property {string} stage built_in|timeout|already_clear + * @property {number} elapsedMs + */ + +/** + * 等待 Turnstile 通过(内置点击 / 自动跳转) + * @param {import('puppeteer').Page} page + * @param {{ timeoutMs?: number, pollMs?: number, useBuiltInClick?: boolean }} [options] + * @returns {Promise} + */ +async function waitForTurnstile(page, options = {}) { + const timeoutMs = options.timeoutMs ?? 60000; + const pollMs = options.pollMs ?? 500; + const started = Date.now(); + + const initial = await detectCloudflare(page); + if (!initial.blocked || initial.hasCfClearance) { + return { success: true, stage: 'already_clear', elapsedMs: Date.now() - started }; + } + + // real browser 已开启 turnstile:true 内置点击;此处轮询等待结果 + while (Date.now() - started < timeoutMs) { + if (await isTurnstileSolved(page)) { + return { success: true, stage: 'built_in', elapsedMs: Date.now() - started }; + } + + const state = await detectCloudflare(page); + if (!state.blocked) { + return { success: true, stage: 'built_in', elapsedMs: Date.now() - started }; + } + + await new Promise((r) => setTimeout(r, pollMs)); + } + + return { success: false, stage: 'timeout', elapsedMs: Date.now() - started }; +} + +/** + * 从页面提取 Turnstile sitekey + * @param {import('puppeteer').Page} page + * @returns {Promise} + */ +async function extractTurnstileSitekey(page) { + return page.evaluate(() => { + const widget = document.querySelector('.cf-turnstile[data-sitekey], [data-sitekey]'); + if (widget) { + return widget.getAttribute('data-sitekey'); + } + const iframe = document.querySelector('iframe[src*="challenges.cloudflare.com"]'); + if (iframe && iframe.src) { + const match = iframe.src.match(/[?&]sitekey=([^&]+)/); + if (match) return decodeURIComponent(match[1]); + } + return null; + }).catch(() => null); +} + +/** + * 将 Captcha API 返回的 token 注入页面 + * @param {import('puppeteer').Page} page + * @param {string} token + */ +async function injectTurnstileToken(page, token) { + await page.evaluate((turnstileToken) => { + const input = document.querySelector('[name="cf-turnstile-response"]'); + if (input) { + input.value = turnstileToken; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + const form = document.querySelector('form'); + if (form) { + form.submit(); + } + }, token); +} + +module.exports = { + waitForTurnstile, + extractTurnstileSitekey, + injectTurnstileToken, +}; diff --git a/patches/puppeteer-api/package-lock.json b/patches/puppeteer-api/package-lock.json new file mode 100755 index 0000000..2746f1d --- /dev/null +++ b/patches/puppeteer-api/package-lock.json @@ -0,0 +1,3103 @@ +{ + "name": "puppeteer-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "puppeteer-api", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "express": "^4.18.2", + "puppeteer": "^25.1.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", + "puppeteer-real-browser": "1.4.4" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.0.4.tgz", + "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmmirror.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/bezier-js": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/@types/bezier-js/-/bezier-js-4.1.3.tgz", + "integrity": "sha512-FNVVCu5mx/rJCWBxLTcL7oOajmGtWtBTDjq6DSUWUI12GeePivrZZXz+UgE0D6VYsLEjvExRO03z4hVtu3pTEQ==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmmirror.com/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmmirror.com/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmmirror.com/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmmirror.com/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmmirror.com/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmmirror.com/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chrome-launcher": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/chrome-launcher/-/chrome-launcher-1.2.1.tgz", + "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz", + "integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/degenerator": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/degenerator/-/degenerator-7.0.1.tgz", + "integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "quickjs-wasi": "^2.2.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1624250", + "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", + "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==", + "license": "BSD-3-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmmirror.com/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmmirror.com/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/get-uri/-/get-uri-8.0.1.tgz", + "integrity": "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.3.1", + "data-uri-to-buffer": "8.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/get-uri/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/ghost-cursor": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/ghost-cursor/-/ghost-cursor-1.4.2.tgz", + "integrity": "sha512-NPMuH05Ik9h99zrAb4fvAzhB4T6MqKwdPoI+Me0IJWN3676j4UoAsjKN/cJq5AG4zyZwAEPPggZXHVU0P57/5g==", + "license": "ISC", + "dependencies": { + "@types/bezier-js": "4", + "bezier-js": "^6.1.3", + "debug": "^4.3.4" + } + }, + "node_modules/ghost-cursor/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ghost-cursor/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", + "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/pac-proxy-agent/-/pac-proxy-agent-9.1.0.tgz", + "integrity": "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "get-uri": "8.0.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "pac-resolver": "9.0.1", + "quickjs-wasi": "^2.2.0", + "socks-proxy-agent": "10.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/pac-resolver": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/pac-resolver/-/pac-resolver-9.0.1.tgz", + "integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "degenerator": "7.0.1", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "quickjs-wasi": "^2.2.0" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/proxy-agent/-/proxy-agent-8.0.2.tgz", + "integrity": "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "9.1.0", + "proxy-from-env": "^2.0.0", + "socks-proxy-agent": "10.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer": { + "version": "25.1.0", + "resolved": "https://registry.npmmirror.com/puppeteer/-/puppeteer-25.1.0.tgz", + "integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.1.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.1.0", + "resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.1.0.tgz", + "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-extra": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", + "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "@types/puppeteer": "*", + "puppeteer": "*", + "puppeteer-core": "*" + }, + "peerDependenciesMeta": { + "@types/puppeteer": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmmirror.com/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-real-browser": { + "version": "1.4.4", + "resolved": "https://registry.npmmirror.com/puppeteer-real-browser/-/puppeteer-real-browser-1.4.4.tgz", + "integrity": "sha512-1CYGlL1Y0SdxP55byi9WQ8dtLkyIYBmRpGr+D+cB6uZDrW17+ZxWMnc7CDZfNuNuJaF15DWW5zvChS/ikymdmg==", + "license": "ISC", + "dependencies": { + "chrome-launcher": "^1.1.2", + "ghost-cursor": "^1.3.0", + "puppeteer-extra": "^3.3.6", + "rebrowser-puppeteer-core": "^23.3.1", + "tree-kill": "^1.2.2", + "xvfb": "^0.4.0" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quickjs-wasi": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", + "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rebrowser-puppeteer-core": { + "version": "23.10.3", + "resolved": "https://registry.npmmirror.com/rebrowser-puppeteer-core/-/rebrowser-puppeteer-core-23.10.3.tgz", + "integrity": "sha512-oWwuFg3XoZUkAt6Te4zTU6sQeS39I9tctjdSEiDPa76MF47R0IfLX8VQhyRwwzMySqD5L1wambYcWyEAN0b9AA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.8.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1367902", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/@puppeteer/browsers": { + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-2.6.1.tgz", + "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/chromium-bidi": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-0.8.0.tgz", + "integrity": "sha512-uJydbGdTw0DEUjhoogGveneJVWX/9YuqkWePzMmkBYwtdAqo5d3J/ovNKFr+/2hWXYmYCr6it8mSSTIj6SS6Ug==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/devtools-protocol": { + "version": "0.0.1367902", + "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", + "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", + "license": "BSD-3-Clause" + }, + "node_modules/rebrowser-puppeteer-core/node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmmirror.com/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/rebrowser-puppeteer-core/node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rebrowser-puppeteer-core/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/rebrowser-puppeteer-core/node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sleep": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/sleep/-/sleep-6.1.0.tgz", + "integrity": "sha512-Z1x4JjJxsru75Tqn8F4tnOFeEu3HjtITTsumYUiuz54sGKdISgLCek9AUlXlVVrkhltRFhNUsJDJE76SFHTDIQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nan": "^2.13.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz", + "integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmmirror.com/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xvfb": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/xvfb/-/xvfb-0.4.0.tgz", + "integrity": "sha512-g55AbjcBL4Bztfn7kiUrR0ne8mMUsFODDJ+HFGf5OuHJqKKccpExX2Qgn7VF2eImw1eoh6+riXHser1J4agrFA==", + "license": "MIT", + "optionalDependencies": { + "sleep": "6.1.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/patches/puppeteer-api/package.json b/patches/puppeteer-api/package.json new file mode 100755 index 0000000..57e6396 --- /dev/null +++ b/patches/puppeteer-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "puppeteer-api", + "version": "1.0.0", + "description": "Puppeteer Microservice", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "puppeteer": "^25.1.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", + "puppeteer-real-browser": "1.4.4" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs" +} diff --git a/patches/puppeteer-api/server.js b/patches/puppeteer-api/server.js new file mode 100755 index 0000000..fa2df45 --- /dev/null +++ b/patches/puppeteer-api/server.js @@ -0,0 +1,887 @@ +const express = require('express'); +const fs = require('fs'); +const crypto = require('crypto'); +const { + rewriteHttpsToHttpForLiteralIp, + attachHttpIpRewriteInterceptor, +} = require('./http-ip-rewrite'); +const { + resolveHttpRedirects, + extractShareTokenFromUrl, +} = require('./url-resolve'); +const { + DEFAULT_UA, + RUNTIME_DIR, + resolveChromeExecutable, + MAX_CONCURRENT_BROWSERS, + MAX_CONCURRENT_BROWSERS_REAL, + API_INTERCEPT_TIMEOUT_MS, + API_INTERCEPT_TIMEOUT_REAL_MS, +} = require('./lib/constants'); +const { + normalizeAntiBot, + createBrowserSession, + runWithProfileLimit, + createManagedPage, + getFactoryStats, + standardLimiter, +} = require('./lib/browser-factory'); +const { launchStandardBrowser } = require('./lib/launch-standard'); +const { isRealBrowserAvailable } = require('./lib/launch-real-browser'); +const { handleCloudflareChallenge, isCaptchaConfigured } = require('./lib/cf-handler'); +const { loadSession, saveSession, sessionCookiesForPage } = require('./lib/session-store'); + +const app = express(); +app.use(express.json({ limit: '50mb' })); + +let shuttingDown = false; + +/** HTTP 层:槽位排队 + 队列超时/关闭中 的统一错误响应 */ +async function runBrowserTask(taskName, handler, profile = 'standard') { + if (shuttingDown) { + const err = new Error('SERVICE_SHUTTING_DOWN'); + err.code = 'SERVICE_SHUTTING_DOWN'; + throw err; + } + return runWithProfileLimit(taskName, profile, handler); +} + +function sendTaskError(res, error, extra = {}) { + if (error.code === 'QUEUE_TIMEOUT') { + return res.status(503).json({ + success: false, + error: '服务繁忙,排队超时,请稍后重试', + queue: standardLimiter.getStats(), + ...extra, + }); + } + if (error.code === 'SERVICE_SHUTTING_DOWN') { + return res.status(503).json({ success: false, error: '服务正在关闭', ...extra }); + } + const errType = classifyPuppeteerError(error); + console.error(`[${errType}]`, error.message); + return res.status(500).json({ success: false, error: error.message, ...extra }); +} + +function generateSafeHash(obj) { + if (!obj) return ''; + const clone = JSON.parse(JSON.stringify(obj)); + const removeDynamicKeys = (target) => { + if (typeof target !== 'object' || target === null) return; + ['timestamp', 'time', 'serverTime', 'reqId', 'requestId', 'traceId'].forEach((k) => delete target[k]); + Object.values(target).forEach((val) => removeDynamicKeys(val)); + }; + removeDynamicKeys(clone); + return crypto.createHash('md5').update(JSON.stringify(clone)).digest('hex'); +} + +async function safeCloseBrowser(browser) { + if (!browser) return; + try { + const pages = await browser.pages(); + await Promise.all(pages.map((p) => p.close().catch(() => {}))); + await browser.close(); + } catch (_) { + try { + const proc = browser.process(); + if (proc && !proc.killed) proc.kill('SIGKILL'); + } catch (_) { /* ignore */ } + } +} + +function classifyPuppeteerError(err) { + const msg = (err?.message || '').toLowerCase(); + if (err?.name === 'TimeoutError' || msg.includes('timeout')) return 'timeout'; + if (msg.includes('net::') || msg.includes('network') || msg.includes('err_connection') + || msg.includes('err_address_unreachable') || msg.includes('err_name_not_resolved')) return 'network'; + if (msg.includes('navigation') || msg.includes('frame was detached')) return 'navigation'; + if (msg.includes('target closed') || msg.includes('session closed')) return 'target_closed'; + return 'unknown'; +} + +function isRetryableError(err) { + const type = classifyPuppeteerError(err); + return type === 'timeout' || type === 'network' || type === 'navigation'; +} + +async function withExponentialBackoff(fn, { + maxRetries = 3, + baseDelayMs = 500, + maxDelayMs = 8000, + shouldRetry = isRetryableError, +} = {}) { + let lastError; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(attempt); + } catch (err) { + lastError = err; + if (attempt >= maxRetries || !shouldRetry(err)) throw err; + const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs); + await new Promise((r) => setTimeout(r, delay)); + } + } + throw lastError; +} + +async function waitForUrlSettled(page, { maxAttempts = 15, stableMs = 500, redirectSettleMs = 1500 } = {}) { + let currentUrl = page.url(); + let urlSettled = false; + let settleAttempts = 0; + + while (!urlSettled && settleAttempts < maxAttempts) { + const urlBeforeWait = currentUrl; + await page.waitForFunction( + (expectedUrl) => window.location.href === expectedUrl, + { timeout: stableMs, polling: 100 }, + urlBeforeWait + ).catch(() => {}); + + const newUrl = page.url(); + if (newUrl === currentUrl) { + urlSettled = true; + } else { + console.log(`[路由重定向探测] 页面从 ${currentUrl} 跳转到了 ${newUrl}`); + currentUrl = newUrl; + await page.waitForNetworkIdle({ idleTime: 300, timeout: redirectSettleMs }).catch(() => {}); + } + settleAttempts++; + } + return currentUrl; +} + +async function executeAuthActions(page, authActions) { + if (!Array.isArray(authActions)) return; + + for (const action of authActions) { + if (action.type === 'type') { + await page.waitForSelector(action.selector, { timeout: 15000 }); + await page.type(action.selector, action.value, { delay: 100 }); + } else if (action.type === 'click') { + await page.waitForSelector(action.selector, { timeout: 15000 }); + await page.evaluate((sel) => { const el = document.querySelector(sel); if (el) el.click(); }, action.selector); + } else if (action.type === 'press') { + await new Promise((r) => setTimeout(r, 200)); + await page.keyboard.press(action.key); + } else if (action.type === 'wait') { + await new Promise((r) => setTimeout(r, action.ms)); + } else if (action.type === 'vue_fill') { + await page.waitForSelector(action.selector, { timeout: 15000 }); + await page.waitForFunction( + (sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }, + { timeout: 2000, polling: 100 }, + action.selector + ).catch(() => new Promise((r) => setTimeout(r, 500))); + + await page.evaluate((sel, val) => { + const inputs = document.querySelectorAll(sel); + for (const input of inputs) { + if (input.getBoundingClientRect().width > 0 && input.getBoundingClientRect().height > 0) { + input.value = val; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + input.focus(); + return; + } + } + }, action.selector, action.value); + } else if (action.type === 'vue_click') { + await page.waitForSelector(action.selector, { timeout: 15000 }); + await page.waitForFunction( + (sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }, + { timeout: 2000, polling: 100 }, + action.selector + ).catch(() => new Promise((r) => setTimeout(r, 500))); + + await page.evaluate((sel, matchText) => { + const btns = document.querySelectorAll(sel); + for (const btn of btns) { + if (btn.getBoundingClientRect().width > 0 && btn.getBoundingClientRect().height > 0) { + let shouldClick = false; + if (matchText) { + const text = (btn.textContent || btn.innerText || '').trim(); + if (text.includes(matchText.trim())) { shouldClick = true; } + } else { + shouldClick = true; + } + if (shouldClick) { + btn.scrollIntoView({ behavior: 'instant', block: 'center' }); + const eventOpts = { bubbles: true, cancelable: true, view: window }; + btn.dispatchEvent(new MouseEvent('mousedown', eventOpts)); + btn.dispatchEvent(new MouseEvent('mouseup', eventOpts)); + btn.dispatchEvent(new MouseEvent('click', eventOpts)); + btn.click(); + return; + } + } + } + }, action.selector, action.text); + } + } +} + +function createApiInterceptor(page, apiUrls, defaultTimeoutMs = API_INTERCEPT_TIMEOUT_MS) { + const interceptedApis = {}; + let responseHandler = null; + let timeoutId = null; + let resolveWait = null; + let waitPromise = null; + + const tryResolveWait = () => { + if (!resolveWait || Object.keys(interceptedApis).length < apiUrls.length) { + return; + } + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (responseHandler) { + page.off('response', responseHandler); + responseHandler = null; + } + const done = resolveWait; + resolveWait = null; + done(); + }; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (responseHandler) { + page.off('response', responseHandler); + responseHandler = null; + } + if (resolveWait) { + const done = resolveWait; + resolveWait = null; + done(); + } + }; + + responseHandler = async (response) => { + const request = response.request(); + if (request.method() === 'OPTIONS') return; + const matchedPath = apiUrls.find((api) => request.url().includes(api) && !interceptedApis[api]); + if (matchedPath && response.status() >= 200 && response.status() < 300) { + try { + interceptedApis[matchedPath] = { + url: request.url(), + headers: request.headers(), + data: JSON.parse(await response.text()), + }; + tryResolveWait(); + } catch (_) { /* JSON 解析失败跳过 */ } + } + }; + page.on('response', responseHandler); + + const beginWaiting = (timeoutMs = defaultTimeoutMs) => { + if (waitPromise) { + return waitPromise; + } + if (Object.keys(interceptedApis).length >= apiUrls.length) { + waitPromise = Promise.resolve(); + return waitPromise; + } + waitPromise = new Promise((resolve) => { + resolveWait = resolve; + timeoutId = setTimeout(() => { + cleanup(); + resolve(); + }, timeoutMs); + }); + return waitPromise; + }; + + const waitForApis = async (timeoutMs = defaultTimeoutMs) => { + await beginWaiting(timeoutMs); + return waitPromise; + }; + + return { interceptedApis, beginWaiting, waitForApis, cleanup }; +} + +async function seedShareTokenCookie(page, pageUrl) { + try { + const parsed = new URL(pageUrl); + const token = parsed.searchParams.get('token'); + if (!token) return; + const cookieUrl = `http://${parsed.hostname}/`; + await page.setCookie({ name: 'share_token', value: token, url: cookieUrl }); + } catch (_) { /* ignore */ } +} + +function resolveShareToken(...urls) { + for (const url of urls) { + const token = extractShareTokenFromUrl(url); + if (token) return token; + } + return ''; +} + +function resolveInterceptTimeout(antiBot) { + const cfg = normalizeAntiBot(antiBot); + return cfg.enabled && cfg.profile === 'real' + ? API_INTERCEPT_TIMEOUT_REAL_MS + : API_INTERCEPT_TIMEOUT_MS; +} + +/** 并发监控:供上游调度器判断负载 */ +app.get('/api/stats', (_req, res) => { + const executablePath = resolveChromeExecutable(); + const factoryStats = getFactoryStats(); + res.json({ + success: true, + shuttingDown, + queue: factoryStats.standard, + queueReal: factoryStats.real, + realBrowserReady: factoryStats.realBrowserReady, + captchaConfigured: isCaptchaConfigured(), + chrome: { + executablePath, + exists: fs.existsSync(executablePath), + }, + config: { + maxConcurrentBrowsers: MAX_CONCURRENT_BROWSERS, + maxConcurrentBrowsersReal: MAX_CONCURRENT_BROWSERS_REAL, + apiInterceptTimeoutMs: API_INTERCEPT_TIMEOUT_MS, + apiInterceptTimeoutRealMs: API_INTERCEPT_TIMEOUT_REAL_MS, + }, + }); +}); + +/** 运维自检:实际尝试 launch 一次 Browser(profile=standard|real) */ +app.get('/api/browser-launch-test', async (req, res) => { + const profile = req.query.profile === 'real' ? 'real' : 'standard'; + let browser; + try { + await runBrowserTask(`browser-launch-test-${profile}`, async () => { + if (profile === 'real') { + const session = await createBrowserSession({ profile: 'real', extraArgs: ['--mute-audio'] }); + browser = session.browser; + const pages = await browser.pages(); + return { pageCount: pages.length, profile }; + } + browser = await launchStandardBrowser(['--mute-audio']); + const pages = await browser.pages(); + return { pageCount: pages.length, profile }; + }, profile); + res.json({ success: true, message: 'Browser 启动成功', profile }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message, + profile, + chrome: { + executablePath: resolveChromeExecutable(), + exists: fs.existsSync(resolveChromeExecutable()), + }, + realBrowserReady: isRealBrowserAvailable(), + runtimeDir: RUNTIME_DIR, + }); + } finally { + if (browser) { + try { + await browser.close(); + } catch (_) { /* ignore */ } + } + } +}); + +/** 接口一:初始化授权与首屏拦截 */ +app.post('/api/auth-and-intercept', async (req, res) => { + const { pageUrl, apiUrls, authActions, antiBot: rawAntiBot } = req.body; + if (!pageUrl || !Array.isArray(apiUrls)) { + return res.status(400).json({ success: false, error: '参数错误' }); + } + + const antiBot = normalizeAntiBot(rawAntiBot); + const profile = antiBot.profile || 'standard'; + const interceptTimeout = resolveInterceptTimeout(antiBot); + const cfLog = { profile, cfDetected: false, turnstileStage: null, solverUsed: false, elapsedMs: 0 }; + + try { + await runBrowserTask('auth-and-intercept', async () => { + let browser; + let sessionCleanup = null; + let interceptorCleanup = null; + const taskStarted = Date.now(); + + try { + const session = await createBrowserSession({ + profile, + extraArgs: ['--mute-audio'], + }); + browser = session.browser; + sessionCleanup = session.cleanup; + + const sessionData = antiBot.sessionKey ? loadSession(antiBot.sessionKey) : null; + const sessionCookies = sessionData + ? sessionCookiesForPage(sessionData, pageUrl) + : []; + + const page = await createManagedPage(browser, session.page, { + userAgent: DEFAULT_UA, + cookies: sessionCookies, + }); + + const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl); + if (httpResolvedUrl !== pageUrl) { + console.log(`[URL解析] HTTP 重定向: ${pageUrl} -> ${httpResolvedUrl}`); + } + + const interceptor = createApiInterceptor(page, apiUrls, interceptTimeout); + interceptorCleanup = interceptor.cleanup; + + let shareToken = resolveShareToken(pageUrl, httpResolvedUrl); + await seedShareTokenCookie(page, pageUrl); + if (httpResolvedUrl !== pageUrl) { + await seedShareTokenCookie(page, httpResolvedUrl); + } + await attachHttpIpRewriteInterceptor(page, 'auth-and-intercept', { shareToken }); + + await withExponentialBackoff( + () => page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }), + { maxRetries: 2, baseDelayMs: 1000 } + ); + + let finalPageUrl = await waitForUrlSettled(page); + if (finalPageUrl !== pageUrl) { + console.log(`[URL解析] 浏览器落地: ${pageUrl} -> ${finalPageUrl}`); + } + + if (antiBot.enabled) { + const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled); + cfLog.cfDetected = cfResult.cfDetected; + cfLog.turnstileStage = cfResult.stage; + cfLog.solverUsed = cfResult.solverUsed; + cfLog.elapsedMs = cfResult.elapsedMs; + + if (!cfResult.success) { + res.status(422).json({ + success: false, + code: cfResult.code || 'CF_TURNSTILE_FAILED', + challengeType: cfResult.challengeType || 'turnstile', + stage: cfResult.stage || 'timeout', + error: 'Cloudflare Turnstile 验证失败', + cf: cfLog, + }); + return; + } + + finalPageUrl = page.url(); + } + + const finalShareToken = resolveShareToken(finalPageUrl); + if (finalShareToken && finalShareToken !== shareToken) { + shareToken = finalShareToken; + await seedShareTokenCookie(page, finalPageUrl); + } + + await executeAuthActions(page, authActions); + await interceptor.waitForApis(interceptTimeout); + + const rawCookies = await page.cookies(); + const cookiePayload = rawCookies.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + })); + + if (antiBot.sessionKey) { + saveSession(antiBot.sessionKey, cookiePayload); + } + + res.json({ + success: true, + pageUrl, + finalPageUrl, + interceptedApis: interceptor.interceptedApis, + cookies: cookiePayload, + cf: cfLog, + profile, + elapsedMs: Date.now() - taskStarted, + }); + } finally { + if (interceptorCleanup) interceptorCleanup(); + if (sessionCleanup) { + await sessionCleanup(); + } else { + await safeCloseBrowser(browser); + } + } + }, profile); + } catch (error) { + if (!res.headersSent) sendTaskError(res, error, { cf: cfLog, profile }); + } +}); + +/** 接口二:脱离 UI 极速并发拉取 */ +app.post('/api/batch-fetch', async (req, res) => { + const { tasks, cookies, antiBot: rawAntiBot } = req.body; + if (!Array.isArray(tasks) || tasks.length === 0) { + return res.status(400).json({ success: false, error: 'tasks 参数错误' }); + } + + const antiBot = normalizeAntiBot(rawAntiBot); + const profile = antiBot.profile || 'standard'; + + try { + await runBrowserTask('batch-fetch', async () => { + let browser; + let sessionCleanup = null; + try { + const session = await createBrowserSession({ + profile, + extraArgs: ['--disable-web-security'], + }); + browser = session.browser; + sessionCleanup = session.cleanup; + + const page = await createManagedPage(browser, session.page, { cookies }); + const firstOrigin = new URL(tasks[0].fullUrl).origin; + + await page.setRequestInterception(true); + page.on('request', (reqObj) => { + if (reqObj.isInterceptResolutionHandled()) return; + const url = rewriteHttpsToHttpForLiteralIp(reqObj.url()); + const resourceType = reqObj.resourceType(); + if (['image', 'media', 'font'].includes(resourceType)) { + reqObj.abort(); + return; + } + if (url === firstOrigin || url === firstOrigin + '/') { + reqObj.respond({ status: 200, contentType: 'text/html', body: 'Context Ready' }); + } else { + reqObj.continue(); + } + }); + + await withExponentialBackoff( + () => page.goto(firstOrigin, { waitUntil: 'domcontentloaded', timeout: 30000 }), + { maxRetries: 2, baseDelayMs: 800 } + ); + + const FETCH_CONCURRENCY = Math.max(1, parseInt(process.env.FETCH_CONCURRENCY || '5', 10)); + const FETCH_RETRY = 2; + + const fetchResults = await page.evaluate(async (taskList, concurrency, maxRetry) => { + const out = {}; + const createLimiter = (limit) => { + let active = 0; + const queue = []; + return (fn) => new Promise((resolve, reject) => { + const run = async () => { + active++; + try { resolve(await fn()); } + catch (e) { reject(e); } + finally { + active--; + if (queue.length) queue.shift()(); + } + }; + if (active < limit) run(); + else queue.push(run); + }); + }; + const limit = createLimiter(concurrency); + + const fetchWithRetry = async (finalUrl, options, retriesLeft) => { + try { + const res = await fetch(finalUrl, options); + return { success: true, data: await res.json() }; + } catch (err) { + if (retriesLeft <= 0) return { success: false, error: err.toString() }; + await new Promise((r) => setTimeout(r, 300 * Math.pow(2, maxRetry - retriesLeft))); + return fetchWithRetry(finalUrl, options, retriesLeft - 1); + } + }; + + const jobs = []; + for (const task of taskList) { + out[task.apiPath] = []; + const cleanHeaders = { ...task.headers }; + ['content-length', 'host', 'origin', 'referer', 'accept-encoding'].forEach((k) => delete cleanHeaders[k]); + + for (const params of task.paramList) { + jobs.push(limit(async () => { + let finalUrl = task.fullUrl; + const options = { method: task.method.toUpperCase(), headers: cleanHeaders }; + if (options.method === 'GET') { + const qs = new URLSearchParams(params).toString(); + finalUrl = finalUrl.includes('?') ? `${finalUrl}&${qs}` : `${finalUrl}?${qs}`; + } else { + options.body = JSON.stringify(params); + } + const result = await fetchWithRetry(finalUrl, options, maxRetry); + out[task.apiPath].push({ params, ...result }); + await new Promise((r) => setTimeout(r, 200)); + })); + } + } + await Promise.all(jobs); + return out; + }, tasks, FETCH_CONCURRENCY, FETCH_RETRY); + + res.json({ success: true, results: fetchResults, profile }); + } finally { + if (sessionCleanup) { + await sessionCleanup(); + } else { + await safeCloseBrowser(browser); + } + } + }, profile); + } catch (error) { + if (!res.headersSent) sendTaskError(res, error, { profile }); + } +}); + +/** 接口三:强制 UI 模拟点击拉取 */ +app.post('/api/ui-pagination', async (req, res) => { + const { + pageUrl, + finalPageUrl, + apiUrl, + cookies, + nextBtnSelector, + clicksToPerform, + waitMs = 2000, + firstPageData, + authActions, + antiBot: rawAntiBot, + } = req.body; + const navigationUrl = finalPageUrl || pageUrl; + const antiBot = normalizeAntiBot(rawAntiBot); + const profile = antiBot.profile || 'standard'; + + try { + await runBrowserTask('ui-pagination', async () => { + let browser; + let sessionCleanup = null; + const capturedData = []; + const debugLogs = []; + const log = (msg) => { console.log(msg); debugLogs.push(msg); }; + const seenHashes = new Set(); + if (firstPageData) { seenHashes.add(generateSafeHash(firstPageData)); } + + try { + const session = await createBrowserSession({ + profile, + extraArgs: ['--window-size=1920,1080'], + }); + browser = session.browser; + sessionCleanup = session.cleanup; + + const page = await createManagedPage(browser, session.page, { + viewport: { width: 1920, height: 1080 }, + cookies, + }); + + log(`[启动] 准备访问页面: ${navigationUrl}`); + if (finalPageUrl && finalPageUrl !== pageUrl) { + log(`[URL解析] 使用 auth 阶段落地地址,跳过短链重定向: ${pageUrl} -> ${finalPageUrl}`); + } + + const shareToken = resolveShareToken(finalPageUrl, pageUrl); + await seedShareTokenCookie(page, navigationUrl); + await attachHttpIpRewriteInterceptor(page, 'ui-pagination', { shareToken }); + + await withExponentialBackoff( + () => page.goto(navigationUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }), + { maxRetries: 2, baseDelayMs: 1000 } + ); + + const settledPageUrl = await waitForUrlSettled(page); + if (settledPageUrl !== navigationUrl) { + log(`[URL解析] 翻页阶段落地: ${navigationUrl} -> ${settledPageUrl}`); + } + + if (antiBot.enabled) { + const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled); + if (!cfResult.success) { + res.status(422).json({ + success: false, + code: cfResult.code || 'CF_TURNSTILE_FAILED', + challengeType: cfResult.challengeType, + stage: cfResult.stage, + error: 'Cloudflare Turnstile 验证失败(翻页阶段)', + debug: debugLogs, + }); + return; + } + } + + if (Array.isArray(authActions) && authActions.length > 0) { + log('[状态同步] 正在为翻页引擎注入授权状态...'); + await executeAuthActions(page, authActions); + log('[状态同步] 授权执行完毕,等待目标列表加载...'); + const authSettled = await page.waitForResponse( + (resp) => resp.url().includes(apiUrl) && resp.request().method() !== 'OPTIONS' && resp.status() >= 200 && resp.status() < 400, + { timeout: 5000 } + ).catch(() => null); + if (!authSettled) { + log('[状态同步] 未捕获到目标 API 响应,降级为固定等待 3 秒...'); + await new Promise((r) => setTimeout(r, 3000)); + } + } + + for (let i = 0; i < clicksToPerform; i++) { + const targetPageNum = i + 2; + let pageData = null; + let attempts = 0; + const maxAttempts = 3; + + await page.evaluate(() => { + document.querySelectorAll('.el-loading-mask, .ant-spin-nested-loading, .el-loading-spinner').forEach((el) => el.remove()); + }); + + await page.waitForFunction( + () => !document.querySelector('.el-loading-mask, .ant-spin-nested-loading, .el-loading-spinner'), + { timeout: 1000, polling: 100 } + ).catch(() => new Promise((r) => setTimeout(r, 500))); + + while (attempts < maxAttempts && !pageData) { + attempts++; + log(`[抓取] 准备抓取第 ${targetPageNum} 页 (尝试 ${attempts}/${maxAttempts})...`); + + let responseHandler = null; + let apiTimeoutId = null; + + const waitForNewPageApi = new Promise((resolve) => { + const cleanupListener = () => { + if (apiTimeoutId) { clearTimeout(apiTimeoutId); apiTimeoutId = null; } + if (responseHandler) { page.off('response', responseHandler); responseHandler = null; } + }; + + responseHandler = async (response) => { + const status = response.status(); + if (response.url().includes(apiUrl) && response.request().method() !== 'OPTIONS' && status >= 200 && status < 400) { + try { + const responseData = await response.json(); + const currentHash = generateSafeHash(responseData); + if (!seenHashes.has(currentHash)) { + seenHashes.add(currentHash); + cleanupListener(); + resolve(responseData); + } else { + log('[防轮询] 抛弃重复数据,继续监听...'); + } + } catch (_) { /* 非 JSON 响应跳过 */ } + } + }; + page.on('response', responseHandler); + apiTimeoutId = setTimeout(() => { cleanupListener(); resolve(null); }, 15000); + }); + + try { + await page.waitForSelector(nextBtnSelector, { timeout: 15000 }); + + const clickStatus = await page.evaluate((sel) => { + const btn = document.querySelector(sel); + if (!btn) return 'not_found'; + if (btn.disabled || btn.classList.contains('is-disabled') || btn.classList.contains('disabled') || btn.getAttribute('aria-disabled') === 'true') { + return 'disabled'; + } + const className = btn.className || ''; + if (btn.disabled === true || className.includes('disabled') || btn.getAttribute('aria-disabled') === 'true') { + return 'disabled'; + } + btn.scrollIntoView({ behavior: 'instant', block: 'center' }); + btn.click(); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + return 'success'; + }, nextBtnSelector); + + if (clickStatus === 'disabled') { + log(`[提示] 第 ${targetPageNum} 页按钮已被禁用,说明到底了,抓取提前结束。`); + break; + } + log('[动作] 双引擎原生点击已执行!'); + } catch (clickErr) { + log(`[错误] 寻找按钮异常 (${classifyPuppeteerError(clickErr)}): ${clickErr.message}`); + } + + pageData = await waitForNewPageApi; + if (!pageData && attempts < maxAttempts) { + log('[重试警报] 雷达未扫描到新数据,准备重试...'); + await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempts - 1))); + } + } + + if (pageData) { + capturedData.push(pageData); + log(`[成功] 斩获第 ${targetPageNum} 页数据!`); + await new Promise((r) => setTimeout(r, waitMs)); + } else { + log(`[致命异常] 连续 ${maxAttempts} 次尝试均失败,放弃后续翻页。`); + break; + } + } + + res.json({ success: true, data: capturedData, debug: debugLogs, profile }); + } catch (error) { + if (!res.headersSent) { + sendTaskError(res, error, { debug: debugLogs, profile }); + } + } finally { + if (sessionCleanup) { + await sessionCleanup(); + } else { + await safeCloseBrowser(browser); + } + } + }, profile); + } catch (error) { + if (!res.headersSent) sendTaskError(res, error, { profile }); + } +}); + +const PORT = parseInt(process.env.PORT || '3001', 10); +const server = app.listen(PORT, '127.0.0.1', () => { + const chromePath = resolveChromeExecutable(); + const chromeReady = fs.existsSync(chromePath); + console.log(`🚀 Node.js 自愈型防脱轨引擎已启动 (standard: ${MAX_CONCURRENT_BROWSERS}, real: ${MAX_CONCURRENT_BROWSERS_REAL})`); + console.log(`[chrome] path=${chromePath} exists=${chromeReady}`); + console.log(`[real-browser] ready=${isRealBrowserAvailable()} captcha=${isCaptchaConfigured()}`); + if (!chromeReady) { + console.warn('[chrome] 未找到 Chrome,请在 puppeteer-api 目录执行: npx puppeteer browsers install chrome'); + } +}); + +async function gracefulShutdown(signal) { + console.log(`[shutdown] 收到 ${signal},停止接收新任务...`); + shuttingDown = true; + server.close(); + + const deadline = Date.now() + 60000; + while (standardLimiter.active > 0 && Date.now() < deadline) { + const { active, queued } = standardLimiter.getStats(); + console.log(`[shutdown] 等待在途任务完成... active=${active}, queued=${queued}`); + await new Promise((r) => setTimeout(r, 1000)); + } + + const remaining = standardLimiter.getStats(); + if (remaining.active > 0) { + console.warn(`[shutdown] 超时,仍有 ${remaining.active} 个 Browser 在运行,强制退出`); + process.exit(1); + } + console.log('[shutdown] 所有任务已完成,进程退出'); + process.exit(0); +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); diff --git a/patches/puppeteer-api/url-resolve.js b/patches/puppeteer-api/url-resolve.js new file mode 100755 index 0000000..1b749f6 --- /dev/null +++ b/patches/puppeteer-api/url-resolve.js @@ -0,0 +1,73 @@ +/** + * URL 重定向解析:在浏览器导航前尽可能解析 HTTP 级重定向链。 + */ +'use strict'; + +const http = require('http'); +const https = require('https'); + +const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]); + +/** + * 发起单次 HEAD/GET,不自动跟随重定向。 + */ +function fetchOnce(method, url, timeoutMs) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const lib = parsed.protocol === 'https:' ? https : http; + const req = lib.request( + url, + { method, timeout: timeoutMs, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; puppeteer-api/1.0)' } }, + (res) => { + res.resume(); + resolve({ statusCode: res.statusCode || 0, headers: res.headers || {} }); + } + ); + req.on('timeout', () => { + req.destroy(); + reject(new Error('REDIRECT_TIMEOUT')); + }); + req.on('error', reject); + req.end(); + }); +} + +/** + * 跟随 HTTP 重定向,返回最终 URL(无法解析时返回原始 URL)。 + */ +async function resolveHttpRedirects(startUrl, { maxRedirects = 10, timeoutMs = 12000 } = {}) { + let current = startUrl; + for (let i = 0; i < maxRedirects; i++) { + let response; + try { + response = await fetchOnce('HEAD', current, timeoutMs); + } catch (_) { + response = await fetchOnce('GET', current, timeoutMs); + } + if (!REDIRECT_STATUS.has(response.statusCode)) { + return current; + } + const location = response.headers.location; + if (!location) { + return current; + } + current = new URL(location, current).href; + } + return current; +} + +/** + * 从分享页 URL 提取 token 参数(星河等云控)。 + */ +function extractShareTokenFromUrl(pageUrl) { + try { + return new URL(pageUrl).searchParams.get('token') || ''; + } catch (_) { + return ''; + } +} + +module.exports = { + resolveHttpRedirects, + extractShareTokenFromUrl, +}; diff --git a/patches/runtime/split_rr/7.cnt b/patches/runtime/split_rr/7.cnt old mode 100644 new mode 100755 diff --git a/patches/runtime/split_rr/999001.cnt b/patches/runtime/split_rr/999001.cnt old mode 100644 new mode 100755 diff --git a/patches/test_write.txt b/patches/test_write.txt old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/CHANGELOG.md b/patches/third_party/geoip2/geoip2/CHANGELOG.md old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/LICENSE b/patches/third_party/geoip2/geoip2/LICENSE old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/README.md b/patches/third_party/geoip2/geoip2/README.md old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/composer.json b/patches/third_party/geoip2/geoip2/composer.json old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/examples/benchmark.php b/patches/third_party/geoip2/geoip2/examples/benchmark.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Database/Reader.php b/patches/third_party/geoip2/geoip2/src/Database/Reader.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Exception/AddressNotFoundException.php b/patches/third_party/geoip2/geoip2/src/Exception/AddressNotFoundException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Exception/AuthenticationException.php b/patches/third_party/geoip2/geoip2/src/Exception/AuthenticationException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Exception/GeoIp2Exception.php b/patches/third_party/geoip2/geoip2/src/Exception/GeoIp2Exception.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Exception/HttpException.php b/patches/third_party/geoip2/geoip2/src/Exception/HttpException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Exception/InvalidRequestException.php b/patches/third_party/geoip2/geoip2/src/Exception/InvalidRequestException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Exception/OutOfQueriesException.php b/patches/third_party/geoip2/geoip2/src/Exception/OutOfQueriesException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/AbstractModel.php b/patches/third_party/geoip2/geoip2/src/Model/AbstractModel.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/AnonymousIp.php b/patches/third_party/geoip2/geoip2/src/Model/AnonymousIp.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/Asn.php b/patches/third_party/geoip2/geoip2/src/Model/Asn.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/City.php b/patches/third_party/geoip2/geoip2/src/Model/City.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/ConnectionType.php b/patches/third_party/geoip2/geoip2/src/Model/ConnectionType.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/Country.php b/patches/third_party/geoip2/geoip2/src/Model/Country.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/Domain.php b/patches/third_party/geoip2/geoip2/src/Model/Domain.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/Enterprise.php b/patches/third_party/geoip2/geoip2/src/Model/Enterprise.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/Insights.php b/patches/third_party/geoip2/geoip2/src/Model/Insights.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Model/Isp.php b/patches/third_party/geoip2/geoip2/src/Model/Isp.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/ProviderInterface.php b/patches/third_party/geoip2/geoip2/src/ProviderInterface.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/AbstractPlaceRecord.php b/patches/third_party/geoip2/geoip2/src/Record/AbstractPlaceRecord.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/AbstractRecord.php b/patches/third_party/geoip2/geoip2/src/Record/AbstractRecord.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/City.php b/patches/third_party/geoip2/geoip2/src/Record/City.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/Continent.php b/patches/third_party/geoip2/geoip2/src/Record/Continent.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/Country.php b/patches/third_party/geoip2/geoip2/src/Record/Country.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/Location.php b/patches/third_party/geoip2/geoip2/src/Record/Location.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/MaxMind.php b/patches/third_party/geoip2/geoip2/src/Record/MaxMind.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/Postal.php b/patches/third_party/geoip2/geoip2/src/Record/Postal.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/RepresentedCountry.php b/patches/third_party/geoip2/geoip2/src/Record/RepresentedCountry.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/Subdivision.php b/patches/third_party/geoip2/geoip2/src/Record/Subdivision.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Record/Traits.php b/patches/third_party/geoip2/geoip2/src/Record/Traits.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/Util.php b/patches/third_party/geoip2/geoip2/src/Util.php old mode 100644 new mode 100755 diff --git a/patches/third_party/geoip2/geoip2/src/WebService/Client.php b/patches/third_party/geoip2/geoip2/src/WebService/Client.php old mode 100644 new mode 100755 diff --git a/patches/third_party/load_geoip2.php b/patches/third_party/load_geoip2.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/CHANGELOG.md b/patches/third_party/maxmind-db/reader/CHANGELOG.md old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/LICENSE b/patches/third_party/maxmind-db/reader/LICENSE old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/README.md b/patches/third_party/maxmind-db/reader/README.md old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/autoload.php b/patches/third_party/maxmind-db/reader/autoload.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/composer.json b/patches/third_party/maxmind-db/reader/composer.json old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/ext/config.m4 b/patches/third_party/maxmind-db/reader/ext/config.m4 old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/ext/config.w32 b/patches/third_party/maxmind-db/reader/ext/config.w32 old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/ext/maxminddb.c b/patches/third_party/maxmind-db/reader/ext/maxminddb.c old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/ext/php_maxminddb.h b/patches/third_party/maxmind-db/reader/ext/php_maxminddb.h old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/ext/tests/001-load.phpt b/patches/third_party/maxmind-db/reader/ext/tests/001-load.phpt old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/ext/tests/002-final.phpt b/patches/third_party/maxmind-db/reader/ext/tests/002-final.phpt old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/ext/tests/003-open-basedir.phpt b/patches/third_party/maxmind-db/reader/ext/tests/003-open-basedir.phpt old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/package.xml b/patches/third_party/maxmind-db/reader/package.xml old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Decoder.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/InvalidDatabaseException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Metadata.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Metadata.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php b/patches/third_party/maxmind-db/reader/src/MaxMind/Db/Reader/Util.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/CHANGELOG.md b/patches/third_party/maxmind/web-service-common/CHANGELOG.md old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/LICENSE b/patches/third_party/maxmind/web-service-common/LICENSE old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/README.md b/patches/third_party/maxmind/web-service-common/README.md old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/composer.json b/patches/third_party/maxmind/web-service-common/composer.json old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/phpstan.neon b/patches/third_party/maxmind/web-service-common/phpstan.neon old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/AuthenticationException.php b/patches/third_party/maxmind/web-service-common/src/Exception/AuthenticationException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/HttpException.php b/patches/third_party/maxmind/web-service-common/src/Exception/HttpException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/InsufficientFundsException.php b/patches/third_party/maxmind/web-service-common/src/Exception/InsufficientFundsException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/InvalidInputException.php b/patches/third_party/maxmind/web-service-common/src/Exception/InvalidInputException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/InvalidRequestException.php b/patches/third_party/maxmind/web-service-common/src/Exception/InvalidRequestException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/IpAddressNotFoundException.php b/patches/third_party/maxmind/web-service-common/src/Exception/IpAddressNotFoundException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/PermissionRequiredException.php b/patches/third_party/maxmind/web-service-common/src/Exception/PermissionRequiredException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/Exception/WebServiceException.php b/patches/third_party/maxmind/web-service-common/src/Exception/WebServiceException.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/WebService/Client.php b/patches/third_party/maxmind/web-service-common/src/WebService/Client.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/WebService/Http/CurlRequest.php b/patches/third_party/maxmind/web-service-common/src/WebService/Http/CurlRequest.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/WebService/Http/Request.php b/patches/third_party/maxmind/web-service-common/src/WebService/Http/Request.php old mode 100644 new mode 100755 diff --git a/patches/third_party/maxmind/web-service-common/src/WebService/Http/RequestFactory.php b/patches/third_party/maxmind/web-service-common/src/WebService/Http/RequestFactory.php old mode 100644 new mode 100755 diff --git a/public/assets/js/backend/split/link.js b/public/assets/js/backend/split/link.js old mode 100644 new mode 100755 index c8e78ff..7c1257e --- a/public/assets/js/backend/split/link.js +++ b/public/assets/js/backend/split/link.js @@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); var table = $("#table"); + var searchSelect = Controller.api.searchSelectColumn; + var searchSelectMeta = Controller.api.searchSelectMeta; table.bootstrapTable({ url: $.fn.bootstrapTable.defaults.extend.index_url, @@ -22,14 +24,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin sortOrder: 'desc', fixedColumns: true, fixedRightNumber: 1, + searchFormVisible: true, columns: [ [ {checkbox: true}, + searchSelect('id', __('Link_code'), Config.linkFilterList || {}, true), {field: 'countries_text', title: __('Countries'), operate: false, formatter: Table.api.formatter.content}, { field: 'link_code', title: __('Link_code'), - operate: 'LIKE', + operate: false, formatter: Controller.api.formatter.linkCode }, {field: 'description', title: __('Description'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content}, @@ -39,9 +43,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin operate: false, formatter: Controller.api.formatter.autoReplyText }, - {field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status}, - {field: 'random_shuffle', title: __('Random_shuffle'), searchList: Config.randomShuffleList, formatter: Table.api.formatter.status}, - {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.status}, + $.extend( + {field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status}, + searchSelectMeta(false) + ), + $.extend( + {field: 'random_shuffle', title: __('Random_shuffle'), searchList: Config.randomShuffleList, formatter: Table.api.formatter.status}, + searchSelectMeta(false) + ), + $.extend( + {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.status}, + searchSelectMeta(false) + ), {field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true}, { field: 'operate', @@ -117,6 +130,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindAutoReplyPreviewTips(table); + table.on('post-common-search.bs.table', function (e, tbl) { + Controller.api.initCommonSearchSelectpicker(tbl); + }); + Table.api.bindevent(table); }, add: function () { @@ -127,6 +144,40 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + searchSelectColumn: function (field, title, searchList, liveSearch) { + return { + field: field, + title: title, + visible: false, + searchList: searchList || {}, + operate: '=', + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectMeta: function (liveSearch) { + return { + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectExtend: function (liveSearch) { + var text = __('Please select'); + if (!text || text === 'Please select' || text === 'Please Select') { + text = '请选择'; + } + var ext = 'data-none-selected-text="' + text + '" title="' + text + '"'; + if (liveSearch) { + ext += ' data-live-search="true"'; + } + return ext; + }, + initCommonSearchSelectpicker: function (tbl) { + var $form = $('form.form-commonsearch', tbl.$container); + if ($form.length) { + Form.events.selectpicker($form); + } + }, /** * 规范化后台跨模块跳转 URL,避免在 split.link 页面内相对解析成 split.link/domain * diff --git a/public/assets/js/backend/split/number.js b/public/assets/js/backend/split/number.js old mode 100644 new mode 100755 index 55a942e..c60647a --- a/public/assets/js/backend/split/number.js +++ b/public/assets/js/backend/split/number.js @@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); var table = $("#table"); + var searchSelect = Controller.api.searchSelectColumn; + var searchSelectMeta = Controller.api.searchSelectMeta; table.bootstrapTable({ url: $.fn.bootstrapTable.defaults.extend.index_url, @@ -22,9 +24,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin sortOrder: 'desc', fixedColumns: true, fixedRightNumber: 1, + searchFormVisible: true, columns: [ [ {checkbox: true}, + searchSelect('split_link_id', __('Link_url'), Config.splitLinkFilterList || {}, true), {field: 'id', title: __('Id'), sortable: true}, { field: 'link_url_text', @@ -34,28 +38,20 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, {field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'}, {field: 'number', title: __('Number'), operate: 'LIKE'}, - { - field: 'number_type', - title: __('Number_type'), - searchList: Config.numberTypeList, - formatter: Table.api.formatter.normal - }, + $.extend( + {field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal}, + searchSelectMeta(false) + ), {field: 'visit_count', title: __('Visit_count'), operate: false, sortable: true}, {field: 'inbound_count', title: __('Inbound_count'), operate: false, sortable: true}, - { - field: 'platform_status', - title: __('Platform_status'), - searchList: Config.platformStatusList, - formatter: Controller.api.formatter.platformStatus - }, - { - field: 'status', - title: __('Status'), - searchList: Config.statusList, - formatter: Table.api.formatter.toggle, - yes: 'normal', - no: 'hidden' - }, + $.extend( + {field: 'platform_status', title: __('Platform_status'), searchList: Config.platformStatusList, formatter: Controller.api.formatter.platformStatus}, + searchSelectMeta(false) + ), + $.extend( + {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'}, + searchSelectMeta(false) + ), { field: 'createtime', title: __('Createtime'), @@ -95,6 +91,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.openBatchUpdateModal(ids, table); }); + $('.btn-batch-operate').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + Controller.api.openBatchOperateModal(table); + }); + + table.on('post-common-search.bs.table', function (e, tbl) { + Controller.api.initCommonSearchSelectpicker(tbl); + }); + Table.api.bindevent(table); }, add: function () { @@ -104,6 +110,41 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + searchSelectColumn: function (field, title, searchList, liveSearch) { + return { + field: field, + title: title, + visible: false, + searchList: searchList || {}, + operate: '=', + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectMeta: function (liveSearch) { + return { + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectExtend: function (liveSearch) { + var text = __('Please select'); + if (!text || text === 'Please select' || text === 'Please Select') { + text = '请选择'; + } + var ext = 'data-none-selected-text="' + text + '" title="' + text + '"'; + if (liveSearch) { + ext += ' data-live-search="true"'; + } + return ext; + }, + initCommonSearchSelectpicker: function (tbl) { + var $form = $('form.form-commonsearch', tbl.$container); + if ($form.length) { + Form.events.selectpicker($form); + } + }, + formatter: { platformStatus: function (value, row) { var text = row.platform_status_text != null && row.platform_status_text !== '' @@ -214,6 +255,101 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin return false; } }); + }, + openBatchOperateModal: function (table) { + var pleaseSelect = __('Please select'); + if (!pleaseSelect || pleaseSelect === 'Please select' || pleaseSelect === 'Please Select') { + pleaseSelect = '请选择'; + } + var linkOptions = ['']; + $.each(Config.splitLinkSelectList || {}, function (id, label) { + linkOptions.push(''); + }); + var html = [ + '
', + '
', + ' ', + ' ', + '
', + '
', + ' ', + ' ', + '
', + '
', + ' ', + ' ', + '
', + '
', + ' ', + ' ', + '
', + '
' + ].join(''); + + var layerIndex = Layer.open({ + type: 1, + title: __('Batch operate title'), + area: ['520px', 'auto'], + shadeClose: false, + content: html, + success: function (layero, index) { + Form.events.selectpicker(layero); + layero.find('.btn-batch-operate-cancel').on('click', function () { + Layer.close(index); + }); + layero.find('.btn-batch-operate-confirm').on('click', function () { + var splitLinkId = $.trim(layero.find('#batch-operate-split-link').val()); + var numbers = $.trim(layero.find('#batch-operate-numbers').val()); + var action = $.trim(layero.find('#batch-operate-action').val()); + + if (!splitLinkId) { + Toastr.warning(__('Please select split link')); + return false; + } + if (!numbers) { + Toastr.warning(__('Please fill at least one number')); + return false; + } + if (!action) { + Toastr.error(__('Invalid batch operate action')); + return false; + } + + var submit = function () { + Fast.api.ajax({ + url: 'split.number/batchoperate', + type: 'post', + data: { + split_link_id: splitLinkId, + numbers: numbers, + action: action + } + }, function (data, ret) { + Layer.close(index); + table.bootstrapTable('refresh'); + Toastr.success(ret.msg || __('Batch operate success')); + }); + }; + + if (action === 'delete') { + Layer.confirm(__('Batch operate delete confirm'), {icon: 3, title: __('Warning')}, function (confirmIndex) { + Layer.close(confirmIndex); + submit(); + }); + return false; + } + + submit(); + return false; + }); + } + }); } } }; diff --git a/public/assets/js/backend/split/ticket.js b/public/assets/js/backend/split/ticket.js old mode 100644 new mode 100755 index 01bb5fa..77598d8 --- a/public/assets/js/backend/split/ticket.js +++ b/public/assets/js/backend/split/ticket.js @@ -14,6 +14,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }); var table = $("#table"); + var searchSelect = Controller.api.searchSelectColumn; + var searchSelectMeta = Controller.api.searchSelectMeta; table.bootstrapTable({ url: $.fn.bootstrapTable.defaults.extend.index_url, @@ -22,9 +24,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin sortOrder: 'desc', fixedColumns: true, fixedRightNumber: 1, + searchFormVisible: true, columns: [ [ {checkbox: true}, + searchSelect('split_link_id', __('Split_link_id'), Config.splitLinkFilterList || {}, true), { field: 'ticket_type', title: __('Ticket_type'), @@ -39,6 +43,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin operate: false, formatter: Controller.api.formatter.splitLinkCode }, + { + field: 'ticket_url', + title: __('Ticket_url'), + operate: 'LIKE', + formatter: Controller.api.formatter.ticketUrlLink + }, { field: 'start_time_text', title: __('Start_time'), @@ -89,14 +99,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin operate: false, formatter: Controller.api.formatter.syncDisplay }, - { - field: 'status', - title: __('Status'), - searchList: Config.statusList, - formatter: Table.api.formatter.toggle, - yes: 'normal', - no: 'hidden' - }, + $.extend( + {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'}, + searchSelectMeta(false) + ), { field: 'createtime', title: __('Createtime'), @@ -110,18 +116,27 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin field: 'operate', title: __('Operate'), table: table, - events: Table.api.events.operate, - formatter: Table.api.formatter.operate + events: Controller.api.events.operate, + formatter: Controller.api.formatter.operate } ] ] }); + table.on('post-common-search.bs.table', function (e, tbl) { + Controller.api.initCommonSearchSelectpicker(tbl); + }); + Table.api.bindevent(table); Controller.api.syncingTicketIds = []; window.__splitTicketPendingPostAddSyncIds = window.__splitTicketPendingPostAddSyncIds || []; - table.on('load-success.bs.table', function () { + table.on('click', '.split-ticket-url-group input, .split-ticket-url-group a', function (e) { + e.stopPropagation(); + }); + + table.on('load-success.bs.table', function (e, res) { + Controller.api.renderSummaryRow(res); var pendingIds = window.__splitTicketPendingPostAddSyncIds; if (!pendingIds || !pendingIds.length) { return; @@ -185,9 +200,67 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin Controller.api.bindevent(); }, api: { + searchSelectColumn: function (field, title, searchList, liveSearch) { + return { + field: field, + title: title, + visible: false, + searchList: searchList || {}, + operate: '=', + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectMeta: function (liveSearch) { + return { + addclass: 'selectpicker', + extend: Controller.api.searchSelectExtend(liveSearch !== false) + }; + }, + searchSelectExtend: function (liveSearch) { + var text = __('Please select'); + if (!text || text === 'Please select' || text === 'Please Select') { + text = '请选择'; + } + var ext = 'data-none-selected-text="' + text + '" title="' + text + '"'; + if (liveSearch) { + ext += ' data-live-search="true"'; + } + return ext; + }, + initCommonSearchSelectpicker: function (tbl) { + var $form = $('form.form-commonsearch', tbl.$container); + if ($form.length) { + Form.events.selectpicker($form); + } + }, + /** @type {number[]} 正在手动同步的工单 ID */ syncingTicketIds: [], + /** + * 渲染筛选结果汇总行(全量筛选数据,非当前页) + * + * @param {object} res 列表接口响应 + */ + renderSummaryRow: function (res) { + var $bar = $('#split-ticket-summary'); + if (!$bar.length) { + return; + } + var total = res && res.total ? parseInt(res.total, 10) : 0; + var summary = res && res.summary ? res.summary : null; + if (!summary || total <= 0) { + $bar.addClass('hide'); + return; + } + $bar.removeClass('hide'); + $bar.find('[data-sum="ticket_total"]').text(summary.ticket_total != null ? summary.ticket_total : 0); + $bar.find('[data-sum="complete_count"]').text(summary.complete_count != null ? summary.complete_count : 0); + $bar.find('[data-sum="ticket_progress_text"]').text(summary.ticket_progress_text || '0.00%'); + $bar.find('[data-sum="speed_per_hour"]').text(summary.speed_per_hour != null ? summary.speed_per_hour : '0.00'); + }, + /** * 后台同步:标记「同步中」并请求 sync 接口 * @@ -243,6 +316,30 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, formatter: { + /** + * 操作列:编辑 → 拷贝 → 删除 + */ + operate: function (value, row, index) { + var column = this; + var table = this.table; + var options = table.bootstrapTable('getOptions'); + var buttons = []; + if (options.extend.edit_url) { + buttons.push($.extend({}, Table.button.edit, {url: options.extend.edit_url})); + } + buttons.push({ + name: 'copy', + icon: 'fa fa-copy', + title: __('Copy'), + classname: 'btn btn-xs btn-info btn-copyone', + url: 'javascript:;', + extend: 'data-toggle="tooltip" data-container="body"', + }); + if (options.extend.del_url) { + buttons.push($.extend({}, Table.button.del)); + } + return Table.api.buttonlink(column, buttons, value, row, index, 'operate'); + }, /** * 工单类型:纯文本展示,无链接/标签样式 */ @@ -267,6 +364,27 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin return '' + safe + ''; }, + /** + * 工单链接:只读 input + 外链图标按钮 + */ + ticketUrlLink: function (value) { + value = value == null ? '' : String(value).trim(); + if (value === '') { + return '-'; + } + var href = value; + if (!/^https?:\/\//i.test(href)) { + href = 'https://' + href; + } + var safeValue = Fast.api.escape(value); + var safeHref = Fast.api.escape(href); + return '
' + + '' + + '' + + '' + + '' + + '
'; + }, speedPerHour: function (value) { var num = parseFloat(value); if (isNaN(num)) { @@ -308,6 +426,22 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin + ''; } }, + events: { + operate: $.extend({}, Table.api.events.operate, { + 'click .btn-copyone': function (e, value, row, index) { + e.stopPropagation(); + e.preventDefault(); + var table = $(this).closest('table'); + var options = table.bootstrapTable('getOptions'); + var id = row[options.pk]; + Fast.api.open( + Fast.api.fixurl('split.ticket/add?copy_id=' + id), + __('Copy'), + $(this).data() || {} + ); + } + }) + }, bindevent: function () { Form.api.bindevent($('form[role=form]')); Controller.api.fixSelectPlaceholder(); diff --git a/public/assets/js/require-table.js b/public/assets/js/require-table.js index 5ba1f90..67e0b58 100755 --- a/public/assets/js/require-table.js +++ b/public/assets/js/require-table.js @@ -10,7 +10,7 @@ define(['jquery', 'bootstrap', 'moment', 'moment/locale/zh-cn', 'bootstrap-table search: true, //是否启用快速搜索 cache: false, commonSearch: true, //是否启用通用搜索 - searchFormVisible: false, //是否始终显示搜索表单 + searchFormVisible: true, //是否始终显示搜索表单 titleForm: '', //为空则不显示标题,不定义默认显示:普通搜索 idTable: 'commonTable', showExport: true, diff --git a/test_root_write b/test_root_write old mode 100644 new mode 100755