From 65e760b88af19116de4eec44f61ad397bbfe87b1 Mon Sep 17 00:00:00 2001 From: ashleyashli <101024304+ashleyashli@users.noreply.github.com> Date: Wed, 20 May 2026 16:19:14 +0800 Subject: [PATCH] feat(seo): add GSC report opportunities (#2388) Co-authored-by: ashley li Co-authored-by: Cursor --- .github/workflows/seo-daily-report.yml | 3 + apps/landing-page/scripts/seo-daily-report.ts | 302 +++++++++++++++++- docs/seo-daily-report.md | 33 ++ 3 files changed, 335 insertions(+), 3 deletions(-) diff --git a/.github/workflows/seo-daily-report.yml b/.github/workflows/seo-daily-report.yml index b762233e1..cff2945b7 100644 --- a/.github/workflows/seo-daily-report.yml +++ b/.github/workflows/seo-daily-report.yml @@ -99,6 +99,9 @@ jobs: FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }} FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_WEBHOOK_SECRET }} REPORT_DELAY_DAYS: '2' + OPP_MIN_IMPRESSIONS: '30' + OPP_LOW_CTR: '0.01' + OPP_MOBILE_DESKTOP_CTR_GAP: '0.30' run: | flags="" if [ -n "${{ github.event.inputs.today }}" ]; then diff --git a/apps/landing-page/scripts/seo-daily-report.ts b/apps/landing-page/scripts/seo-daily-report.ts index 35b6cf45c..393ed3420 100644 --- a/apps/landing-page/scripts/seo-daily-report.ts +++ b/apps/landing-page/scripts/seo-daily-report.ts @@ -44,14 +44,49 @@ interface Mover { previousCtr: number; } +interface DimensionRow { + key: string; + clicks: number; + impressions: number; + ctr: number; + position: number; +} + +interface Opportunity { + label: string; + item: string; + evidence: string; + action: string; +} + +interface OpportunityBuckets { + doorwayQueries: Opportunity[]; + lowCtrQueries: Opportunity[]; + lowCtrPages: Opportunity[]; + deviceCtrGaps: Opportunity[]; +} + +interface OpportunityThresholds { + minImpressions: number; + lowCtr: number; + mobileDesktopCtrGap: number; +} + interface DailyReport { reportDate: string; comparisonDate: string; + rollingStartDate: string; + rollingEndDate: string; metrics: Metrics; delta: MetricDelta; + devices: DimensionRow[]; + countries: DimensionRow[]; + searchAppearances: DimensionRow[]; pageRisers: Mover[]; pageFallers: Mover[]; queryRisers: Mover[]; + opportunities: OpportunityBuckets; + thresholds: OpportunityThresholds; } function parseArgs(argv: string[]): Args { @@ -83,9 +118,24 @@ async function buildReport(args: Args): Promise { const today = args.today ?? todayInShanghai(); const reportDate = addDays(today, -args.delayDays); const comparisonDate = addDays(reportDate, -7); + const rollingStartDate = addDays(reportDate, -6); + const rollingEndDate = reportDate; const dataState = 'all'; + const thresholds = readOpportunityThresholds(); - const [currentTotals, previousTotals, currentPages, previousPages, currentQueries, previousQueries] = + const [ + currentTotals, + previousTotals, + currentPages, + previousPages, + currentQueries, + previousQueries, + deviceRows, + countryRows, + searchAppearanceRows, + rollingQueries, + rollingPages, + ] = await Promise.all([ querySearchAnalyticsRows({ startDate: reportDate, @@ -123,6 +173,38 @@ async function buildReport(args: Args): Promise { dimensions: ['query'], dataState, }), + querySearchAnalyticsRows({ + startDate: rollingStartDate, + endDate: rollingEndDate, + dimensions: ['device'], + dataState, + }), + querySearchAnalyticsRows({ + startDate: rollingStartDate, + endDate: rollingEndDate, + dimensions: ['country'], + dataState, + }), + querySearchAnalyticsRows({ + startDate: rollingStartDate, + endDate: rollingEndDate, + dimensions: ['searchAppearance'], + dataState, + }), + querySearchAnalyticsRows({ + startDate: rollingStartDate, + endDate: rollingEndDate, + dimensions: ['query'], + rowLimit: 25_000, + dataState, + }), + querySearchAnalyticsRows({ + startDate: rollingStartDate, + endDate: rollingEndDate, + dimensions: ['page'], + rowLimit: 25_000, + dataState, + }), ]); const rowCounts = { currentTotals: currentTotals.length, @@ -131,9 +213,14 @@ async function buildReport(args: Args): Promise { previousPages: previousPages.length, currentQueries: currentQueries.length, previousQueries: previousQueries.length, + devices: deviceRows.length, + countries: countryRows.length, + searchAppearances: searchAppearanceRows.length, + rollingQueries: rollingQueries.length, + rollingPages: rollingPages.length, }; console.log( - `GSC rows for ${reportDate} vs ${comparisonDate} (${dataState}): ${JSON.stringify(rowCounts)}`, + `GSC rows for ${reportDate} vs ${comparisonDate}; rolling ${rollingStartDate}..${rollingEndDate} (${dataState}): ${JSON.stringify(rowCounts)}`, ); if (Object.values(rowCounts).every((count) => count === 0)) { throw new Error( @@ -149,6 +236,8 @@ async function buildReport(args: Args): Promise { return { reportDate, comparisonDate, + rollingStartDate, + rollingEndDate, metrics, delta: { clicks: percentDelta(metrics.clicks, previousMetrics.clicks), @@ -156,6 +245,9 @@ async function buildReport(args: Args): Promise { ctrPoints: (metrics.ctr - previousMetrics.ctr) * 100, position: metrics.position - previousMetrics.position, }, + devices: dimensionRows(deviceRows), + countries: dimensionRows(countryRows).slice(0, 5), + searchAppearances: dimensionRows(searchAppearanceRows), pageRisers: [...pageMovers] .sort((a, b) => b.clickDelta - a.clickDelta) .slice(0, 5), @@ -163,6 +255,13 @@ async function buildReport(args: Args): Promise { queryRisers: [...queryMovers] .sort((a, b) => b.clickDelta - a.clickDelta) .slice(0, 5), + opportunities: buildOpportunities({ + queries: rollingQueries, + pages: rollingPages, + devices: deviceRows, + thresholds, + }), + thresholds, }; } @@ -206,6 +305,149 @@ function rowsByFirstKey(rows: SearchAnalyticsRow[]): Map { return map; } +function dimensionRows(rows: SearchAnalyticsRow[]): DimensionRow[] { + return rows + .map((row) => ({ + key: row.keys[0] ?? 'unknown', + clicks: row.clicks, + impressions: row.impressions, + ctr: row.ctr, + position: row.position, + })) + .sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions); +} + +function readOpportunityThresholds(): OpportunityThresholds { + return { + minImpressions: readNumberEnv('OPP_MIN_IMPRESSIONS', 30), + lowCtr: readNumberEnv('OPP_LOW_CTR', 0.01), + mobileDesktopCtrGap: readNumberEnv('OPP_MOBILE_DESKTOP_CTR_GAP', 0.3), + }; +} + +function readNumberEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return fallback; + return value; +} + +function buildOpportunities(input: { + queries: SearchAnalyticsRow[]; + pages: SearchAnalyticsRow[]; + devices: SearchAnalyticsRow[]; + thresholds: OpportunityThresholds; +}): OpportunityBuckets { + const { queries, pages, devices, thresholds } = input; + const minImpressions = thresholds.minImpressions; + const lowCtr = thresholds.lowCtr; + + const doorwayQueries = queries + .filter( + (row) => + row.impressions >= minImpressions && + row.position >= 11 && + row.position <= 20 && + Boolean(row.keys[0]), + ) + .sort((a, b) => b.impressions - a.impressions) + .slice(0, 3) + .map((row) => + opportunity( + '门口页 query', + row.keys[0] ?? '', + `曝光 ${number(row.impressions)} · 平均排名 ${row.position.toFixed(1)} · CTR ${percent(row.ctr)}`, + '补强匹配页面的标题、H1、FAQ 和内部链接,把 page 2 query 推进前 10。', + ), + ); + + const lowCtrQueries = queries + .filter( + (row) => + row.impressions >= minImpressions && + row.position <= 10 && + row.ctr < lowCtr && + Boolean(row.keys[0]), + ) + .sort((a, b) => b.impressions - a.impressions) + .slice(0, 3) + .map((row) => + opportunity( + '高曝光低 CTR query', + row.keys[0] ?? '', + `曝光 ${number(row.impressions)} · 排名 ${row.position.toFixed(1)} · CTR ${percent(row.ctr)}`, + '重写目标页 title/meta description,让 snippet 更贴近搜索意图和差异化卖点。', + ), + ); + + const lowCtrPages = pages + .filter( + (row) => + row.impressions >= minImpressions && + row.position <= 10 && + row.ctr < lowCtr && + Boolean(row.keys[0]), + ) + .sort((a, b) => b.impressions - a.impressions) + .slice(0, 3) + .map((row) => + opportunity( + '高排名低 CTR page', + row.keys[0] ?? '', + `曝光 ${number(row.impressions)} · 排名 ${row.position.toFixed(1)} · CTR ${percent(row.ctr)}`, + '优先检查 SERP 标题、描述和首屏承诺,避免排名有了但点击损失。', + ), + ); + + return { + doorwayQueries, + lowCtrQueries, + lowCtrPages, + deviceCtrGaps: deviceCtrGapOpportunities(devices, thresholds), + }; +} + +function deviceCtrGapOpportunities( + rows: SearchAnalyticsRow[], + thresholds: OpportunityThresholds, +): Opportunity[] { + const byDevice = rowsByFirstKey(rows); + const mobile = byDevice.get('MOBILE') ?? byDevice.get('mobile'); + const desktop = byDevice.get('DESKTOP') ?? byDevice.get('desktop'); + if (!mobile || !desktop) return []; + if ( + mobile.impressions < thresholds.minImpressions || + desktop.impressions < thresholds.minImpressions + ) { + return []; + } + + const betterCtr = Math.max(mobile.ctr, desktop.ctr); + if (betterCtr === 0) return []; + const relativeGap = Math.abs(mobile.ctr - desktop.ctr) / betterCtr; + if (relativeGap <= thresholds.mobileDesktopCtrGap) return []; + + const worse = mobile.ctr < desktop.ctr ? 'mobile' : 'desktop'; + return [ + opportunity( + '设备 CTR 差距', + `${worse} CTR 落后`, + `mobile ${percent(mobile.ctr)} / desktop ${percent(desktop.ctr)} · 差距 ${(relativeGap * 100).toFixed(0)}%`, + `优先检查 ${worse} 搜索结果承诺与落地页体验,确认首屏、速度和 CTA 是否拖累点击。`, + ), + ]; +} + +function opportunity( + label: string, + item: string, + evidence: string, + action: string, +): Opportunity { + return { label, item, evidence, action }; +} + function buildFeishuCard(report: DailyReport) { return { config: { wide_screen_mode: true }, @@ -219,12 +461,20 @@ function buildFeishuCard(report: DailyReport) { elements: [ markdown(summaryMarkdown(report)), { tag: 'hr' }, + markdown(dimensionMarkdown('设备分布(近 7 天)', report.devices)), + markdown(dimensionMarkdown('国家/地区 Top 5(近 7 天)', report.countries)), + ...(report.searchAppearances.length > 0 + ? [markdown(dimensionMarkdown('Search appearance(近 7 天)', report.searchAppearances))] + : []), + { tag: 'hr' }, markdown(moversMarkdown('Top 5 页面增长', report.pageRisers)), markdown(moversMarkdown('Top 5 页面下滑', report.pageFallers)), markdown(moversMarkdown('Top 5 查询增长', report.queryRisers)), { tag: 'hr' }, + markdown(opportunitiesMarkdown(report)), + { tag: 'hr' }, markdown( - `数据口径: ${report.reportDate} vs ${report.comparisonDate} · ${GSC_SITE_URL} · GSC API`, + `数据口径: 单日 ${report.reportDate} vs ${report.comparisonDate};维度/机会 ${report.rollingStartDate}..${report.rollingEndDate} · ${GSC_SITE_URL} · GSC API`, ), ], }; @@ -255,6 +505,48 @@ function moversMarkdown(title: string, movers: Mover[]): string { ].join('\n'); } +function dimensionMarkdown(title: string, rows: DimensionRow[]): string { + if (rows.length === 0) return `**${title}**\n\n暂无数据`; + return [ + `**${title}**`, + '', + '| 维度 | 点击 | 曝光 | CTR |', + '| --- | ---: | ---: | ---: |', + ...rows.map( + (row) => + `| ${formatDimensionKey(row.key)} | ${number(row.clicks)} | ${number(row.impressions)} | ${percent(row.ctr)} |`, + ), + ].join('\n'); +} + +function opportunitiesMarkdown(report: DailyReport): string { + const sections = [ + opportunitySection('门口页 query', report.opportunities.doorwayQueries), + opportunitySection('高曝光低 CTR query', report.opportunities.lowCtrQueries), + opportunitySection('高排名低 CTR page', report.opportunities.lowCtrPages), + opportunitySection('设备 CTR 差距', report.opportunities.deviceCtrGaps), + ].filter(Boolean); + + const thresholdLine = `阈值: 曝光 ≥ ${number(report.thresholds.minImpressions)} · 低 CTR < ${percent(report.thresholds.lowCtr)} · 设备 CTR 相对差距 > ${(report.thresholds.mobileDesktopCtrGap * 100).toFixed(0)}%`; + + if (sections.length === 0) { + return `**优化机会(近 7 天)**\n\n近 7 天暂无明显优化候选。\n\n${thresholdLine}`; + } + + return ['**优化机会(近 7 天)**', '', thresholdLine, '', ...sections].join('\n'); +} + +function opportunitySection(title: string, opportunities: Opportunity[]): string { + if (opportunities.length === 0) return ''; + return [ + `**${title}**`, + ...opportunities.map( + (item) => + `- ${formatKey(item.item)} — ${item.evidence};建议:${item.action}`, + ), + ].join('\n'); +} + function markdown(content: string) { return { tag: 'div', @@ -351,6 +643,10 @@ function formatKey(key: string): string { } } +function formatDimensionKey(key: string): string { + return truncate(escapeTableText(key.toLowerCase()), 48); +} + function truncate(value: string, maxLength: number): string { if (value.length <= maxLength) return value; return `${value.slice(0, maxLength - 1)}…`; diff --git a/docs/seo-daily-report.md b/docs/seo-daily-report.md index ab1b7fc86..dae5c1a14 100644 --- a/docs/seo-daily-report.md +++ b/docs/seo-daily-report.md @@ -19,9 +19,17 @@ it final. - Site totals: clicks, impressions, CTR, average position - Week-over-week deltas +- Device breakdown over the latest stable 7-day window +- Country / region Top 5 over the latest stable 7-day window +- Search appearance breakdown when GSC returns rich-result rows - Top 5 page risers by click delta - Top 5 page fallers by click delta - Top 5 query risers by click delta +- Optimization opportunities from the latest stable 7-day window: + - doorway queries ranking in positions 11-20 + - high-impression, low-CTR queries + - high-ranking, low-CTR pages + - mobile / desktop CTR gaps ## Required GitHub Secrets @@ -42,6 +50,31 @@ Feishu posting requires: | `FEISHU_WEBHOOK_URL` | Yes | Custom bot webhook URL from the target Feishu group. | | `FEISHU_WEBHOOK_SECRET` | No | Required only if signing verification is enabled for the custom bot. | +## Data windows + +The report uses two windows: + +| Section | Window | Why | +| --- | --- | --- | +| Site totals and Top 5 movers | T-2 vs T-9 | Same-weekday daily comparison. | +| Device, country, search appearance, optimization opportunities | T-8 through T-2 | A 7-day window smooths daily noise and gives enough volume for action candidates. | + +All Search Analytics calls use `dataState: all` so recent GSC rows are included +before Google marks them final. + +## Opportunity thresholds + +These environment variables tune the optimization section: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `OPP_MIN_IMPRESSIONS` | `30` | Minimum impressions before a query/page is considered actionable. | +| `OPP_LOW_CTR` | `0.01` | CTR below this value is treated as low CTR. | +| `OPP_MOBILE_DESKTOP_CTR_GAP` | `0.30` | Relative CTR gap between mobile and desktop before surfacing a device issue. | + +The current defaults are intentionally low because `open-design.ai` is still +building GSC history. Tighten them as traffic grows. + ## Manual test From GitHub Actions: