feat(seo): add GSC report opportunities (#2388)

Co-authored-by: ashley li <ashleyli@ashleydeMacBook-Air-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ashleyashli 2026-05-20 16:19:14 +08:00 committed by GitHub
parent b409e5b923
commit 65e760b88a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 335 additions and 3 deletions

View file

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

View file

@ -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<DailyReport> {
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<DailyReport> {
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<DailyReport> {
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<DailyReport> {
return {
reportDate,
comparisonDate,
rollingStartDate,
rollingEndDate,
metrics,
delta: {
clicks: percentDelta(metrics.clicks, previousMetrics.clicks),
@ -156,6 +245,9 @@ async function buildReport(args: Args): Promise<DailyReport> {
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<DailyReport> {
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<string, Metrics> {
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)}`;

View file

@ -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: