先说背景。
组里同学反馈说GitLab流水线里有时候会失败,我看了下错误,安装Puppeteer失败了。我们知道,Puppeteer依赖于Chrome内核,所以会下载一个无头浏览器,这个是比较耗时的,而且它的资源地址多半在国外,所以下载慢是正常的,有时候失败也是正常的。
针对我们的特殊国情,国内有个npm的淘宝镜像,这两年还更换了域名为cdn.npmmirror.com。它里面就有Puppeteer需要的Chrome资源包:
所以,我们只需要在项目根目录的.npmrc目录下配置:
puppeteer-download-host="https://cdn.npmmirror.com/binaries"
我自信满满地给组内同学这么说了,得到的反馈是还是不行。
打脸来的这么突然,难道老经验不好使了?
我确实已经有几年没有安装过Node.js版本的Puppeteer了(这两年在用Deno版本的),不知道是哪个环节出了问题。所以还去GitHub看源码吧。
版本号探究
Puppeteer依赖Chromium的版本号在这里:github.com/puppeteer/p…
export const PUPPETEER_REVISIONS = Object.freeze({
chrome: '114.0.5735.90',
firefox: 'latest',
});
我去对照看了下Deno版本的,是这样的github.com/lucacasonat…:
export const PUPPETEER_REVISIONS = Object.freeze({
chromium: "1022525",
firefox: "latest",
});
这两个版本号有什么关联吗?一头雾水,就去问ChatGPT,答曰:
114.0.5735.90,Chromium版本号中的第一部分是主版本号,第二部分是次版本号,第三部分是修订版本号。
而494755是Chromium代码库中的提交版本号(revision number),它是用于跟踪代码库中的更改的唯一标识符。在不同的Chromium版本中,提交版本号会不断递增,而主、次、修订版本号则会根据发布策略和更新内容进行变更。
在发版页面github.com/puppeteer/p…搜索:
可以看到修改了这几处:
这里的提交记录就是二者的对应关系。
在我们上面的淘宝镜像网站registry.npmmirror.com/binary.html…就能找到文件了:
这时首先冒出的想法是,大不了把这个文件下载下来,按照报错信息里『cdn.npmmirror.com/binaries/11…』按照同样的路径放到我们CDN上,这样也能解决问题。
下载路径
本着来都来了的心思,看下代码吧。找到下载路径的代码:
// Prepare variables used in browser downloading
if (!configuration.skipDownload) {
configuration.browserRevision =
process.env['PUPPETEER_BROWSER_REVISION'] ??
process.env['npm_config_puppeteer_browser_revision'] ??
process.env['npm_package_config_puppeteer_browser_revision'] ??
configuration.browserRevision;
const downloadHost =
process.env['PUPPETEER_DOWNLOAD_HOST'] ??
process.env['npm_config_puppeteer_download_host'] ??
process.env['npm_package_config_puppeteer_download_host'];
if (downloadHost && configuration.logLevel === 'warn') {
console.warn(
`PUPPETEER_DOWNLOAD_HOST is deprecated. Use PUPPETEER_DOWNLOAD_BASE_URL instead.`
);
}
configuration.downloadBaseUrl =
process.env['PUPPETEER_DOWNLOAD_BASE_URL'] ??
process.env['npm_config_puppeteer_download_base_url'] ??
process.env['npm_package_config_puppeteer_download_base_url'] ??
configuration.downloadBaseUrl ??
downloadHost;
configuration.downloadPath =
process.env['PUPPETEER_DOWNLOAD_PATH'] ??
process.env['npm_config_puppeteer_download_path'] ??
process.env['npm_package_config_puppeteer_download_path'] ??
configuration.downloadPath;
}
在.npmrc
中配置的是其实就是上面的npm_config_puppeteer_download_host
,_换成了-:
puppeteer-download-host=https://cdn.npmmirror.com/binaries
注意看那行警告,现在推荐的是使用
PUPPETEER_DOWNLOAD_PATH
,对应到.npmrc
中也就是puppeteer-download-base-url
。
下载url的拼接逻辑在这里:
// packages/browsers/src/install.ts
function getDownloadUrl(
browser: Browser,
platform: BrowserPlatform,
buildId: string,
baseUrl?: string
): URL {
return new URL(downloadUrls[browser](platform, buildId, baseUrl));
}
// packages/browsers/src/browser-data/browser-data.ts
export const downloadUrls = {
[Browser.CHROMEDRIVER]: chromedriver.resolveDownloadUrl,
[Browser.CHROME]: chrome.resolveDownloadUrl,
[Browser.CHROMIUM]: chromium.resolveDownloadUrl,
[Browser.FIREFOX]: firefox.resolveDownloadUrl,
};
// packages/browsers/src/browser-data/chromedriver.ts
export function resolveDownloadUrl(
platform: BrowserPlatform,
buildId: string,
baseUrl = 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing'
): string {
return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`;
}
也就是说,如果前面如果没有得到配置downloadBaseUrl,就会找edgedl.me.gvt1.com/edgedl/chro…。这个与packages/puppeteer-core/src/common/Configuration.ts
代码注释中是一致的:
/**
* Specifies the URL prefix that is used to download the browser.
*
* Can be overridden by `PUPPETEER_DOWNLOAD_BASE_URL`.
*
* @remarks
* This must include the protocol and may even need a path prefix.
*
* @defaultValue Either https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing or
* https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central,
* depending on the product.
*/
downloadBaseUrl?: string;
从这个url的命名上看,应该是测试用的。
这时灵光一闪,淘宝镜像里也有一个同名的地址,被我习惯地忽略了:registry.npmmirror.com/binary.html…
上面的113.0.5672.63是同事报错的版本,下面的114.0.5735.90是我们看的源码中的版本。
只是怎么只有2个文件夹啊?
先不管三七二十一,修改.npmrc,直接换成这个地址试试:
puppeteer-download-base-url="https://cdn.npmmirror.com/binaries/chrome-for-testing"
可以了!
按理说事情就该结束了,但这肯定不是一个程序员解决问题的正确打开方式。我还有个疑问是,下载路径中的chrome版本哪里来的呢?
// packages/browsers/src/browser-data/browser-data.ts
export async function resolveBuildId(
browser: Browser,
platform: BrowserPlatform,
tag: string
): Promise<string> {
switch (browser) {
case Browser.FIREFOX:
switch (tag as BrowserTag) {
case BrowserTag.LATEST:
return await firefox.resolveBuildId('FIREFOX_NIGHTLY');
case BrowserTag.BETA:
case BrowserTag.CANARY:
case BrowserTag.DEV:
case BrowserTag.STABLE:
throw new Error(`${tag} is not supported for ${browser}`);
}
case Browser.CHROME:
switch (tag as BrowserTag) {
case BrowserTag.LATEST:
return await chrome.resolveBuildId(
platform,
ChromeReleaseChannel.CANARY
);
case BrowserTag.BETA:
return await chrome.resolveBuildId(
platform,
ChromeReleaseChannel.BETA
);
case BrowserTag.CANARY:
return await chrome.resolveBuildId(
platform,
ChromeReleaseChannel.CANARY
);
case BrowserTag.DEV:
return await chrome.resolveBuildId(
platform,
ChromeReleaseChannel.DEV
);
case BrowserTag.STABLE:
return await chrome.resolveBuildId(
platform,
ChromeReleaseChannel.STABLE
);
}
case Browser.CHROMEDRIVER:
switch (tag as BrowserTag) {
case BrowserTag.LATEST:
case BrowserTag.CANARY:
return await chromedriver.resolveBuildId(ChromeReleaseChannel.CANARY);
case BrowserTag.BETA:
return await chromedriver.resolveBuildId(ChromeReleaseChannel.BETA);
case BrowserTag.DEV:
return await chromedriver.resolveBuildId(ChromeReleaseChannel.DEV);
case BrowserTag.STABLE:
return await chromedriver.resolveBuildId(ChromeReleaseChannel.STABLE);
}
case Browser.CHROMIUM:
switch (tag as BrowserTag) {
case BrowserTag.LATEST:
return await chromium.resolveBuildId(platform, 'latest');
case BrowserTag.BETA:
return await chromium.resolveBuildId(
platform,
ChromeReleaseChannel.BETA
);
case BrowserTag.CANARY:
return await chromium.resolveBuildId(
platform,
ChromeReleaseChannel.CANARY
);
case BrowserTag.DEV:
return await chromium.resolveBuildId(
platform,
ChromeReleaseChannel.DEV
);
case BrowserTag.STABLE:
return await chromium.resolveBuildId(
platform,
ChromeReleaseChannel.STABLE
);
}
}
// We assume the tag is the buildId if it didn't match any keywords.
return tag;
}
// packages/browsers/src/browser-data/chrome.ts
export async function getLastKnownGoodReleaseForChannel(
channel: ChromeReleaseChannel
): Promise<{version: string; revision: string}> {
const data = (await getJSON(
new URL(
'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json'
)
)) as {
channels: {
[channel: string]: {version: string};
};
};
for (const channel of Object.keys(data.channels)) {
data.channels[channel.toLowerCase()] = data.channels[channel]!;
delete data.channels[channel];
}
return (
data as {
channels: {
[channel in ChromeReleaseChannel]: {version: string; revision: string};
};
}
).channels[channel];
}
export async function resolveBuildId(
_platform: BrowserPlatform,
channel: ChromeReleaseChannel
): Promise<string> {
return (await getLastKnownGoodReleaseForChannel(channel)).version;
}
一步一步找到了getLastKnownGoodReleaseForChannel
这个函数。我们打开googlechromelabs.github.io/chrome-for-…:
为什么只有114.0.5735.90,而没有113.0.5672.63?
我猜测过了一天,刚好114成为正式版本了?
那么以前版本难道也用的最新版本的chrome?不太可能吧。我试着修改.npmrc:
puppeteer-download-host="https://cdn.npmmirror.com/xxx" # 故意改错
这时安装puppeteer的16.0.0,报错了:
npm WARN deprecated puppeteer@16.0.0: < 19.4.0 is no longer supported
npm ERR! code 1
npm ERR! path /wk/puppeteer-test/node_modules/puppeteer
npm ERR! command failed
npm ERR! command sh -c -- node install.js
npm ERR! ERROR: Failed to set up Chromium r1022525! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.
npm ERR! Error: Download failed: server returned code 404. URL: https://cdn.npmmirror.com/xxx/chromium-browser-snapshots/Linux_x64/1022525/chrome-linux.zip
1022525这个路径看着可不像现在的东西,到GitHub上看源码,将版本切换到16.0.0,找到revisions:
难道是19.0.0有了修改吗?切换到19的最新版本,仍然是带具体版本号的:
在GitHub代码上19版本的tag只能找到19.2.2,现在还没有20的标签。但在外面的tag里是有的:
再看发版本说明里大大的警告,是破坏性更新:
合并声明里面是这么说的:
大意是:Puppeteer现在默认使用Chrome进行测试二进制文件,而不是Chromium。这些二进制文件将来会更类似于Chrome二进制文件。由于二进制文件不同,因此我们将更改标记为破坏性,并包括与浏览器安装方式相关的其他破坏性更改。其中包括删除BrowserFetcher、启用mac arm64版本的下载以及删除一些标志和脚本。
总之,这个破坏性的更新很危险。
PS:chromium与puppeteer的对应关系在这里:pptr.dev/chromium-su…
Chromium 114.0.5735.90 - Puppeteer v20.6.0
Chrome for Testing 113.0.5672.63 - Puppeteer v20.1.0
Chrome for Testing 112.0.5615.121 - Puppeteer v20.0.0
注意上面我们看到的registry.npmmirror.com/binary.html…只有两个文件夹,113和114两个版本,所以20.0.0是不能用这个镜像源的。
TIPS
Deno版本
回头再看Deno版本,可以看到它停留在16.2.0这个版本上已经快10个月了。
注意它不是Puppeteer官方维护的,所以很可能看到Puppeteer这么大刀阔斧地更新,没有迭代的动力了吧?
随着Deno对Node.js API兼容的完善,直接支持运行Puppeteer可能也不远了。
缓存目录变更
自v19.0.0起,chrome内核文件被下载到$HOME/.cache/puppeteer
目录下(以前是node_modules/puppeteer/.local-chromium
)。这样在流水线中使用的话,要么将这个目录缓存,要么修改路径。
如果是修改路径的话,可以在项目根目录下新建一个文件.puppeteerrc.cjs
或者puppeteer.config.cjs
:
const { join } = require('path');
/**
* @type {import("puppeteer").Configuration}
*/
module.exports = {
// Changes the cache location for Puppeteer.
cacheDirectory: join(__dirname, 'node_modules', '.cache', 'puppeteer'),
};
跳过下载
在某些时候,你可能并不需要下载这一步,比如将下载资源前置或后置。通过设置环境变量PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
,可以跳过这一步。
Linux中安装依赖和字体
比如在centos7中安装依赖库和字体:
#依赖库
yum install pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 -y
#字体
yum install ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc -y
总结
我们通过查看代码,在淘宝镜像中找到了新的Puppeteer对应的Chrome资源包地址。对于Puppeteer20.1以上的版本,可以通过以下方式使用国产镜像源:
puppeteer-download-base-url="https://cdn.npmmirror.com/binaries/chrome-for-testing"
19以下版本还是原来的方式:
puppeteer-download-host="https://cdn.npmmirror.com/binaries"