最近将 Blog 所使用的 Cloudflare CDN 切换到了 Vercel 之上,整体加载速度要比之前快上不少,但是之前使用 Cloudflare Zaraz 部署的 Google Anlytics(GA) 服务无法使用了。为了优化官方版本的 GA (130kB+)拉低访问速度的情况,同时存在被 Adblock 工具拦截的问题,决定使用一些精简版本的工具实现。
Minimal Google Analytics Snippet 开源项目
互联网上的 Minimal Google Analytics Snippet 项目都能满足常见事件捕获的同时大幅度减少脚本大小,基本逻辑就是前端捕获用户动作事件再并发送到https://www.google-analytics.com/collect
,对于我这样的普通用户完全足够使用了。
经过 gzip 只有 2kB,支持了 Paga views 等常用的事件捕获。实现上使用 navigator.sendBeacon(${r}?${s})
发送,同时支持自定义 Endpoint,可以避免 Google 域名被拦截的问题。
部署步骤
前端页面中 head 标签中嵌入如下代码:
<script>
window.minimalAnalytics = {
trackingId: 'G-XXXXXXXXXX',
analyticsEndpoint: 'https://www.google-analytics.com/collect',
defineGlobal: false,
autoTrack: true,
}
</script>
<script async defer src="https://unpkg.com/@minimal-analytics/ga4/dist/index.js"></script>
测试
实际部署后发现,如果开启 Adguard,无论是否使用 endpoint,navigator.sendBeacon()
大概率会被拦截。为了解决上述问题,需要实现一个转发用的 endpoint 服务,同时使用其他方式替代sendBeacon
。
关于 Endpoint 可以使用 Cloudflare Workers 实现一个转发逻辑,无论是免费的额度、自定义域名、实现难度,都是很优质的选择。 关于替换 sendBeacon,虽然是 POST 请求,但是使用的是 searchParams 传递的参数,所以可以任意通过 GET、POST 方法完成相关的转发。
实现 Cloudflare Workers GA Endpoint
Worker 功能上可以接收 GET、POST请求,并转发到 GA ;可以将 @minimal-analytics 的脚本中的 sendBeacon 替换成自己的实现,并将新的脚本返回客户端,具体方法采用现尝试虚拟 Image 对象发起请求(对抗 Ad 拦截),失败使用 Fetch 尝试,最终再尝试 sendBeacon。
替换 sendBeacon 的策略方法
;((url, searchParams) => {
const fullUrl = `${url}?${new URLSearchParams(searchParams)}`
const tryMethods = [
() =>
new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(true)
img.onerror = reject
img.src = fullUrl
}),
() =>
fetch(fullUrl, {
method: 'POST',
body: JSON.stringify(searchParams),
keepalive: true,
}).then((res) => (res.ok ? Promise.resolve(true) : Promise.reject())),
() =>
navigator.sendBeacon(fullUrl, new FormData()) ? Promise.resolve(true) : Promise.reject(),
]
tryMethods.reduce((p, method) => p.catch(() => method()), Promise.reject())
})(url, searchParams)
方法实现已经满足要求,只需要将sendBeacon中的参数替换掉 url、searchParms。
Cloudflare Workers 整体实现
const GA_COLLECT_ENDPOINT = 'https://www.google-analytics.com/g/collect'
const ANALYTICS_SCRIPT_URL = 'https://unpkg.com/@minimal-analytics/ga4/dist/index.js'
const getCorsHeaders = (origin) => ({
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Access-Control-Max-Age': '86400',
})
const getForwardHeaders = (headers, isImage) => {
const headersToForward = {
'user-agent': headers.get('user-agent'),
referer: headers.get('referer'),
dnt: headers.get('dnt'),
}
if (!isImage) {
headersToForward['sec-ch-ua'] = headers.get('sec-ch-ua')
headersToForward['sec-ch-ua-mobile'] = headers.get('sec-ch-ua-mobile')
headersToForward['sec-ch-ua-platform'] = headers.get('sec-ch-ua-platform')
}
return Object.fromEntries(Object.entries(headersToForward).filter(([_, v]) => v != null))
}
async function handleScriptProxy(request) {
const url = new URL(request.url)
const fallback = url.searchParams.get('fallback')
const response = await fetch(ANALYTICS_SCRIPT_URL)
let script = await response.text()
script = script.replace(
/https:\/\/www\.google-analytics\.com\/g\/collect/g,
url.origin + url.pathname,
)
if (fallback)
script = script.replace(
/navigator\.sendBeacon\(`\${([^}]+)}\?\${([^}]+)}`\)/g,
(_, urlParam, pParam) =>
`((url,searchParams)=>{const fullUrl=\`\${url}?\${new URLSearchParams(searchParams)}\`;const tryMethods=[()=>new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>resolve(true);img.onerror=reject;img.src=fullUrl}),()=>fetch(fullUrl,{method:'POST',body:JSON.stringify(searchParams),keepalive:!0}).then(res=>res.ok?Promise.resolve(true):Promise.reject()),()=>navigator.sendBeacon(fullUrl,new FormData())?Promise.resolve(true):Promise.reject()];tryMethods.reduce((p,method)=>p.catch(()=>method()),Promise.reject())})(${urlParam},${pParam})`,
)
return new Response(script, {
headers: {
'Content-Type': 'application/javascript',
'Cache-Control': 'public, max-age=3600',
...getCorsHeaders(request.headers.get('Origin')),
},
})
}
async function handleGA4Collection(request, env, queryParams) {
const url = new URL(GA_COLLECT_ENDPOINT)
// Validate measurement ID
if (!queryParams.tid || queryParams.tid !== env.MEASUREMENT_ID) {
throw new Error('Invalid measurement ID')
}
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
...getForwardHeaders(request.headers, request.headers.get('accept')?.includes('image')),
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`GA4 responded with ${response.status}`)
}
return response
}
async function handleRequest(request, env) {
try {
const url = new URL(request.url)
const origin = request.headers.get('Origin')
if (url.searchParams.has('fallback')) {
return handleScriptProxy(request)
}
if (request.method === 'OPTIONS') {
return new Response(null, { headers: getCorsHeaders(origin) })
}
if (!['GET', 'POST'].includes(request.method)) {
throw new Error('Method not allowed')
}
const queryParams = Object.fromEntries(url.searchParams)
const gaResponse = await handleGA4Collection(request, env, queryParams)
if (request.method === 'GET' && gaResponse.ok) {
const imageBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAwAB/gn/mkQAAAAASUVORK5CYII='
const imageData = Uint8Array.from(atob(imageBase64), (c) => c.charCodeAt(0))
return new Response(imageData, {
status: 200,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'no-store',
},
})
}
return new Response(await gaResponse.text(), {
status: gaResponse.status,
headers: {
...getCorsHeaders(origin),
'Content-Type': gaResponse.headers.get('content-type') || 'text/plain',
'Cache-Control': gaResponse.headers.get('cache-control') || 'no-store',
},
})
} catch (error) {
console.error('GA4 proxy error:', error)
return new Response(
JSON.stringify({
error: error.message || 'Internal Server Error',
timestamp: new Date().toISOString(),
}),
{
status: error.message === 'Invalid measurement ID' ? 403 : 500,
headers: {
...getCorsHeaders(request.headers.get('Origin')),
'Content-Type': 'application/json',
},
},
)
}
}
export default {
fetch: handleRequest,
}
直接复制全部代码,粘贴到 Worker 中,之后在环境变量中添加 MEASUREMENT_ID(GA 中获取),再绑定上域名,比如 example.com
。也可以 fork cf-worker-google-analytics-endpoints 仓库,借助 Github Action 完成部署。
修改前端代码
<script>
window.minimalAnalytics = {
trackingId: 'G-XXXXXXXXXX',
analyticsEndpoint: 'https://example.com',
defineGlobal: false,
autoTrack: true,
}
</script>
<script async defer src="https://example.com?fallback=true"></script>
测试
- 如果需要统计的网站是
https://some.fylsen.com
,使用的 endpoint 是https://ga.fylsen.com
时,可以正常的获取脚本文件,但是转发服务还是会被拦截。转而使用不相关的域名,比如https://example.com
时,Adguard 便会放行。 - Image 对象发送 GET 请求基本可以成功,不会用到后续的方法,稳定性还是不错的。
后续
虽然解决了我当前环境中的 AdGuard 拦截问题以及官方 GA 比较大的问题,但是使用时间比较短,可能还存在没能测试出的情况,以及关于域名被拦截的问题还不清楚是什么策略导致的。另外后续想要尝试筛选一下流量,更准确的捕获准确数据,比如机器流量。
相关项目