使用轻量版 Google Analytics:通过 Cloudflare Workers 规避拦截与优化性能

24 年 11 月 15 日 星期五
1247 字
7 分钟

AI 摘要

奋力赶来...

最近将 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,对于我这样的普通用户完全足够使用了。

@minimal-analytics/ga4

@minimal-analytics/ga4

经过 gzip 只有 2kB,支持了 Paga views 等常用的事件捕获。实现上使用 navigator.sendBeacon(${r}?${s}) 发送,同时支持自定义 Endpoint,可以避免 Google 域名被拦截的问题。

部署步骤

前端页面中 head 标签中嵌入如下代码:

html
<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 的策略方法

javascript
;((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 整体实现

javascript
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 完成部署。

修改前端代码

html
<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>

测试

  1. 如果需要统计的网站是 https://some.fylsen.com,使用的 endpoint 是 https://ga.fylsen.com 时,可以正常的获取脚本文件,但是转发服务还是会被拦截。转而使用不相关的域名,比如 https://example.com 时,Adguard 便会放行。
  2. Image 对象发送 GET 请求基本可以成功,不会用到后续的方法,稳定性还是不错的。

后续

博客技术栈

虽然解决了我当前环境中的 AdGuard 拦截问题以及官方 GA 比较大的问题,但是使用时间比较短,可能还存在没能测试出的情况,以及关于域名被拦截的问题还不清楚是什么策略导致的。另外后续想要尝试筛选一下流量,更准确的捕获准确数据,比如机器流量。

相关项目

文章标题:使用轻量版 Google Analytics:通过 Cloudflare Workers 规避拦截与优化性能

文章作者:Cedar

文章链接:https://some.fylsen.com/posts/lightweight-google-analytics-cloudflare-workers-adblock-solution  [复制]

最后修改时间:


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。