GSC MCP part 1 (#233)
This commit is contained in:
parent
1fd7b2cbce
commit
23c38ca4e5
@ -21,10 +21,11 @@ Use this when the user wants a market-level view across several competitors. For
|
|||||||
## OpenSEO MCP tools
|
## OpenSEO MCP tools
|
||||||
|
|
||||||
- `research_keywords`: discover representative market queries.
|
- `research_keywords`: discover representative market queries.
|
||||||
- `get_keyword_search_volume`: validate known query sets and trend/metric priority.
|
- `get_keyword_metrics`: validate known query sets with volume, difficulty, intent, and trends.
|
||||||
- `get_serp_results`: identify recurring ranking domains across target queries.
|
- `get_serp_results`: identify recurring ranking domains across target queries.
|
||||||
- `find_serp_competitors`: compare domains competing across supplied keywords; use this before manual SERP counting when a keyword set is available.
|
- `find_serp_competitors`: compare domains competing across supplied keywords; use this before manual SERP counting when a keyword set is available.
|
||||||
- `get_domain_overview`: size organic footprint for candidate leaders.
|
- `get_domain_overview`: size organic footprint for candidate leaders.
|
||||||
|
- `get_search_console_performance`: when the user's own domain is in the comparison and Search Console is connected, anchor their position with first-party clicks/impressions/CTR rather than third-party estimates.
|
||||||
- `get_ranked_keywords`: find exact ranking keywords, URLs, ranks, intents, and SERP result types for leaders.
|
- `get_ranked_keywords`: find exact ranking keywords, URLs, ranks, intents, and SERP result types for leaders.
|
||||||
- `get_backlinks_overview`: compare backlink/referring-domain strength where relevant.
|
- `get_backlinks_overview`: compare backlink/referring-domain strength where relevant.
|
||||||
- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO markets where proximity, Maps rankings, business categories, reviews, or Google Q&A affect who is winning.
|
- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO markets where proximity, Maps rankings, business categories, reviews, or Google Q&A affect who is winning.
|
||||||
@ -35,7 +36,7 @@ Use this when the user wants a market-level view across several competitors. For
|
|||||||
- Use provided keywords, or call `research_keywords` to build 5-10 representative queries.
|
- Use provided keywords, or call `research_keywords` to build 5-10 representative queries.
|
||||||
- Include mixed intent: informational, commercial, comparison, and tool/software terms when applicable.
|
- Include mixed intent: informational, commercial, comparison, and tool/software terms when applicable.
|
||||||
- For local SEO, include neighborhood/city/service-area queries and identify the priority locations or coordinates.
|
- For local SEO, include neighborhood/city/service-area queries and identify the priority locations or coordinates.
|
||||||
2. If the query set is already known, use `get_keyword_search_volume` to validate relative demand and `find_serp_competitors` to identify recurring domains at scale.
|
2. If the query set is already known, use `get_keyword_metrics` to validate relative demand and difficulty and `find_serp_competitors` to identify recurring domains at scale.
|
||||||
3. For local SEO, call `search_local_businesses` and `get_local_serp_results` for the highest-priority location(s) before synthesizing winners. Use `get_serp_results` as a complement for organic pages, not as the only local evidence.
|
3. For local SEO, call `search_local_businesses` and `get_local_serp_results` for the highest-priority location(s) before synthesizing winners. Use `get_serp_results` as a complement for organic pages, not as the only local evidence.
|
||||||
4. Call `get_serp_results` for representative queries when live SERP composition, ranking URLs, or SERP features need inspection. Send at most 10 queries per call.
|
4. Call `get_serp_results` for representative queries when live SERP composition, ranking URLs, or SERP features need inspection. Send at most 10 queries per call.
|
||||||
5. Identify recurring domains and group them by type:
|
5. Identify recurring domains and group them by type:
|
||||||
|
|||||||
@ -21,6 +21,7 @@ Use this for a named competitor. For identifying the market leaders first, use `
|
|||||||
## OpenSEO MCP tools
|
## OpenSEO MCP tools
|
||||||
|
|
||||||
- `get_domain_overview`: baseline organic traffic and keyword count.
|
- `get_domain_overview`: baseline organic traffic and keyword count.
|
||||||
|
- `get_search_console_performance`: when comparing to the user's own domain and Search Console is connected, use it as the first-party baseline (real clicks/impressions/CTR/position) instead of estimating the user's own performance from third-party data.
|
||||||
- `get_ranked_keywords`: exact keyword, URL, rank, intent, traffic, CPC, and SERP-type rows for the competitor domain or page.
|
- `get_ranked_keywords`: exact keyword, URL, rank, intent, traffic, CPC, and SERP-type rows for the competitor domain or page.
|
||||||
- `get_backlinks_overview`: backlink/referring-domain profile.
|
- `get_backlinks_overview`: backlink/referring-domain profile.
|
||||||
- `find_serp_competitors`: validate whether the named competitor is a real search competitor across the target keyword set.
|
- `find_serp_competitors`: validate whether the named competitor is a real search competitor across the target keyword set.
|
||||||
@ -31,7 +32,7 @@ Use this for a named competitor. For identifying the market leaders first, use `
|
|||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Call `get_domain_overview` for the competitor, passing provided location/language when supported.
|
1. Call `get_domain_overview` for the competitor, passing provided location/language when supported.
|
||||||
2. If comparing to the user, call `get_domain_overview` for the user's domain too.
|
2. If comparing to the user, call `get_domain_overview` for the user's domain too — and if Search Console is connected, `get_search_console_performance` for the user's real baseline.
|
||||||
3. Call `get_ranked_keywords` for the competitor. Use filters like `maxRank`, `minSearchVolume`, `excludeBrandTerms`, and `resultTypes` to keep rows relevant.
|
3. Call `get_ranked_keywords` for the competitor. Use filters like `maxRank`, `minSearchVolume`, `excludeBrandTerms`, and `resultTypes` to keep rows relevant.
|
||||||
4. If comparing to the user, call `get_ranked_keywords` for the user's domain/page too, or use `get_serp_results` for the shared terms when a lighter check is enough.
|
4. If comparing to the user, call `get_ranked_keywords` for the user's domain/page too, or use `get_serp_results` for the shared terms when a lighter check is enough.
|
||||||
5. For local SEO, use `search_local_businesses` and `get_local_serp_results` around the relevant business location(s) before drawing local-pack conclusions. Add `get_google_business_questions` only when Q&A evidence matters.
|
5. For local SEO, use `search_local_businesses` and `get_local_serp_results` around the relevant business location(s) before drawing local-pack conclusions. Add `get_google_business_questions` only when Q&A evidence matters.
|
||||||
|
|||||||
@ -22,6 +22,7 @@ If keywords are not provided, use `list_saved_keywords` for saved sets, `researc
|
|||||||
- `list_saved_keywords`: fetch an existing keyword set, optionally filtered by tags.
|
- `list_saved_keywords`: fetch an existing keyword set, optionally filtered by tags.
|
||||||
- `research_keywords`: expand a seed when the user starts from a topic.
|
- `research_keywords`: expand a seed when the user starts from a topic.
|
||||||
- `get_ranked_keywords`: gather exact ranking keywords and URLs when the user starts from a domain or page.
|
- `get_ranked_keywords`: gather exact ranking keywords and URLs when the user starts from a domain or page.
|
||||||
|
- `get_search_console_performance`: when Search Console is connected, pull real queries with `dimensions: ["query","page"]` to map terms to the pages already earning impressions and to surface cannibalization (one query splitting clicks across multiple URLs).
|
||||||
- `get_serp_results`: validate whether keywords belong on the same page by checking SERP overlap and intent.
|
- `get_serp_results`: validate whether keywords belong on the same page by checking SERP overlap and intent.
|
||||||
- `get_local_serp_results`: use for local SEO clusters when Maps/local-pack intent should affect page mapping.
|
- `get_local_serp_results`: use for local SEO clusters when Maps/local-pack intent should affect page mapping.
|
||||||
- `save_keywords`: optionally tag final clusters after user confirmation.
|
- `save_keywords`: optionally tag final clusters after user confirmation.
|
||||||
@ -29,6 +30,7 @@ If keywords are not provided, use `list_saved_keywords` for saved sets, `researc
|
|||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Gather the candidate keyword set.
|
1. Gather the candidate keyword set.
|
||||||
|
- Use `get_search_console_performance` (dimensions `["query","page"]`) when Search Console is connected to start from real queries and the pages already ranking for them.
|
||||||
- Use `get_ranked_keywords` for domain/page-driven clustering.
|
- Use `get_ranked_keywords` for domain/page-driven clustering.
|
||||||
- Use `search_local_businesses` and `get_local_serp_results` when proximity, local packs, or Google Business results determine whether terms belong on location pages.
|
- Use `search_local_businesses` and `get_local_serp_results` when proximity, local packs, or Google Business results determine whether terms belong on location pages.
|
||||||
2. Remove duplicates, irrelevant terms, and terms that clearly require a different product or audience.
|
2. Remove duplicates, irrelevant terms, and terms that clearly require a different product or audience.
|
||||||
@ -41,7 +43,7 @@ If keywords are not provided, use `list_saved_keywords` for saved sets, `researc
|
|||||||
- Existing URL, if supplied and appropriate
|
- Existing URL, if supplied and appropriate
|
||||||
- New page recommendation, if no existing page fits
|
- New page recommendation, if no existing page fits
|
||||||
- Do-not-target / later bucket, if weak or off-strategy
|
- Do-not-target / later bucket, if weak or off-strategy
|
||||||
6. Identify cannibalization risk when multiple pages would target the same intent.
|
6. Identify cannibalization risk when multiple pages would target the same intent. When Search Console is connected, confirm it from real data with `get_search_console_performance` (`dimensions: ["query","page"]`) — the same query sending impressions to multiple URLs.
|
||||||
7. Ask before applying cluster tags with `save_keywords`.
|
7. Ask before applying cluster tags with `save_keywords`.
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
|
|||||||
@ -20,8 +20,9 @@ If `projectId` is missing, use `list_projects` first. If the target market/locat
|
|||||||
## OpenSEO MCP tools
|
## OpenSEO MCP tools
|
||||||
|
|
||||||
- `research_keywords`: primary discovery tool. Use 1-5 seeds per call and prefer 150 results unless the user asks for exhaustive research.
|
- `research_keywords`: primary discovery tool. Use 1-5 seeds per call and prefer 150 results unless the user asks for exhaustive research.
|
||||||
- `get_keyword_search_volume`: validate known keywords, compare CPC/competition, or refresh monthly trends without broad discovery.
|
- `get_keyword_metrics`: hydrate up to 700 known keywords with volume, keyword difficulty (KD), search intent, CPC, and monthly trends in one call. Use it to score candidate or known terms — including the Search Console striking-distance queries from step 1.
|
||||||
- `get_ranked_keywords`: pull exact ranking keyword rows when a target domain or page is part of the research brief.
|
- `get_ranked_keywords`: pull exact ranking keyword rows when a target domain or page is part of the research brief.
|
||||||
|
- `get_search_console_performance`: when Search Console is connected, start from the project's real first-party demand — queries already earning impressions and near-ranking ("striking distance") terms. Request a high `rowLimit` and filter average position 5-20 client-side, since the API sorts by clicks and can't filter by position. Then hydrate those striking-distance queries with `get_keyword_metrics` to attach difficulty and intent.
|
||||||
- `get_serp_results`: inspect SERPs for the top candidate terms, especially when intent is ambiguous.
|
- `get_serp_results`: inspect SERPs for the top candidate terms, especially when intent is ambiguous.
|
||||||
- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO topics when a business/location radius matters.
|
- `search_local_businesses`, `get_local_serp_results`, and `get_google_business_questions`: use for local SEO topics when a business/location radius matters.
|
||||||
- `list_saved_keywords`: avoid duplicating already-saved work or use existing tags as context.
|
- `list_saved_keywords`: avoid duplicating already-saved work or use existing tags as context.
|
||||||
@ -29,10 +30,10 @@ If `projectId` is missing, use `list_projects` first. If the target market/locat
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Normalize seeds into a small set of distinct research angles.
|
1. Normalize seeds into a small set of distinct research angles. If Search Console is connected for the project, first pull `get_search_console_performance` (high `rowLimit`, default lookback), filter to striking-distance positions (~5–20) client-side, and hydrate those queries with `get_keyword_metrics` to attach KD and intent. That ranked, hydrated list is your fastest opportunity set — work it before broad discovery.
|
||||||
2. If the request is local SEO, identify the business, location/coordinates or service area, and local categories. Use `search_local_businesses` and `get_local_serp_results` for the most important location/keyword set instead of relying only on national keyword/SERP data.
|
2. If the request is local SEO, identify the business, location/coordinates or service area, and local categories. Use `search_local_businesses` and `get_local_serp_results` for the most important location/keyword set instead of relying only on national keyword/SERP data.
|
||||||
3. Call `research_keywords` for exploratory seeds. Use bulk calls when possible.
|
3. Call `research_keywords` for exploratory seeds. Use bulk calls when possible.
|
||||||
4. Use `get_keyword_search_volume` when the user provides a fixed keyword list or when exact known-term metrics/trends are more useful than related-keyword expansion.
|
4. Use `get_keyword_metrics` to hydrate a fixed keyword list — or the striking-distance queries from step 1 — with volume, KD, and intent before prioritizing.
|
||||||
5. Use `get_ranked_keywords` when the user provides a domain/page and wants opportunities based on current rankings, near-misses, or competitor-owned terms.
|
5. Use `get_ranked_keywords` when the user provides a domain/page and wants opportunities based on current rankings, near-misses, or competitor-owned terms.
|
||||||
6. Remove irrelevant, duplicate, branded-only, and off-intent terms.
|
6. Remove irrelevant, duplicate, branded-only, and off-intent terms.
|
||||||
7. Prioritize by practical opportunity, not volume alone:
|
7. Prioritize by practical opportunity, not volume alone:
|
||||||
|
|||||||
@ -37,7 +37,7 @@ Good starting points:
|
|||||||
|
|
||||||
## What each workflow does
|
## What each workflow does
|
||||||
|
|
||||||
- `seo-project-setup`: sets up the workspace, verifies MCP, captures goals, positioning, and GSC exports.
|
- `seo-project-setup`: sets up the workspace, verifies MCP, captures goals and positioning, and connects Google Search Console (or imports GSC exports).
|
||||||
- `keyword-research`: finds search opportunities from seed topics and evaluates volume, difficulty, CPC, intent, and SERPs.
|
- `keyword-research`: finds search opportunities from seed topics and evaluates volume, difficulty, CPC, intent, and SERPs.
|
||||||
- `keyword-clustering`: groups keywords by intent and maps clusters to existing or proposed pages.
|
- `keyword-clustering`: groups keywords by intent and maps clusters to existing or proposed pages.
|
||||||
- `competitive-landscape`: identifies who wins across a market and what content/backlink patterns are working.
|
- `competitive-landscape`: identifies who wins across a market and what content/backlink patterns are working.
|
||||||
@ -49,6 +49,7 @@ Good starting points:
|
|||||||
Explain the difference between data sources:
|
Explain the difference between data sources:
|
||||||
|
|
||||||
- OpenSEO MCP tools provide SEO data such as keyword research, exact ranked keywords, search volume, SERPs, SERP competitors, local business and Maps data, domain overviews, backlinks, saved keywords, projects, and rank trackers.
|
- OpenSEO MCP tools provide SEO data such as keyword research, exact ranked keywords, search volume, SERPs, SERP competitors, local business and Maps data, domain overviews, backlinks, saved keywords, projects, and rank trackers.
|
||||||
|
- Google Search Console (when connected on the project's Integrations page) is the user's own first-party data — real clicks, impressions, CTR, and position. Read it live with `get_search_console_performance` instead of asking for CSV exports. It's free (no credits) and the best starting point for "what already ranks" and near-ranking opportunities.
|
||||||
- Web search can find current market context, recent pages, reviews, docs, social profiles, and contact paths outside OpenSEO.
|
- Web search can find current market context, recent pages, reviews, docs, social profiles, and contact paths outside OpenSEO.
|
||||||
- Browser/page scraping can extract page copy, headings, author names, contact links, schema, and content structure.
|
- Browser/page scraping can extract page copy, headings, author names, contact links, schema, and content structure.
|
||||||
- Local files can preserve strategy, GSC CSVs, content briefs, crawls, prospect lists, and prior decisions over time.
|
- Local files can preserve strategy, GSC CSVs, content briefs, crawls, prospect lists, and prior decisions over time.
|
||||||
|
|||||||
@ -94,9 +94,13 @@ After the user has described the company, website, goals, and positioning, check
|
|||||||
|
|
||||||
Do not run research tools just to test connectivity; `whoami` and `list_projects` are enough.
|
Do not run research tools just to test connectivity; `whoami` and `list_projects` are enough.
|
||||||
|
|
||||||
### 6. Request Google Search Console export
|
### 6. Connect Google Search Console
|
||||||
|
|
||||||
Ask the user to export a CSV from Google Search Console and place it in the SEO working folder.
|
GSC is the richest first-party signal: existing impressions, near-ranking terms, cannibalization, and pages that already have search demand.
|
||||||
|
|
||||||
|
**Preferred (hosted): connect it natively.** On the project's Integrations page, connect Google Search Console and pull live data with `get_search_console_performance`. Once connected, the agent reads it directly in `keyword-research` and `keyword-clustering` — no manual files to maintain.
|
||||||
|
|
||||||
|
**Fallback (self-hosted, or if the user prefers files):** ask the user to export CSVs from Search Console into the SEO working folder.
|
||||||
|
|
||||||
Recommended exports:
|
Recommended exports:
|
||||||
|
|
||||||
@ -114,8 +118,6 @@ gsc/queries-last-16-months.csv
|
|||||||
gsc/pages-last-16-months.csv
|
gsc/pages-last-16-months.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
Explain that GSC data reveals existing impressions, near-ranking terms, cannibalization, and pages that already have search demand.
|
|
||||||
|
|
||||||
### 7. Inventory existing assets
|
### 7. Inventory existing assets
|
||||||
|
|
||||||
Ask for or discover:
|
Ask for or discover:
|
||||||
@ -158,6 +160,6 @@ Then summarize:
|
|||||||
## Guardrails
|
## Guardrails
|
||||||
|
|
||||||
- Keep setup lightweight. The user should feel oriented, not assigned homework.
|
- Keep setup lightweight. The user should feel oriented, not assigned homework.
|
||||||
- Do not pretend a GSC CSV has been uploaded unless you can see it.
|
- Do not pretend a GSC CSV has been uploaded unless you can see it, and do not claim Search Console is connected unless `get_search_console_performance` confirms it (it returns a "not connected" message otherwise).
|
||||||
- Keep project setup focused on setup and context unless the user asks for live research.
|
- Keep project setup focused on setup and context unless the user asks for live research.
|
||||||
- If web search or scraping is used for positioning research, distinguish source evidence from inference.
|
- If web search or scraping is used for positioning research, distinguish source evidence from inference.
|
||||||
|
|||||||
37
adr/0003-google-search-console-integration.md
Normal file
37
adr/0003-google-search-console-integration.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Google Search Console integration
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Users previously got Search Console data into OpenSEO by manually exporting CSVs. We want the agent to read a project's real first-party search data (clicks, impressions, CTR, position) directly. Google does not charge for this data, so it should not consume credits the way DataForSEO does.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a native GSC connection plus two read-only MCP tools.
|
||||||
|
|
||||||
|
**Auth (incremental OAuth grant).** Connecting requests a read-only Search Console scope through a dedicated Better Auth `genericOAuth` provider (`google-search-console`), separate from logging in with Google. `allowDifferentEmails` lets a user connect a Google account whose email differs from their OpenSEO login (an agency connecting a client). OAuth tokens are encrypted at rest.
|
||||||
|
|
||||||
|
**Scoping.** A connection maps one verified property to one project (`gsc_connections`, unique per project). The connection belongs to the project/workspace; any member can query it, and requests run under the connecting member's grant. Property selection lives in the Integrations UI (account dropdown), not an MCP tool.
|
||||||
|
|
||||||
|
**MCP tools** — read-only, free (no Autumn metering), scoped to a project the caller's workspace owns:
|
||||||
|
|
||||||
|
- `get_search_console_performance`: thin pass-through of Search Analytics (group by query/page/country/device/date, simple AND filters, ≤1000 rows with offset paging, 16-month and ~3-day-lag clamps).
|
||||||
|
- `inspect_urls`: URL Inspection for 1–10 URLs with per-URL partial results; connection-level failures (not connected / expired grant) abort the call with a reconnect prompt.
|
||||||
|
|
||||||
|
Whether an inspected URL belongs to the property is enforced by Google's API, not re-checked locally, so both `sc-domain:` and URL-prefix properties work.
|
||||||
|
|
||||||
|
**Disconnect** removes the project's property mapping and unlinks the OAuth grant only when its connector has no other connected project — never as a side effect of a different member disconnecting.
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
Leaning on Better Auth's incremental OAuth keeps token storage and refresh out of feature code. Per-project property mapping matches how teams work (a different site per project) and keeps queries scoped to a workspace the caller owns. Treating GSC reads as free reflects that Google doesn't bill for them and makes connecting an activation hook rather than a metered cost.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The read-only scope is a Google "sensitive" scope: until the OAuth app clears verification, only test users can connect and their grant expires ~weekly.
|
||||||
|
- "Connected by" surfaces the OpenSEO member who connected, not the Google account's email.
|
||||||
|
- One property per project (re-selecting replaces it); no history or caching — every query hits Google live.
|
||||||
|
- New GSC capabilities should extend `GscService` and the MCP tools, keeping reads free and project-scoped.
|
||||||
16
drizzle/0019_true_absorbing_man.sql
Normal file
16
drizzle/0019_true_absorbing_man.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE `gsc_connections` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`organization_id` text NOT NULL,
|
||||||
|
`site_url` text NOT NULL,
|
||||||
|
`connected_by_user_id` text NOT NULL,
|
||||||
|
`connected_account_email` text,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `gsc_connections_project_idx` ON `gsc_connections` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `gsc_connections_organization_idx` ON `gsc_connections` (`organization_id`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_onboarding_answers` ADD `gsc_nudge_dismissed_at` text;
|
||||||
2568
drizzle/meta/0019_snapshot.json
Normal file
2568
drizzle/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -134,6 +134,13 @@
|
|||||||
"when": 1779849000462,
|
"when": 1779849000462,
|
||||||
"tag": "0018_minor_mad_thinker",
|
"tag": "0018_minor_mad_thinker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1780519331717,
|
||||||
|
"tag": "0019_true_absorbing_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -24,9 +24,10 @@ const toolCategories: ToolCategory[] = [
|
|||||||
description: "Read tracked keyword positions.",
|
description: "Read tracked keyword positions.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "get_keyword_search_volume",
|
name: "get_keyword_metrics",
|
||||||
title: "Get keyword search volume",
|
title: "Get keyword metrics",
|
||||||
description: "Check Google Ads volume and CPC for keyword demand.",
|
description:
|
||||||
|
"Volume, difficulty, intent, CPC, and trends for any keyword list.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "list_saved_keywords",
|
name: "list_saved_keywords",
|
||||||
@ -95,6 +96,23 @@ const toolCategories: ToolCategory[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Search Console",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "get_search_console_performance",
|
||||||
|
title: "Get Search Console performance",
|
||||||
|
description:
|
||||||
|
"Read clicks, impressions, CTR, and position from Search Console.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inspect_urls",
|
||||||
|
title: "Inspect URLs",
|
||||||
|
description:
|
||||||
|
"Check index status, crawl, and canonical for up to 10 URLs.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AvailableTools() {
|
export function AvailableTools() {
|
||||||
|
|||||||
28
src/client/features/gsc/GoogleGlyph.tsx
Normal file
28
src/client/features/gsc/GoogleGlyph.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/** Google "G" brand mark, shared across the Search Console connect surfaces. */
|
||||||
|
export function GoogleGlyph({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/client/features/gsc/GscReEngagementModal.tsx
Normal file
124
src/client/features/gsc/GscReEngagementModal.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Modal } from "@/client/components/Modal";
|
||||||
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||||
|
import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
import { getGscGrantStatus } from "@/serverFunctions/gsc";
|
||||||
|
import { dismissGscNudge } from "@/serverFunctions/onboarding";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time re-engagement prompt nudging users who finished onboarding *before*
|
||||||
|
* the Search Console step existed to connect GSC. Hosted-only (the connect flow
|
||||||
|
* needs Better Auth). Shows once — server-persisted dismissal means it never
|
||||||
|
* reappears after the user connects or dismisses, on any device.
|
||||||
|
*
|
||||||
|
* `suppressed` lets the layout hide this when another modal (e.g. the missing
|
||||||
|
* DataForSEO key prompt) is already showing so the two never stack.
|
||||||
|
*/
|
||||||
|
export function GscReEngagementModal({
|
||||||
|
projectId,
|
||||||
|
suppressed,
|
||||||
|
}: {
|
||||||
|
projectId: string | null;
|
||||||
|
suppressed: boolean;
|
||||||
|
}) {
|
||||||
|
const hosted = isHostedClientAuthMode();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [closed, setClosed] = React.useState(false);
|
||||||
|
const shownRef = React.useRef(false);
|
||||||
|
|
||||||
|
const onboardingQuery = useQuery({
|
||||||
|
...onboardingAnswersQueryOptions(),
|
||||||
|
enabled: hosted,
|
||||||
|
});
|
||||||
|
const grantQuery = useQuery({
|
||||||
|
queryKey: ["gscGrantStatus"],
|
||||||
|
queryFn: () => getGscGrantStatus(),
|
||||||
|
enabled: hosted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissMutation = useMutation({
|
||||||
|
mutationFn: () => dismissGscNudge(),
|
||||||
|
onSettled: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const eligible =
|
||||||
|
hosted &&
|
||||||
|
!suppressed &&
|
||||||
|
!closed &&
|
||||||
|
onboardingQuery.isSuccess &&
|
||||||
|
grantQuery.isSuccess &&
|
||||||
|
Boolean(onboardingQuery.data?.completedAt) &&
|
||||||
|
!onboardingQuery.data?.gscNudgeDismissedAt &&
|
||||||
|
!grantQuery.data?.connected;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (eligible && !shownRef.current) {
|
||||||
|
shownRef.current = true;
|
||||||
|
captureClientEvent("gsc:nudge_shown");
|
||||||
|
}
|
||||||
|
}, [eligible]);
|
||||||
|
|
||||||
|
if (!eligible) return null;
|
||||||
|
|
||||||
|
function persistDismiss() {
|
||||||
|
setClosed(true);
|
||||||
|
dismissMutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismiss() {
|
||||||
|
captureClientEvent("gsc:nudge_dismissed");
|
||||||
|
persistDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConnect() {
|
||||||
|
captureClientEvent("gsc:nudge_connect_clicked");
|
||||||
|
// Resolve the nudge up front: the user is leaving for Google's consent
|
||||||
|
// screen, and on return they'll either have a grant (which suppresses this
|
||||||
|
// anyway) or have abandoned it — neither case should re-nag.
|
||||||
|
persistDismiss();
|
||||||
|
// Land them on the project's Integrations page so they can pick a property
|
||||||
|
// right after granting access (the grant alone has no property bound yet).
|
||||||
|
const callbackURL = projectId
|
||||||
|
? `${window.location.origin}/p/${projectId}/integrations`
|
||||||
|
: window.location.href;
|
||||||
|
void startGscLink(callbackURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
maxWidth="max-w-lg"
|
||||||
|
onClose={handleDismiss}
|
||||||
|
labelledBy="gsc-nudge-title"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 id="gsc-nudge-title" className="text-lg font-semibold">
|
||||||
|
New: Connect Google Search Console
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
Bring your real clicks, impressions, and rankings into OpenSEO and
|
||||||
|
query them from Claude or Codex over MCP. It's free.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={handleDismiss}>
|
||||||
|
Maybe later
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConnect}
|
||||||
|
className="inline-flex items-center justify-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold text-base-content shadow-sm transition hover:bg-base-200 hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||||
|
>
|
||||||
|
<GoogleGlyph className="size-[18px]" />
|
||||||
|
Connect with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
src/client/features/gsc/SearchConsoleConnectionCard.tsx
Normal file
253
src/client/features/gsc/SearchConsoleConnectionCard.tsx
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
import { SitePicker } from "@/client/features/gsc/SitePicker";
|
||||||
|
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||||
|
import {
|
||||||
|
disconnectGsc,
|
||||||
|
getGscConnection,
|
||||||
|
listGscSites,
|
||||||
|
setGscSite,
|
||||||
|
} from "@/serverFunctions/gsc";
|
||||||
|
|
||||||
|
const GRANT_STATUS_KEY = ["gscGrantStatus"];
|
||||||
|
|
||||||
|
export function SearchConsoleConnectionCard({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
const hosted = isHostedClientAuthMode();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [picking, setPicking] = React.useState(false);
|
||||||
|
const [selectedSiteUrl, setSelectedSiteUrl] = React.useState<string>("");
|
||||||
|
|
||||||
|
const connectionKey = ["gscConnection", projectId];
|
||||||
|
const connectionQuery = useQuery({
|
||||||
|
queryKey: connectionKey,
|
||||||
|
queryFn: () => getGscConnection({ data: { projectId } }),
|
||||||
|
enabled: hosted,
|
||||||
|
});
|
||||||
|
const connection = connectionQuery.data;
|
||||||
|
const connected = Boolean(connection?.connected);
|
||||||
|
|
||||||
|
const showPicker = picking || (connection?.currentUserHasGrant && !connected);
|
||||||
|
const sitesQuery = useQuery({
|
||||||
|
queryKey: ["gscSites", projectId],
|
||||||
|
queryFn: () => listGscSites({ data: { projectId } }),
|
||||||
|
enabled: Boolean(showPicker),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setSiteMutation = useMutation({
|
||||||
|
mutationFn: (siteUrl: string) =>
|
||||||
|
setGscSite({ data: { projectId, siteUrl } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
captureClientEvent("gsc:property_select");
|
||||||
|
toast.success("Search Console connected");
|
||||||
|
setPicking(false);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const disconnectMutation = useMutation({
|
||||||
|
mutationFn: () => disconnectGsc({ data: { projectId } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Search Console disconnected");
|
||||||
|
setPicking(false);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
||||||
|
// Disconnect can drop the account-level grant server-side; keep the
|
||||||
|
// shared grant-status cache (onboarding step + re-engagement nudge) honest.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConnect = () => void startGscLink(window.location.href);
|
||||||
|
|
||||||
|
if (!hosted) {
|
||||||
|
return (
|
||||||
|
<IntegrationCard>
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
|
||||||
|
</p>
|
||||||
|
</IntegrationCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegrationCard
|
||||||
|
status={
|
||||||
|
connectionQuery.isLoading
|
||||||
|
? undefined
|
||||||
|
: connected
|
||||||
|
? "connected"
|
||||||
|
: "disconnected"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{connectionQuery.isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-base-content/50">
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
Checking…
|
||||||
|
</div>
|
||||||
|
) : connected && !picking ? (
|
||||||
|
<ConnectedState
|
||||||
|
siteUrl={connection?.siteUrl ?? ""}
|
||||||
|
connectedByEmail={connection?.connectedByEmail ?? null}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedSiteUrl(connection?.siteUrl ?? "");
|
||||||
|
setPicking(true);
|
||||||
|
}}
|
||||||
|
onDisconnect={() => disconnectMutation.mutate()}
|
||||||
|
disconnecting={disconnectMutation.isPending}
|
||||||
|
/>
|
||||||
|
) : showPicker ? (
|
||||||
|
<SitePicker
|
||||||
|
loading={sitesQuery.isLoading}
|
||||||
|
error={sitesQuery.isError}
|
||||||
|
sites={sitesQuery.data?.sites ?? []}
|
||||||
|
selectedSiteUrl={selectedSiteUrl}
|
||||||
|
onSelect={setSelectedSiteUrl}
|
||||||
|
onSave={() =>
|
||||||
|
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
|
||||||
|
}
|
||||||
|
saving={setSiteMutation.isPending}
|
||||||
|
onReconnect={handleConnect}
|
||||||
|
secondaryAction={
|
||||||
|
connected
|
||||||
|
? { label: "Cancel", onClick: () => setPicking(false) }
|
||||||
|
: {
|
||||||
|
label: "Disconnect",
|
||||||
|
destructive: true,
|
||||||
|
disabled: disconnectMutation.isPending,
|
||||||
|
onClick: () => disconnectMutation.mutate(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
Real clicks, impressions, and rankings. Free.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConnect}
|
||||||
|
className="inline-flex items-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold text-base-content shadow-sm transition hover:bg-base-200 hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||||
|
>
|
||||||
|
<GoogleGlyph className="size-[18px]" />
|
||||||
|
Connect with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</IntegrationCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Card shell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function IntegrationCard({
|
||||||
|
status,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
status?: "connected" | "disconnected";
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-4 p-5 sm:p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold leading-tight">
|
||||||
|
Google Search Console
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-sm text-base-content/55">
|
||||||
|
Your search data, straight from Google.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{status ? <StatusPill status={status} /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-base-300 p-5 sm:p-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: "connected" | "disconnected" }) {
|
||||||
|
const connected = status === "connected";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||||
|
connected
|
||||||
|
? "border-success/30 bg-success/10 text-success"
|
||||||
|
: "border-base-300 bg-base-200 text-base-content/60",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
connected ? "bg-success" : "bg-base-content/40",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
{connected ? "Connected" : "Not connected"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Connected state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ConnectedState({
|
||||||
|
siteUrl,
|
||||||
|
connectedByEmail,
|
||||||
|
onChange,
|
||||||
|
onDisconnect,
|
||||||
|
disconnecting,
|
||||||
|
}: {
|
||||||
|
siteUrl: string;
|
||||||
|
connectedByEmail: string | null;
|
||||||
|
onChange: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
disconnecting: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-base-300 bg-base-200/40 p-3.5">
|
||||||
|
<div className="grid size-9 shrink-0 place-items-center rounded-md border border-base-300 bg-base-100">
|
||||||
|
<GoogleGlyph className="size-[18px]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-mono text-sm">{siteUrl}</p>
|
||||||
|
{connectedByEmail ? (
|
||||||
|
<p className="truncate text-xs text-base-content/55">
|
||||||
|
Connected by {connectedByEmail}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onChange}
|
||||||
|
>
|
||||||
|
Change property
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm text-error hover:bg-error/10"
|
||||||
|
onClick={onDisconnect}
|
||||||
|
disabled={disconnecting}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/client/features/gsc/SitePicker.tsx
Normal file
119
src/client/features/gsc/SitePicker.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
|
||||||
|
type SiteOption = {
|
||||||
|
siteUrl: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
selectable: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SecondaryAction = {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verified-property selector for a connected Google account. Shared by the
|
||||||
|
* Integrations card and the onboarding step. `secondaryAction` is optional —
|
||||||
|
* omit it where there's nothing to cancel/disconnect (e.g. onboarding).
|
||||||
|
*/
|
||||||
|
export function SitePicker({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
sites,
|
||||||
|
selectedSiteUrl,
|
||||||
|
onSelect,
|
||||||
|
onSave,
|
||||||
|
saving,
|
||||||
|
onReconnect,
|
||||||
|
secondaryAction,
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
error: boolean;
|
||||||
|
sites: SiteOption[];
|
||||||
|
selectedSiteUrl: string;
|
||||||
|
onSelect: (siteUrl: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
onReconnect: () => void;
|
||||||
|
secondaryAction?: SecondaryAction;
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-base-content/50">
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
Loading properties…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-error">
|
||||||
|
Connection expired. Reconnect to continue.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReconnect}
|
||||||
|
className="inline-flex items-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold shadow-sm transition hover:bg-base-200"
|
||||||
|
>
|
||||||
|
<GoogleGlyph className="size-[18px]" />
|
||||||
|
Reconnect with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-base-content/80">
|
||||||
|
Property
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full max-w-md"
|
||||||
|
value={selectedSiteUrl}
|
||||||
|
onChange={(e) => onSelect(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select a property…
|
||||||
|
</option>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<option
|
||||||
|
key={site.siteUrl}
|
||||||
|
value={site.siteUrl}
|
||||||
|
disabled={!site.selectable}
|
||||||
|
>
|
||||||
|
{site.siteUrl}
|
||||||
|
{site.selectable ? "" : " (no access)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!selectedSiteUrl || saving}
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save property"}
|
||||||
|
</button>
|
||||||
|
{secondaryAction ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"btn btn-ghost btn-sm",
|
||||||
|
secondaryAction.destructive ? "text-error hover:bg-error/10" : "",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={secondaryAction.onClick}
|
||||||
|
disabled={secondaryAction.disabled}
|
||||||
|
>
|
||||||
|
{secondaryAction.label}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/client/features/gsc/startGscLink.ts
Normal file
29
src/client/features/gsc/startGscLink.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off the incremental Google Search Console OAuth grant. On success this
|
||||||
|
* redirects the whole page to Google's consent screen; `callbackURL` is where
|
||||||
|
* Google returns the user afterward. Shared by the connect card, the onboarding
|
||||||
|
* step, and the re-engagement nudge so the link/error/redirect flow stays in
|
||||||
|
* one place — callers keep their own analytics/dismissal at the call site.
|
||||||
|
*/
|
||||||
|
export async function startGscLink(callbackURL: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await authClient.oauth2.link({
|
||||||
|
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||||
|
callbackURL,
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
toast.error(res.error.message ?? "Could not start Google sign-in");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.data?.url) {
|
||||||
|
window.location.href = res.data.url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getStandardErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,10 +5,12 @@ import {
|
|||||||
CLIENT_WEBSITE_COUNT_OPTIONS,
|
CLIENT_WEBSITE_COUNT_OPTIONS,
|
||||||
CLIENT_WORK_FOR,
|
CLIENT_WORK_FOR,
|
||||||
INTEREST_OPTIONS,
|
INTEREST_OPTIONS,
|
||||||
|
ONBOARDING_LAST_STEP,
|
||||||
type OnboardingAnswers,
|
type OnboardingAnswers,
|
||||||
SOURCE_OPTIONS,
|
SOURCE_OPTIONS,
|
||||||
WORK_FOR_OPTIONS,
|
WORK_FOR_OPTIONS,
|
||||||
} from "@/client/features/onboarding/onboardingModel";
|
} from "@/client/features/onboarding/onboardingModel";
|
||||||
|
import { SearchConsoleOnboardingStep } from "@/client/features/onboarding/SearchConsoleOnboardingStep";
|
||||||
|
|
||||||
type PostSignupOnboardingProps = {
|
type PostSignupOnboardingProps = {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@ -62,7 +64,7 @@ export function PostSignupOnboarding({
|
|||||||
className="mx-auto size-10 rounded-lg"
|
className="mx-auto size-10 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-base-content/50">
|
<p className="text-xs font-medium uppercase tracking-wide text-base-content/50">
|
||||||
Step {step + 1} of 4
|
Step {step + 1} of {ONBOARDING_LAST_STEP + 1}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">
|
||||||
{title ??
|
{title ??
|
||||||
@ -120,6 +122,8 @@ export function PostSignupOnboarding({
|
|||||||
otherValue={answers.sourceOther}
|
otherValue={answers.sourceOther}
|
||||||
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
|
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
|
||||||
/>
|
/>
|
||||||
|
) : step === 3 ? (
|
||||||
|
<SearchConsoleOnboardingStep />
|
||||||
) : (
|
) : (
|
||||||
<McpRecommendation
|
<McpRecommendation
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
@ -129,7 +133,7 @@ export function PostSignupOnboarding({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step < 3 ? (
|
{step < ONBOARDING_LAST_STEP ? (
|
||||||
<div className="mt-5 flex items-center justify-between gap-3">
|
<div className="mt-5 flex items-center justify-between gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
142
src/client/features/onboarding/SearchConsoleOnboardingStep.tsx
Normal file
142
src/client/features/onboarding/SearchConsoleOnboardingStep.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
import { SitePicker } from "@/client/features/gsc/SitePicker";
|
||||||
|
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
import {
|
||||||
|
getGscConnection,
|
||||||
|
listGscSites,
|
||||||
|
setGscSite,
|
||||||
|
} from "@/serverFunctions/gsc";
|
||||||
|
import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding step for connecting Google Search Console: link the account-level
|
||||||
|
* OAuth grant, then bind a verified property to the user's default project —
|
||||||
|
* the same binding the project's Integrations page does — so it's done in one
|
||||||
|
* place. Hosted-only (the connect flow needs Better Auth).
|
||||||
|
*/
|
||||||
|
export function SearchConsoleOnboardingStep() {
|
||||||
|
const hosted = isHostedClientAuthMode();
|
||||||
|
const projectQuery = useQuery({
|
||||||
|
queryKey: ["defaultProject"],
|
||||||
|
queryFn: () => getOrCreateDefaultProject(),
|
||||||
|
enabled: hosted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
Connect with Google Search Console now?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!hosted ? (
|
||||||
|
<p className="rounded-lg border border-base-300 bg-base-200/40 px-3 py-2.5 text-sm text-base-content/60">
|
||||||
|
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
|
||||||
|
</p>
|
||||||
|
) : projectQuery.data ? (
|
||||||
|
<GscConnect projectId={projectQuery.data.id} />
|
||||||
|
) : (
|
||||||
|
<Checking />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs leading-relaxed text-base-content/55">
|
||||||
|
For now, Search Console data flows through the OpenSEO MCP. We're
|
||||||
|
building it into the OpenSEO app soon too.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect + pick-a-property flow, scoped to a known project. */
|
||||||
|
function GscConnect({ projectId }: { projectId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedSiteUrl, setSelectedSiteUrl] = React.useState("");
|
||||||
|
|
||||||
|
const connectionKey = ["gscConnection", projectId];
|
||||||
|
const connectionQuery = useQuery({
|
||||||
|
queryKey: connectionKey,
|
||||||
|
queryFn: () => getGscConnection({ data: { projectId } }),
|
||||||
|
});
|
||||||
|
const connection = connectionQuery.data;
|
||||||
|
const connected = Boolean(connection?.connected);
|
||||||
|
const hasGrant = Boolean(connection?.currentUserHasGrant);
|
||||||
|
|
||||||
|
const sitesQuery = useQuery({
|
||||||
|
queryKey: ["gscSites", projectId],
|
||||||
|
queryFn: () => listGscSites({ data: { projectId } }),
|
||||||
|
enabled: hasGrant && !connected,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setSiteMutation = useMutation({
|
||||||
|
mutationFn: (siteUrl: string) =>
|
||||||
|
setGscSite({ data: { projectId, siteUrl } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
captureClientEvent("gsc:property_select");
|
||||||
|
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getStandardErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
captureClientEvent("onboarding:gsc_connect_clicked");
|
||||||
|
void startGscLink(window.location.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (connectionQuery.isLoading) return <Checking />;
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-success/30 bg-success/10 p-3.5 text-sm">
|
||||||
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-success/20 text-success">
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="text-base-content/80">
|
||||||
|
Connected to <span className="font-mono">{connection?.siteUrl}</span>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasGrant) {
|
||||||
|
return (
|
||||||
|
<SitePicker
|
||||||
|
loading={sitesQuery.isLoading}
|
||||||
|
error={sitesQuery.isError}
|
||||||
|
sites={sitesQuery.data?.sites ?? []}
|
||||||
|
selectedSiteUrl={selectedSiteUrl}
|
||||||
|
onSelect={setSelectedSiteUrl}
|
||||||
|
onSave={() =>
|
||||||
|
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
|
||||||
|
}
|
||||||
|
saving={setSiteMutation.isPending}
|
||||||
|
onReconnect={handleConnect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConnect}
|
||||||
|
className="inline-flex items-center gap-2.5 rounded-lg border border-base-300 bg-base-100 px-4 py-2.5 text-sm font-semibold text-base-content shadow-sm transition hover:bg-base-200 hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||||
|
>
|
||||||
|
<GoogleGlyph className="size-[18px]" />
|
||||||
|
Connect with Google
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Checking() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-base-content/50">
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
Checking…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { getOnboardingAnswers } from "@/serverFunctions/onboarding";
|
import { getOnboardingAnswers } from "@/serverFunctions/onboarding";
|
||||||
|
|
||||||
export const ONBOARDING_LAST_STEP = 3;
|
export const ONBOARDING_LAST_STEP = 4;
|
||||||
|
|
||||||
export const INTEREST_OPTIONS = [
|
export const INTEREST_OPTIONS = [
|
||||||
"AI workflows with Claude or Codex (MCP)",
|
"AI workflows with Claude or Codex (MCP)",
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
CircleHelp,
|
CircleHelp,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Menu,
|
Menu,
|
||||||
|
Plug,
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -15,7 +16,11 @@ import {
|
|||||||
MissingSeoSetupModal,
|
MissingSeoSetupModal,
|
||||||
SeoApiStatusBanners,
|
SeoApiStatusBanners,
|
||||||
} from "@/client/layout/AppShellParts";
|
} from "@/client/layout/AppShellParts";
|
||||||
import { getProjectNavGroups } from "@/client/navigation/items";
|
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
|
||||||
|
import {
|
||||||
|
getProjectNavGroups,
|
||||||
|
integrationsLinkOptions,
|
||||||
|
} from "@/client/navigation/items";
|
||||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { BILLING_ROUTE } from "@/shared/billing";
|
import { BILLING_ROUTE } from "@/shared/billing";
|
||||||
@ -138,6 +143,11 @@ export function AuthenticatedAppLayout({
|
|||||||
isOpen={shouldShowMissingSeoApiKeyModal}
|
isOpen={shouldShowMissingSeoApiKeyModal}
|
||||||
onClose={() => setShowMissingSeoApiKeyModal(false)}
|
onClose={() => setShowMissingSeoApiKeyModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GscReEngagementModal
|
||||||
|
projectId={headerProjectId}
|
||||||
|
suppressed={shouldShowMissingSeoApiKeyModal}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -290,16 +300,22 @@ function TopNav({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccountMenu />
|
<AccountMenu projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccountMenu mobileOnly />
|
<AccountMenu mobileOnly projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
function AccountMenu({
|
||||||
|
mobileOnly = false,
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
mobileOnly?: boolean;
|
||||||
|
projectId?: string | null;
|
||||||
|
}) {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
const email = session?.user?.email;
|
const email = session?.user?.email;
|
||||||
@ -344,6 +360,17 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
|
{projectId ? (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
{...integrationsLinkOptions(projectId)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plug className="h-4 w-4" />
|
||||||
|
Integrations
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
<li>
|
<li>
|
||||||
<Link to="/settings" className="flex items-center gap-2">
|
<Link to="/settings" className="flex items-center gap-2">
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
|
|||||||
@ -120,6 +120,16 @@ export function getProjectNavGroups(projectId: string) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Integrations is a setup surface, so it lives in the account dropdown rather
|
||||||
|
// than the workflow nav — but it's still project-scoped (a property is bound
|
||||||
|
// per project), hence the projectId param.
|
||||||
|
export function integrationsLinkOptions(projectId: string) {
|
||||||
|
return linkOptions({
|
||||||
|
to: "/p/$projectId/integrations" as const,
|
||||||
|
params: { projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const dataforseoHelpLinkOptions = linkOptions({
|
export const dataforseoHelpLinkOptions = linkOptions({
|
||||||
to: "/help/dataforseo-api-key",
|
to: "/help/dataforseo-api-key",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -34,6 +34,10 @@ export const userOnboardingAnswers = sqliteTable(
|
|||||||
foundVia: text("found_via"),
|
foundVia: text("found_via"),
|
||||||
mcpSetupIntent: text("mcp_setup_intent"),
|
mcpSetupIntent: text("mcp_setup_intent"),
|
||||||
completedAt: text("completed_at"),
|
completedAt: text("completed_at"),
|
||||||
|
// Set when the user dismisses (or acts on) the one-time "connect Search
|
||||||
|
// Console" nudge shown to people who finished onboarding before the GSC
|
||||||
|
// step existed. Null = never shown/dismissed.
|
||||||
|
gscNudgeDismissedAt: text("gsc_nudge_dismissed_at"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
|||||||
38
src/db/gsc.schema.ts
Normal file
38
src/db/gsc.schema.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { sqliteTable, text, uniqueIndex, index } from "drizzle-orm/sqlite-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { organization } from "./better-auth-schema";
|
||||||
|
import { projects } from "./app.schema";
|
||||||
|
|
||||||
|
// Connected Google Search Console property per project (hosted-only).
|
||||||
|
// OAuth tokens live in the better-auth `account` table under providerId
|
||||||
|
// "google-search-console"; this row only records which verified property maps
|
||||||
|
// to a project and whose grant to use when calling the GSC API.
|
||||||
|
export const gscConnections = sqliteTable(
|
||||||
|
"gsc_connections",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
organizationId: text("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
|
// Stored verbatim from sites.list — "sc-domain:example.com" or
|
||||||
|
// "https://example.com/". Never normalize; GSC matches it byte-for-byte.
|
||||||
|
siteUrl: text("site_url").notNull(),
|
||||||
|
// Whose google-search-console grant getAccessToken should use.
|
||||||
|
connectedByUserId: text("connected_by_user_id").notNull(),
|
||||||
|
connectedAccountEmail: text("connected_account_email"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// One selected property per project in v1; switching replaces the row.
|
||||||
|
uniqueIndex("gsc_connections_project_idx").on(table.projectId),
|
||||||
|
index("gsc_connections_organization_idx").on(table.organizationId),
|
||||||
|
],
|
||||||
|
);
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./app.schema";
|
export * from "./app.schema";
|
||||||
export * from "./better-auth-schema";
|
export * from "./better-auth-schema";
|
||||||
|
export * from "./gsc.schema";
|
||||||
export * from "./reddit-attribution.schema";
|
export * from "./reddit-attribution.schema";
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import {
|
import {
|
||||||
|
genericOAuthClient,
|
||||||
inferAdditionalFields,
|
inferAdditionalFields,
|
||||||
organizationClient,
|
organizationClient,
|
||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
@ -11,6 +12,7 @@ export const authClient = createAuthClient({
|
|||||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
plugins: [
|
plugins: [
|
||||||
organizationClient(),
|
organizationClient(),
|
||||||
|
genericOAuthClient(),
|
||||||
inferAdditionalFields({ user: userAdditionalFields }),
|
inferAdditionalFields({ user: userAdditionalFields }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,47 @@
|
|||||||
import { organization } from "better-auth/plugins";
|
import { env } from "cloudflare:workers";
|
||||||
|
import { genericOAuth, organization } from "better-auth/plugins";
|
||||||
import { baseAuthOptions } from "@/lib/auth-options";
|
import { baseAuthOptions } from "@/lib/auth-options";
|
||||||
|
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||||
|
|
||||||
|
/** Read-only Search Console scope. openid/email/profile are also required —
|
||||||
|
* the genericOAuth callback rejects with `name_is_missing` without a name claim. */
|
||||||
|
const GSC_OAUTH_SCOPES = [
|
||||||
|
"openid",
|
||||||
|
"email",
|
||||||
|
"profile",
|
||||||
|
"https://www.googleapis.com/auth/webmasters.readonly",
|
||||||
|
];
|
||||||
|
|
||||||
export function createBaseAuthConfig() {
|
export function createBaseAuthConfig() {
|
||||||
return {
|
return {
|
||||||
...baseAuthOptions,
|
...baseAuthOptions,
|
||||||
plugins: [organization()],
|
account: {
|
||||||
|
// Encrypt OAuth access/refresh tokens at rest in D1. Also covers the
|
||||||
|
// google social-login tokens; the key derives from BETTER_AUTH_SECRET.
|
||||||
|
encryptOAuthTokens: true,
|
||||||
|
accountLinking: {
|
||||||
|
// Allow connecting a Google account whose email differs from the
|
||||||
|
// logged-in user's (agency/freelancer managing a client's property).
|
||||||
|
allowDifferentEmails: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
organization(),
|
||||||
|
genericOAuth({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||||
|
clientId: env.GOOGLE_CLIENT_ID?.trim() ?? "",
|
||||||
|
clientSecret: env.GOOGLE_CLIENT_SECRET?.trim() ?? "",
|
||||||
|
discoveryUrl:
|
||||||
|
"https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
scopes: GSC_OAUTH_SCOPES,
|
||||||
|
accessType: "offline", // request a refresh token
|
||||||
|
prompt: "consent", // force refresh-token issuance on re-consent
|
||||||
|
pkce: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,5 +24,9 @@ export function isHostedClientAuthMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isEmailVerificationBypassed() {
|
export function isEmailVerificationBypassed() {
|
||||||
|
// Local-dev escape hatch (BYPASS_EMAIL_VERIFICATION=true). The server skips
|
||||||
|
// verification and never marks users emailVerified, so the client must treat
|
||||||
|
// the session as verified too — otherwise route guards and /verify-email
|
||||||
|
// bounce each other in an infinite redirect loop.
|
||||||
return import.meta.env.BYPASS_EMAIL_VERIFICATION === "true";
|
return import.meta.env.BYPASS_EMAIL_VERIFICATION === "true";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/
|
|||||||
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
||||||
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
|
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
|
||||||
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
||||||
|
import { Route as ProjectPProjectIdIntegrationsRouteImport } from './routes/_project/p/$projectId/integrations'
|
||||||
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
||||||
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
|
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
|
||||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||||
@ -181,6 +182,12 @@ const ProjectPProjectIdKeywordsRoute =
|
|||||||
path: '/keywords',
|
path: '/keywords',
|
||||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectPProjectIdIntegrationsRoute =
|
||||||
|
ProjectPProjectIdIntegrationsRouteImport.update({
|
||||||
|
id: '/integrations',
|
||||||
|
path: '/integrations',
|
||||||
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
|
} as any)
|
||||||
const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
|
const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
|
||||||
id: '/domain',
|
id: '/domain',
|
||||||
path: '/domain',
|
path: '/domain',
|
||||||
@ -251,6 +258,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
|
'/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
||||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
@ -282,6 +290,7 @@ export interface FileRoutesByTo {
|
|||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
|
'/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
||||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||||
@ -319,6 +328,7 @@ export interface FileRoutesById {
|
|||||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
|
'/_project/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
||||||
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||||
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
@ -354,6 +364,7 @@ export interface FileRouteTypes {
|
|||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
|
| '/p/$projectId/integrations'
|
||||||
| '/p/$projectId/keywords'
|
| '/p/$projectId/keywords'
|
||||||
| '/p/$projectId/prompt-explorer'
|
| '/p/$projectId/prompt-explorer'
|
||||||
| '/p/$projectId/rank-tracking'
|
| '/p/$projectId/rank-tracking'
|
||||||
@ -385,6 +396,7 @@ export interface FileRouteTypes {
|
|||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
|
| '/p/$projectId/integrations'
|
||||||
| '/p/$projectId/keywords'
|
| '/p/$projectId/keywords'
|
||||||
| '/p/$projectId/prompt-explorer'
|
| '/p/$projectId/prompt-explorer'
|
||||||
| '/p/$projectId/saved'
|
| '/p/$projectId/saved'
|
||||||
@ -421,6 +433,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_project/p/$projectId/backlinks'
|
| '/_project/p/$projectId/backlinks'
|
||||||
| '/_project/p/$projectId/brand-lookup'
|
| '/_project/p/$projectId/brand-lookup'
|
||||||
| '/_project/p/$projectId/domain'
|
| '/_project/p/$projectId/domain'
|
||||||
|
| '/_project/p/$projectId/integrations'
|
||||||
| '/_project/p/$projectId/keywords'
|
| '/_project/p/$projectId/keywords'
|
||||||
| '/_project/p/$projectId/prompt-explorer'
|
| '/_project/p/$projectId/prompt-explorer'
|
||||||
| '/_project/p/$projectId/rank-tracking'
|
| '/_project/p/$projectId/rank-tracking'
|
||||||
@ -636,6 +649,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProjectPProjectIdKeywordsRouteImport
|
preLoaderRoute: typeof ProjectPProjectIdKeywordsRouteImport
|
||||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_project/p/$projectId/integrations': {
|
||||||
|
id: '/_project/p/$projectId/integrations'
|
||||||
|
path: '/integrations'
|
||||||
|
fullPath: '/p/$projectId/integrations'
|
||||||
|
preLoaderRoute: typeof ProjectPProjectIdIntegrationsRouteImport
|
||||||
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
|
}
|
||||||
'/_project/p/$projectId/domain': {
|
'/_project/p/$projectId/domain': {
|
||||||
id: '/_project/p/$projectId/domain'
|
id: '/_project/p/$projectId/domain'
|
||||||
path: '/domain'
|
path: '/domain'
|
||||||
@ -757,6 +777,7 @@ interface ProjectPProjectIdRouteRouteChildren {
|
|||||||
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
||||||
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
|
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
|
||||||
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
||||||
|
ProjectPProjectIdIntegrationsRoute: typeof ProjectPProjectIdIntegrationsRoute
|
||||||
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
||||||
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
|
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
|
||||||
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||||
@ -770,6 +791,7 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
|
|||||||
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
||||||
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
|
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
|
||||||
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
||||||
|
ProjectPProjectIdIntegrationsRoute: ProjectPProjectIdIntegrationsRoute,
|
||||||
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
||||||
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
|
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
|
||||||
ProjectPProjectIdRankTrackingRoute:
|
ProjectPProjectIdRankTrackingRoute:
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const DISCORD_URL = "https://discord.gg/c9uGs3cFXr";
|
|||||||
const SUPPORT_EMAIL = "ben@openseo.so";
|
const SUPPORT_EMAIL = "ben@openseo.so";
|
||||||
const SAM_GITHUB_URL = "https://github.com/every-app/sam";
|
const SAM_GITHUB_URL = "https://github.com/every-app/sam";
|
||||||
const SKILL_NAMES = [
|
const SKILL_NAMES = [
|
||||||
"onboarding-checklist",
|
"seo-project-setup",
|
||||||
"seo-coach",
|
"seo-coach",
|
||||||
"keyword-research",
|
"keyword-research",
|
||||||
"keyword-clustering",
|
"keyword-clustering",
|
||||||
@ -222,7 +222,7 @@ function AiPage() {
|
|||||||
<p className="text-sm text-base-content/70 leading-relaxed">
|
<p className="text-sm text-base-content/70 leading-relaxed">
|
||||||
Start with{" "}
|
Start with{" "}
|
||||||
<span className="font-mono text-base-content">
|
<span className="font-mono text-base-content">
|
||||||
/onboarding-checklist
|
/seo-project-setup
|
||||||
</span>
|
</span>
|
||||||
. It will ask about your project and help configure your
|
. It will ask about your project and help configure your
|
||||||
workspace.
|
workspace.
|
||||||
|
|||||||
25
src/routes/_project/p/$projectId/integrations.tsx
Normal file
25
src/routes/_project/p/$projectId/integrations.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_project/p/$projectId/integrations")({
|
||||||
|
component: IntegrationsRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
function IntegrationsRoute() {
|
||||||
|
const { projectId } = Route.useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="search-console"
|
||||||
|
className="mx-auto w-full max-w-4xl space-y-6 p-4 sm:p-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Integrations</h1>
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
Connect your data sources.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SearchConsoleConnectionCard projectId={projectId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { gscConnections } from "@/db/schema";
|
||||||
|
|
||||||
|
export type GscConnection = typeof gscConnections.$inferSelect;
|
||||||
|
|
||||||
|
async function getByProjectId(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<GscConnection | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(gscConnections)
|
||||||
|
.where(eq(gscConnections.projectId, projectId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsert(input: {
|
||||||
|
projectId: string;
|
||||||
|
organizationId: string;
|
||||||
|
siteUrl: string;
|
||||||
|
connectedByUserId: string;
|
||||||
|
connectedAccountEmail: string | null;
|
||||||
|
}): Promise<GscConnection> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(gscConnections)
|
||||||
|
.values({ id: crypto.randomUUID(), ...input })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: gscConnections.projectId,
|
||||||
|
set: {
|
||||||
|
siteUrl: input.siteUrl,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
connectedByUserId: input.connectedByUserId,
|
||||||
|
connectedAccountEmail: input.connectedAccountEmail,
|
||||||
|
updatedAt: sql`(current_timestamp)`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("Failed to upsert gsc_connection");
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteByProjectId(projectId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(gscConnections)
|
||||||
|
.where(eq(gscConnections.projectId, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this user is still the connector for any project's GSC property. */
|
||||||
|
async function existsForConnector(userId: string): Promise<boolean> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: gscConnections.id })
|
||||||
|
.from(gscConnections)
|
||||||
|
.where(eq(gscConnections.connectedByUserId, userId))
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GscConnectionRepository = {
|
||||||
|
getByProjectId,
|
||||||
|
upsert,
|
||||||
|
deleteByProjectId,
|
||||||
|
existsForConnector,
|
||||||
|
};
|
||||||
116
src/server/features/gsc/searchAnalytics.test.ts
Normal file
116
src/server/features/gsc/searchAnalytics.test.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildSearchAnalyticsRequest,
|
||||||
|
resolveDateRange,
|
||||||
|
} from "@/server/features/gsc/searchAnalytics";
|
||||||
|
|
||||||
|
const TODAY = new Date("2026-05-28T00:00:00Z");
|
||||||
|
|
||||||
|
describe("resolveDateRange", () => {
|
||||||
|
it("ends convenience ranges 3 days back for GSC data lag", () => {
|
||||||
|
const { endDate } = resolveDateRange({ dateRange: "last_28_days" }, TODAY);
|
||||||
|
expect(endDate).toBe("2026-05-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes a 28-day window from the lagged end", () => {
|
||||||
|
const { startDate, endDate } = resolveDateRange(
|
||||||
|
{ dateRange: "last_28_days" },
|
||||||
|
TODAY,
|
||||||
|
);
|
||||||
|
expect(startDate).toBe("2026-04-27");
|
||||||
|
expect(endDate).toBe("2026-05-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the start to the 16-month floor", () => {
|
||||||
|
const { startDate } = resolveDateRange(
|
||||||
|
{ dateRange: "last_16_months" },
|
||||||
|
TODAY,
|
||||||
|
);
|
||||||
|
// end (2026-05-25) - 16 months = 2025-01-25, but floor is today - 16 months.
|
||||||
|
expect(startDate).toBe("2025-01-28");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes explicit dates through, clamping start to the floor", () => {
|
||||||
|
const { startDate, endDate } = resolveDateRange(
|
||||||
|
{ startDate: "2020-01-01", endDate: "2026-05-01" },
|
||||||
|
TODAY,
|
||||||
|
);
|
||||||
|
expect(startDate).toBe("2025-01-28"); // clamped
|
||||||
|
expect(endDate).toBe("2026-05-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an in-range explicit start untouched", () => {
|
||||||
|
const { startDate } = resolveDateRange(
|
||||||
|
{ startDate: "2026-01-01", endDate: "2026-05-01" },
|
||||||
|
TODAY,
|
||||||
|
);
|
||||||
|
expect(startDate).toBe("2026-01-01");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSearchAnalyticsRequest", () => {
|
||||||
|
it("wraps flat filters into a single AND dimensionFilterGroup", () => {
|
||||||
|
const request = buildSearchAnalyticsRequest(
|
||||||
|
{
|
||||||
|
projectId: "p1",
|
||||||
|
dimensions: ["query"],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "equals",
|
||||||
|
expression: "https://example.com/post",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TODAY,
|
||||||
|
);
|
||||||
|
// The whole point: GSC ignores a top-level `filters` field.
|
||||||
|
expect(request).not.toHaveProperty("filters");
|
||||||
|
expect(request.dimensionFilterGroups).toEqual([
|
||||||
|
{
|
||||||
|
groupType: "and",
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "equals",
|
||||||
|
expression: "https://example.com/post",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits dimensionFilterGroups when no filters are given", () => {
|
||||||
|
const request = buildSearchAnalyticsRequest({ projectId: "p1" }, TODAY);
|
||||||
|
expect(request.dimensionFilterGroups).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults dimensions, type, dataState, and rowLimit", () => {
|
||||||
|
const request = buildSearchAnalyticsRequest({ projectId: "p1" }, TODAY);
|
||||||
|
expect(request.dimensions).toEqual(["query"]);
|
||||||
|
expect(request.type).toBe("web");
|
||||||
|
expect(request.dataState).toBe("all");
|
||||||
|
expect(request.rowLimit).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps rowLimit to the 1000 ceiling", () => {
|
||||||
|
expect(
|
||||||
|
buildSearchAnalyticsRequest({ projectId: "p1", rowLimit: 99999 }, TODAY)
|
||||||
|
.rowLimit,
|
||||||
|
).toBe(1000);
|
||||||
|
expect(
|
||||||
|
buildSearchAnalyticsRequest({ projectId: "p1", rowLimit: 0 }, TODAY)
|
||||||
|
.rowLimit,
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only includes startRow when positive", () => {
|
||||||
|
expect(
|
||||||
|
buildSearchAnalyticsRequest({ projectId: "p1" }, TODAY).startRow,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
buildSearchAnalyticsRequest({ projectId: "p1", startRow: 1000 }, TODAY)
|
||||||
|
.startRow,
|
||||||
|
).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
162
src/server/features/gsc/searchAnalytics.ts
Normal file
162
src/server/features/gsc/searchAnalytics.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import type { GscSearchAnalyticsRequest } from "@/server/lib/gscClient";
|
||||||
|
|
||||||
|
// Shared option sets — also drive the MCP tool Zod schemas so the two stay in sync.
|
||||||
|
export const GSC_DIMENSIONS = [
|
||||||
|
"query",
|
||||||
|
"page",
|
||||||
|
"country",
|
||||||
|
"device",
|
||||||
|
"date",
|
||||||
|
"searchAppearance",
|
||||||
|
] as const;
|
||||||
|
export const GSC_FILTER_OPERATORS = [
|
||||||
|
"equals",
|
||||||
|
"notEquals",
|
||||||
|
"contains",
|
||||||
|
"notContains",
|
||||||
|
] as const;
|
||||||
|
export const GSC_SEARCH_TYPES = [
|
||||||
|
"web",
|
||||||
|
"image",
|
||||||
|
"video",
|
||||||
|
"news",
|
||||||
|
"googleNews",
|
||||||
|
"discover",
|
||||||
|
] as const;
|
||||||
|
export const GSC_DATE_RANGES = [
|
||||||
|
"last_7_days",
|
||||||
|
"last_28_days",
|
||||||
|
"last_3_months",
|
||||||
|
"last_6_months",
|
||||||
|
"last_12_months",
|
||||||
|
"last_16_months",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const GSC_DEFAULT_ROW_LIMIT = 1000;
|
||||||
|
// v1 caps rows-per-call at 1000 to protect the MCP context window. The GSC API
|
||||||
|
// supports up to 25000, but we keep fetched == returned so counts stay honest;
|
||||||
|
// the agent paginates with `startRow` for more.
|
||||||
|
export const GSC_MAX_ROW_LIMIT = 1000;
|
||||||
|
// GSC data trails by ~2-3 days; default the end of convenience ranges before it.
|
||||||
|
const GSC_DATA_LAG_DAYS = 3;
|
||||||
|
|
||||||
|
export type GscDimension = (typeof GSC_DIMENSIONS)[number];
|
||||||
|
type GscFilterOperator = (typeof GSC_FILTER_OPERATORS)[number];
|
||||||
|
export type GscSearchType = (typeof GSC_SEARCH_TYPES)[number];
|
||||||
|
export type GscDateRange = (typeof GSC_DATE_RANGES)[number];
|
||||||
|
|
||||||
|
export type GscPerformanceFilter = {
|
||||||
|
dimension: GscDimension;
|
||||||
|
operator: GscFilterOperator;
|
||||||
|
expression: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GscPerformanceInput = {
|
||||||
|
projectId: string;
|
||||||
|
dimensions?: GscDimension[];
|
||||||
|
dateRange?: GscDateRange;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
filters?: GscPerformanceFilter[];
|
||||||
|
rowLimit?: number;
|
||||||
|
startRow?: number;
|
||||||
|
type?: GscSearchType;
|
||||||
|
dataState?: "all" | "final";
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractRange(end: Date, range: GscDateRange): Date {
|
||||||
|
const d = new Date(end);
|
||||||
|
switch (range) {
|
||||||
|
case "last_7_days":
|
||||||
|
d.setUTCDate(d.getUTCDate() - 7);
|
||||||
|
break;
|
||||||
|
case "last_28_days":
|
||||||
|
d.setUTCDate(d.getUTCDate() - 28);
|
||||||
|
break;
|
||||||
|
case "last_3_months":
|
||||||
|
d.setUTCMonth(d.getUTCMonth() - 3);
|
||||||
|
break;
|
||||||
|
case "last_6_months":
|
||||||
|
d.setUTCMonth(d.getUTCMonth() - 6);
|
||||||
|
break;
|
||||||
|
case "last_12_months":
|
||||||
|
d.setUTCMonth(d.getUTCMonth() - 12);
|
||||||
|
break;
|
||||||
|
case "last_16_months":
|
||||||
|
d.setUTCMonth(d.getUTCMonth() - 16);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sixteenMonthFloor(today: Date): string {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setUTCMonth(d.getUTCMonth() - 16);
|
||||||
|
return formatDate(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a convenience `dateRange` or explicit start/end into GSC dates.
|
||||||
|
* `today` is injectable for deterministic tests. */
|
||||||
|
export function resolveDateRange(
|
||||||
|
input: Pick<GscPerformanceInput, "dateRange" | "startDate" | "endDate">,
|
||||||
|
today: Date = new Date(),
|
||||||
|
): { startDate: string; endDate: string } {
|
||||||
|
const floor = sixteenMonthFloor(today);
|
||||||
|
|
||||||
|
if (input.startDate && input.endDate) {
|
||||||
|
// Clamp the start to GSC's 16-month lower bound.
|
||||||
|
const startDate = input.startDate < floor ? floor : input.startDate;
|
||||||
|
return { startDate, endDate: input.endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = new Date(today);
|
||||||
|
end.setUTCDate(end.getUTCDate() - GSC_DATA_LAG_DAYS);
|
||||||
|
const start = subtractRange(end, input.dateRange ?? "last_28_days");
|
||||||
|
const startDate = formatDate(start);
|
||||||
|
return {
|
||||||
|
startDate: startDate < floor ? floor : startDate,
|
||||||
|
endDate: formatDate(end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the GSC `searchAnalytics.query` body from validated tool input.
|
||||||
|
* Critically, flat `filters` are wrapped into `dimensionFilterGroups` — GSC
|
||||||
|
* silently ignores a top-level `filters` field. */
|
||||||
|
export function buildSearchAnalyticsRequest(
|
||||||
|
input: GscPerformanceInput,
|
||||||
|
today: Date = new Date(),
|
||||||
|
): GscSearchAnalyticsRequest {
|
||||||
|
const { startDate, endDate } = resolveDateRange(input, today);
|
||||||
|
const request: GscSearchAnalyticsRequest = {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
dimensions:
|
||||||
|
input.dimensions && input.dimensions.length > 0
|
||||||
|
? input.dimensions
|
||||||
|
: ["query"],
|
||||||
|
rowLimit: clamp(
|
||||||
|
input.rowLimit ?? GSC_DEFAULT_ROW_LIMIT,
|
||||||
|
1,
|
||||||
|
GSC_MAX_ROW_LIMIT,
|
||||||
|
),
|
||||||
|
type: input.type ?? "web",
|
||||||
|
dataState: input.dataState ?? "all",
|
||||||
|
};
|
||||||
|
if (input.startRow && input.startRow > 0) {
|
||||||
|
request.startRow = input.startRow;
|
||||||
|
}
|
||||||
|
if (input.filters && input.filters.length > 0) {
|
||||||
|
request.dimensionFilterGroups = [
|
||||||
|
{ groupType: "and", filters: input.filters },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
138
src/server/features/gsc/services/GscService.test.ts
Normal file
138
src/server/features/gsc/services/GscService.test.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
listSites: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
getByProjectId: vi.fn(),
|
||||||
|
deleteByProjectId: vi.fn(),
|
||||||
|
existsForConnector: vi.fn(),
|
||||||
|
dbDelete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("cloudflare:workers", () => ({ env: {} }));
|
||||||
|
vi.mock("@/db", () => ({ db: { delete: mocks.dbDelete } }));
|
||||||
|
vi.mock("@/server/lib/gscClient", () => ({
|
||||||
|
createGscClient: () => ({ listSites: mocks.listSites }),
|
||||||
|
GscApiError: class extends Error {},
|
||||||
|
GscTokenError: class extends Error {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/server/features/gsc/repositories/GscConnectionRepository", () => ({
|
||||||
|
GscConnectionRepository: {
|
||||||
|
upsert: mocks.upsert,
|
||||||
|
getByProjectId: mocks.getByProjectId,
|
||||||
|
deleteByProjectId: mocks.deleteByProjectId,
|
||||||
|
existsForConnector: mocks.existsForConnector,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseInput = {
|
||||||
|
projectId: "p1",
|
||||||
|
organizationId: "org1",
|
||||||
|
userId: "u1",
|
||||||
|
userEmail: "alice@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("GscService.setSite", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.listSites.mockReset();
|
||||||
|
mocks.upsert.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts a verified property using the connector's identity", async () => {
|
||||||
|
mocks.listSites.mockResolvedValue([
|
||||||
|
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||||
|
]);
|
||||||
|
mocks.upsert.mockResolvedValue({ siteUrl: "https://x/" });
|
||||||
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
|
await GscService.setSite({ ...baseInput, siteUrl: "https://x/" });
|
||||||
|
|
||||||
|
expect(mocks.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
projectId: "p1",
|
||||||
|
siteUrl: "https://x/",
|
||||||
|
connectedByUserId: "u1",
|
||||||
|
connectedAccountEmail: "alice@example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unverified property with FORBIDDEN", async () => {
|
||||||
|
mocks.listSites.mockResolvedValue([
|
||||||
|
{ siteUrl: "https://x/", permissionLevel: "siteUnverifiedUser" },
|
||||||
|
]);
|
||||||
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
GscService.setSite({ ...baseInput, siteUrl: "https://x/" }),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(mocks.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a property not on the grant with NOT_FOUND", async () => {
|
||||||
|
mocks.listSites.mockResolvedValue([
|
||||||
|
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||||
|
]);
|
||||||
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
GscService.setSite({ ...baseInput, siteUrl: "https://not-mine/" }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
expect(mocks.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GscService.disconnect", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.getByProjectId.mockReset();
|
||||||
|
mocks.deleteByProjectId.mockReset().mockResolvedValue(undefined);
|
||||||
|
mocks.existsForConnector.mockReset();
|
||||||
|
mocks.dbDelete.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlinks the connector's grant when they disconnect their last project", async () => {
|
||||||
|
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "u1" });
|
||||||
|
mocks.existsForConnector.mockResolvedValue(false);
|
||||||
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
|
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||||
|
|
||||||
|
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
||||||
|
expect(mocks.existsForConnector).toHaveBeenCalledWith("u1");
|
||||||
|
expect(mocks.dbDelete).toHaveBeenCalled(); // grant unlinked
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the grant when the connector still has another connected project", async () => {
|
||||||
|
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "u1" });
|
||||||
|
mocks.existsForConnector.mockResolvedValue(true);
|
||||||
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
|
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||||
|
|
||||||
|
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never revokes a grant when a different member disconnects the connection", async () => {
|
||||||
|
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "owner" });
|
||||||
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
|
await GscService.disconnect({ projectId: "p1", userId: "other-member" });
|
||||||
|
|
||||||
|
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
||||||
|
expect(mocks.existsForConnector).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlinks the caller's dangling grant when no property was ever bound", async () => {
|
||||||
|
// Linked Google but never picked a property → no connection row. Disconnect
|
||||||
|
// should still drop the caller's own grant.
|
||||||
|
mocks.getByProjectId.mockResolvedValue(null);
|
||||||
|
mocks.existsForConnector.mockResolvedValue(false);
|
||||||
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
|
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||||
|
|
||||||
|
expect(mocks.existsForConnector).toHaveBeenCalledWith("u1");
|
||||||
|
expect(mocks.dbDelete).toHaveBeenCalled(); // grant unlinked
|
||||||
|
});
|
||||||
|
});
|
||||||
218
src/server/features/gsc/services/GscService.ts
Normal file
218
src/server/features/gsc/services/GscService.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { account } from "@/db/schema";
|
||||||
|
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import {
|
||||||
|
createGscClient,
|
||||||
|
GscTokenError,
|
||||||
|
type GscSite,
|
||||||
|
type UrlInspectionResult,
|
||||||
|
} from "@/server/lib/gscClient";
|
||||||
|
import {
|
||||||
|
buildSearchAnalyticsRequest,
|
||||||
|
type GscPerformanceInput,
|
||||||
|
} from "@/server/features/gsc/searchAnalytics";
|
||||||
|
import {
|
||||||
|
GscConnectionRepository,
|
||||||
|
type GscConnection,
|
||||||
|
} from "@/server/features/gsc/repositories/GscConnectionRepository";
|
||||||
|
import type {
|
||||||
|
GscSearchAnalyticsRequest,
|
||||||
|
GscSearchAnalyticsRow,
|
||||||
|
} from "@/server/lib/gscClient";
|
||||||
|
|
||||||
|
const SITE_UNVERIFIED_PERMISSION = "siteUnverifiedUser";
|
||||||
|
|
||||||
|
type GscPerformanceResult = {
|
||||||
|
siteUrl: string;
|
||||||
|
connectedBy: string | null;
|
||||||
|
request: GscSearchAnalyticsRequest;
|
||||||
|
rows: GscSearchAnalyticsRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Thrown when a project has no connected GSC property. */
|
||||||
|
export class GscNotConnectedError extends Error {
|
||||||
|
constructor(public readonly projectId: string) {
|
||||||
|
super("Search Console is not connected for this project");
|
||||||
|
this.name = "GscNotConnectedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConnection(projectId: string): Promise<GscConnection | null> {
|
||||||
|
return GscConnectionRepository.getByProjectId(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this user has linked a google-search-console grant (regardless of
|
||||||
|
* whether they've picked a property yet). Drives the connect-vs-pick UI. */
|
||||||
|
async function userHasGrant(userId: string): Promise<boolean> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: account.id })
|
||||||
|
.from(account)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(account.userId, userId),
|
||||||
|
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List verified properties available on a user's google-search-console grant. */
|
||||||
|
async function listSitesForUser(userId: string): Promise<GscSite[]> {
|
||||||
|
return createGscClient({ userId }).listSites();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a verified property to a project. Rejects unverified properties and
|
||||||
|
* properties not present on the connector's grant. */
|
||||||
|
async function setSite(input: {
|
||||||
|
projectId: string;
|
||||||
|
organizationId: string;
|
||||||
|
siteUrl: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
}): Promise<GscConnection> {
|
||||||
|
const sites = await listSitesForUser(input.userId);
|
||||||
|
const match = sites.find((s) => s.siteUrl === input.siteUrl);
|
||||||
|
if (!match) {
|
||||||
|
throw new AppError(
|
||||||
|
"NOT_FOUND",
|
||||||
|
"That Search Console property isn't available on your connected Google account.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (match.permissionLevel === SITE_UNVERIFIED_PERMISSION) {
|
||||||
|
throw new AppError(
|
||||||
|
"FORBIDDEN",
|
||||||
|
"You don't have verified access to that Search Console property.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return GscConnectionRepository.upsert({
|
||||||
|
projectId: input.projectId,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
siteUrl: input.siteUrl,
|
||||||
|
connectedByUserId: input.userId,
|
||||||
|
connectedAccountEmail: input.userEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove this user's google-search-console grant (stored OAuth tokens). */
|
||||||
|
async function unlinkUserGrant(userId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(account)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(account.userId, userId),
|
||||||
|
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect(input: {
|
||||||
|
projectId: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const connection = await GscConnectionRepository.getByProjectId(
|
||||||
|
input.projectId,
|
||||||
|
);
|
||||||
|
await GscConnectionRepository.deleteByProjectId(input.projectId);
|
||||||
|
// Clean up the caller's *own* OAuth grant once none of their projects still
|
||||||
|
// use it. Safe by construction: unlinkUserGrant only ever deletes the
|
||||||
|
// caller's account row, never another member's. We skip cleanup only when the
|
||||||
|
// binding we removed belonged to a *different* member, so unbinding their
|
||||||
|
// property never revokes the caller's unrelated grant. A null connection
|
||||||
|
// means the caller linked Google but never picked a property — that dangling
|
||||||
|
// grant is theirs to drop.
|
||||||
|
if (!connection || connection.connectedByUserId === input.userId) {
|
||||||
|
const stillUsed = await GscConnectionRepository.existsForConnector(
|
||||||
|
input.userId,
|
||||||
|
);
|
||||||
|
if (!stillUsed) {
|
||||||
|
await unlinkUserGrant(input.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pass-through of GSC `searchAnalytics.query` for a project's connected property. */
|
||||||
|
async function getPerformance(
|
||||||
|
input: GscPerformanceInput,
|
||||||
|
): Promise<GscPerformanceResult> {
|
||||||
|
const connection = await GscConnectionRepository.getByProjectId(
|
||||||
|
input.projectId,
|
||||||
|
);
|
||||||
|
if (!connection) {
|
||||||
|
throw new GscNotConnectedError(input.projectId);
|
||||||
|
}
|
||||||
|
const request = buildSearchAnalyticsRequest(input);
|
||||||
|
const client = createGscClient({ userId: connection.connectedByUserId });
|
||||||
|
const rows = await client.querySearchAnalytics(connection.siteUrl, request);
|
||||||
|
return {
|
||||||
|
siteUrl: connection.siteUrl,
|
||||||
|
connectedBy: connection.connectedAccountEmail,
|
||||||
|
request,
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type GscUrlInspection = {
|
||||||
|
url: string;
|
||||||
|
result: UrlInspectionResult | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GscInspectUrlsResult = {
|
||||||
|
siteUrl: string;
|
||||||
|
connectedBy: string | null;
|
||||||
|
results: GscUrlInspection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Inspect 1–N URLs against a project's connected property. Resolves the
|
||||||
|
* connection once, then inspects each URL; per-URL failures are captured
|
||||||
|
* inline so one bad URL doesn't fail the batch. Token/grant failures
|
||||||
|
* propagate so the caller can prompt a reconnect. */
|
||||||
|
async function inspectUrls(input: {
|
||||||
|
projectId: string;
|
||||||
|
urls: string[];
|
||||||
|
languageCode?: string;
|
||||||
|
}): Promise<GscInspectUrlsResult> {
|
||||||
|
const connection = await GscConnectionRepository.getByProjectId(
|
||||||
|
input.projectId,
|
||||||
|
);
|
||||||
|
if (!connection) {
|
||||||
|
throw new GscNotConnectedError(input.projectId);
|
||||||
|
}
|
||||||
|
const client = createGscClient({ userId: connection.connectedByUserId });
|
||||||
|
const results: GscUrlInspection[] = [];
|
||||||
|
for (const url of input.urls) {
|
||||||
|
try {
|
||||||
|
const result = await client.inspectUrl(
|
||||||
|
connection.siteUrl,
|
||||||
|
url,
|
||||||
|
input.languageCode,
|
||||||
|
);
|
||||||
|
results.push({ url, result });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GscTokenError) throw error;
|
||||||
|
results.push({
|
||||||
|
url,
|
||||||
|
result: null,
|
||||||
|
error: error instanceof Error ? error.message : "Inspection failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
siteUrl: connection.siteUrl,
|
||||||
|
connectedBy: connection.connectedAccountEmail,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GscService = {
|
||||||
|
getConnection,
|
||||||
|
userHasGrant,
|
||||||
|
listSitesForUser,
|
||||||
|
setSite,
|
||||||
|
disconnect,
|
||||||
|
getPerformance,
|
||||||
|
inspectUrls,
|
||||||
|
};
|
||||||
@ -62,9 +62,6 @@ vi.mock("@/server/lib/dataforseo/serp", () => ({
|
|||||||
fetchRankCheckSerp: vi.fn(),
|
fetchRankCheckSerp: vi.fn(),
|
||||||
fetchLocalSerp: vi.fn(),
|
fetchLocalSerp: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/server/lib/dataforseo/keywordsData", () => ({
|
|
||||||
fetchKeywordSearchVolume: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/server/lib/dataforseo/business", () => ({
|
vi.mock("@/server/lib/dataforseo/business", () => ({
|
||||||
fetchBusinessListingsSearch: vi.fn(),
|
fetchBusinessListingsSearch: vi.fn(),
|
||||||
fetchQuestionsAnswers: vi.fn(),
|
fetchQuestionsAnswers: vi.fn(),
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import {
|
|||||||
fetchLocalSerp,
|
fetchLocalSerp,
|
||||||
fetchRankCheckSerp,
|
fetchRankCheckSerp,
|
||||||
} from "@/server/lib/dataforseo/serp";
|
} from "@/server/lib/dataforseo/serp";
|
||||||
import { fetchKeywordSearchVolume } from "@/server/lib/dataforseo/keywordsData";
|
|
||||||
import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse";
|
import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse";
|
||||||
import {
|
import {
|
||||||
fetchLlmAggregatedMetrics,
|
fetchLlmAggregatedMetrics,
|
||||||
@ -59,16 +58,25 @@ export { mapDataforseoPathToCreditFeature };
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a section fetcher with billing metering. Each entry on the client is
|
* Wraps a section fetcher with billing metering. Each entry on the client is
|
||||||
* `meter(customer, fetcher, creditFeature?)`, which returns a function with the
|
* `meter(customer, fetcher, defaultFeature?)`, which returns a function with the
|
||||||
* fetcher's own input type and resolves to its unwrapped `.data`.
|
* fetcher's own input type and resolves to its unwrapped `.data`.
|
||||||
|
*
|
||||||
|
* `defaultFeature` is the fallback credit feature; a caller can override it per
|
||||||
|
* call by passing `creditFeature` in the input (e.g. an MCP tool attributing
|
||||||
|
* spend to its own feature). The extra field is ignored by the fetchers, which
|
||||||
|
* read named fields rather than spreading the input.
|
||||||
*/
|
*/
|
||||||
function meter<I, T>(
|
function meter<I, T>(
|
||||||
customer: BillingCustomerContext,
|
customer: BillingCustomerContext,
|
||||||
fetcher: (input: I) => Promise<DataforseoApiResponse<T>>,
|
fetcher: (input: I) => Promise<DataforseoApiResponse<T>>,
|
||||||
creditFeature?: CreditFeature,
|
defaultFeature?: CreditFeature,
|
||||||
): (input: I) => Promise<T> {
|
): (input: I & { creditFeature?: CreditFeature }) => Promise<T> {
|
||||||
return (input: I) =>
|
return (input) =>
|
||||||
meterDataforseoCall(customer, () => fetcher(input), creditFeature);
|
meterDataforseoCall(
|
||||||
|
customer,
|
||||||
|
() => fetcher(input),
|
||||||
|
input.creditFeature ?? defaultFeature,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDataforseoClient(customer: BillingCustomerContext) {
|
export function createDataforseoClient(customer: BillingCustomerContext) {
|
||||||
@ -103,10 +111,10 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
|||||||
rankCheck: meter(customer, fetchRankCheckSerp, "rank_tracking"),
|
rankCheck: meter(customer, fetchRankCheckSerp, "rank_tracking"),
|
||||||
local: meter(customer, fetchLocalSerp, "local_seo"),
|
local: meter(customer, fetchLocalSerp, "local_seo"),
|
||||||
},
|
},
|
||||||
keywordData: {
|
|
||||||
searchVolume: meter(customer, fetchKeywordSearchVolume),
|
|
||||||
},
|
|
||||||
labs: {
|
labs: {
|
||||||
|
// Callers (e.g. the keyword-metrics MCP tool) can attribute the spend to
|
||||||
|
// their own feature by passing `creditFeature` in the input; defaults to
|
||||||
|
// rank_tracking when omitted.
|
||||||
keywordOverview: meter(customer, fetchKeywordOverview, "rank_tracking"),
|
keywordOverview: meter(customer, fetchKeywordOverview, "rank_tracking"),
|
||||||
serpCompetitors: meter(customer, fetchSerpCompetitors),
|
serpCompetitors: meter(customer, fetchSerpCompetitors),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
BacklinksApi,
|
BacklinksApi,
|
||||||
BusinessDataApi,
|
BusinessDataApi,
|
||||||
DataforseoLabsApi,
|
DataforseoLabsApi,
|
||||||
KeywordsDataApi,
|
|
||||||
OnPageApi,
|
OnPageApi,
|
||||||
SerpApi,
|
SerpApi,
|
||||||
} from "dataforseo-client";
|
} from "dataforseo-client";
|
||||||
@ -101,7 +100,6 @@ function http(classify?: DataforseoErrorClassifier) {
|
|||||||
// read lazily (it lives in the Worker env, not in module scope).
|
// read lazily (it lives in the Worker env, not in module scope).
|
||||||
export const labsApi = () => new DataforseoLabsApi(API_BASE, http());
|
export const labsApi = () => new DataforseoLabsApi(API_BASE, http());
|
||||||
export const serpApi = () => new SerpApi(API_BASE, http());
|
export const serpApi = () => new SerpApi(API_BASE, http());
|
||||||
export const keywordsDataApi = () => new KeywordsDataApi(API_BASE, http());
|
|
||||||
export const businessDataApi = () => new BusinessDataApi(API_BASE, http());
|
export const businessDataApi = () => new BusinessDataApi(API_BASE, http());
|
||||||
export const onPageApi = () => new OnPageApi(API_BASE, http());
|
export const onPageApi = () => new OnPageApi(API_BASE, http());
|
||||||
export const backlinksApi = (classify?: DataforseoErrorClassifier) =>
|
export const backlinksApi = (classify?: DataforseoErrorClassifier) =>
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import {
|
|||||||
fetchLlmResponse,
|
fetchLlmResponse,
|
||||||
fetchLlmTopPages,
|
fetchLlmTopPages,
|
||||||
} from "@/server/lib/dataforseo/ai";
|
} from "@/server/lib/dataforseo/ai";
|
||||||
import { fetchKeywordSearchVolume } from "@/server/lib/dataforseo/keywordsData";
|
|
||||||
|
|
||||||
function parseDataforseoRequestBody(init: RequestInit | undefined): unknown {
|
function parseDataforseoRequestBody(init: RequestInit | undefined): unknown {
|
||||||
const body = init?.body;
|
const body = init?.body;
|
||||||
@ -84,53 +83,6 @@ describe("DataForSEO SDK-backed endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send location_name for keyword search volume", async () => {
|
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
|
||||||
Response.json({
|
|
||||||
status_code: 20000,
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
status_code: 20000,
|
|
||||||
path: [
|
|
||||||
"v3",
|
|
||||||
"keywords_data",
|
|
||||||
"google_ads",
|
|
||||||
"search_volume",
|
|
||||||
"live",
|
|
||||||
],
|
|
||||||
cost: 0.0001,
|
|
||||||
result_count: 1,
|
|
||||||
result: [
|
|
||||||
{
|
|
||||||
keyword: "storage units",
|
|
||||||
location_code: 2840,
|
|
||||||
language_code: "en",
|
|
||||||
search_volume: 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
await fetchKeywordSearchVolume({
|
|
||||||
keywords: ["storage units"],
|
|
||||||
locationCode: 2840,
|
|
||||||
languageCode: "en",
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = parseDataforseoRequestBody(fetchMock.mock.calls[0]?.[1]);
|
|
||||||
expect(payload).toEqual([
|
|
||||||
{
|
|
||||||
keywords: ["storage units"],
|
|
||||||
location_code: 2840,
|
|
||||||
language_code: "en",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(JSON.stringify(payload)).not.toContain("location_name");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes LLM mentions domain targets for all live endpoints", async () => {
|
it("serializes LLM mentions domain targets for all live endpoints", async () => {
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockImplementation((url) => {
|
const fetchMock = vi.fn<typeof fetch>().mockImplementation((url) => {
|
||||||
const path =
|
const path =
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// Public surface of the DataForSEO integration. Internals live in the
|
// Public surface of the DataForSEO integration. Internals live in the
|
||||||
// per-section files (labs / serp / keywordsData / business / backlinks / ai /
|
// per-section files (labs / serp / business / backlinks / ai /
|
||||||
// lighthouse); everything funnels through envelope.ts (status + billing) and is
|
// lighthouse); everything funnels through envelope.ts (status + billing) and is
|
||||||
// metered in client.ts.
|
// metered in client.ts.
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ export {
|
|||||||
type LabsKeywordDataItem,
|
type LabsKeywordDataItem,
|
||||||
type DomainRankedKeywordItem,
|
type DomainRankedKeywordItem,
|
||||||
type RelevantPagesItem,
|
type RelevantPagesItem,
|
||||||
|
type KeywordOverviewItem,
|
||||||
} from "@/server/lib/dataforseo/labs";
|
} from "@/server/lib/dataforseo/labs";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import {
|
|
||||||
KeywordsDataGoogleAdsSearchVolumeLiveRequestInfo,
|
|
||||||
type KeywordsDataGoogleAdsSearchVolumeLiveResultInfo,
|
|
||||||
} from "dataforseo-client";
|
|
||||||
import { keywordsDataApi } from "@/server/lib/dataforseo/core";
|
|
||||||
import {
|
|
||||||
assertOk,
|
|
||||||
buildTaskBilling,
|
|
||||||
type DataforseoApiResponse,
|
|
||||||
} from "@/server/lib/dataforseo/envelope";
|
|
||||||
|
|
||||||
// The search_volume live response returns keyword rows directly as the task
|
|
||||||
// result array (no nested `items`). `competition` is a string enum here
|
|
||||||
// ("LOW"/"MEDIUM"/"HIGH"), distinct from the numeric Labs competition.
|
|
||||||
type KeywordSearchVolumeRow = KeywordsDataGoogleAdsSearchVolumeLiveResultInfo;
|
|
||||||
|
|
||||||
export async function fetchKeywordSearchVolume(input: {
|
|
||||||
keywords: string[];
|
|
||||||
locationCode?: number;
|
|
||||||
languageCode?: string;
|
|
||||||
}): Promise<DataforseoApiResponse<KeywordSearchVolumeRow[]>> {
|
|
||||||
const response = await keywordsDataApi().googleAdsSearchVolumeLive([
|
|
||||||
new KeywordsDataGoogleAdsSearchVolumeLiveRequestInfo({
|
|
||||||
keywords: input.keywords,
|
|
||||||
location_code: input.locationCode,
|
|
||||||
language_code: input.languageCode,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const task = assertOk(response);
|
|
||||||
return {
|
|
||||||
data: task.result ?? [],
|
|
||||||
billing: buildTaskBilling(task),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -29,7 +29,7 @@ export type LabsKeywordDataItem = KeywordDataInfo;
|
|||||||
type RelatedKeywordItem = DataforseoLabsRelatedKeywordsLiveItem;
|
type RelatedKeywordItem = DataforseoLabsRelatedKeywordsLiveItem;
|
||||||
type DomainMetricsItem = DataforseoLabsDomainRankOverviewLiveItem;
|
type DomainMetricsItem = DataforseoLabsDomainRankOverviewLiveItem;
|
||||||
export type RelevantPagesItem = DataforseoLabsRelevantPagesLiveItem;
|
export type RelevantPagesItem = DataforseoLabsRelevantPagesLiveItem;
|
||||||
type KeywordOverviewItem = DataforseoLabsGoogleKeywordOverviewLiveItem;
|
export type KeywordOverviewItem = DataforseoLabsGoogleKeywordOverviewLiveItem;
|
||||||
type SerpCompetitorItem = DataforseoLabsSerpCompetitorsLiveItem;
|
type SerpCompetitorItem = DataforseoLabsSerpCompetitorsLiveItem;
|
||||||
|
|
||||||
// Ranked keywords is the one Labs endpoint the SDK types loosely: its
|
// Ranked keywords is the one Labs endpoint the SDK types loosely: its
|
||||||
|
|||||||
124
src/server/lib/gscClient.test.ts
Normal file
124
src/server/lib/gscClient.test.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getAccessToken: vi.fn(),
|
||||||
|
fetch: vi.fn<typeof fetch>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
getAuth: () => ({ api: { getAccessToken: mocks.getAccessToken } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200) {
|
||||||
|
return Response.json(body, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gscClient", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.getAccessToken.mockReset();
|
||||||
|
mocks.getAccessToken.mockResolvedValue({ accessToken: "tok_123" });
|
||||||
|
mocks.fetch.mockReset();
|
||||||
|
vi.stubGlobal("fetch", mocks.fetch);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists sites with a bearer token", async () => {
|
||||||
|
mocks.fetch.mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
siteEntry: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { createGscClient } = await import("./gscClient");
|
||||||
|
const sites = await createGscClient({ userId: "u1" }).listSites();
|
||||||
|
|
||||||
|
expect(sites).toHaveLength(1);
|
||||||
|
const [url, init] = mocks.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe("https://www.googleapis.com/webmasters/v3/sites");
|
||||||
|
expect(init?.headers).toMatchObject({ Authorization: "Bearer tok_123" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes the siteUrl in the searchAnalytics path (both property forms)", async () => {
|
||||||
|
mocks.fetch.mockImplementation(async () => jsonResponse({ rows: [] }));
|
||||||
|
const { createGscClient } = await import("./gscClient");
|
||||||
|
const client = createGscClient({ userId: "u1" });
|
||||||
|
|
||||||
|
await client.querySearchAnalytics("sc-domain:example.com", {
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-01-28",
|
||||||
|
});
|
||||||
|
expect(mocks.fetch.mock.calls[0][0]).toBe(
|
||||||
|
"https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Aexample.com/searchAnalytics/query",
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.querySearchAnalytics("https://example.com/", {
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-01-28",
|
||||||
|
});
|
||||||
|
expect(mocks.fetch.mock.calls[1][0]).toBe(
|
||||||
|
"https://www.googleapis.com/webmasters/v3/sites/https%3A%2F%2Fexample.com%2F/searchAnalytics/query",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts to the URL Inspection endpoint and returns the result", async () => {
|
||||||
|
mocks.fetch.mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
inspectionResult: {
|
||||||
|
indexStatusResult: { verdict: "PASS", coverageState: "Indexed" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { createGscClient } = await import("./gscClient");
|
||||||
|
const result = await createGscClient({ userId: "u1" }).inspectUrl(
|
||||||
|
"sc-domain:example.com",
|
||||||
|
"https://example.com/post",
|
||||||
|
"en-US",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [url, init] = mocks.fetch.mock.calls[0];
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://searchconsole.googleapis.com/v1/urlInspection/index:inspect",
|
||||||
|
);
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(init?.headers).toMatchObject({ Authorization: "Bearer tok_123" });
|
||||||
|
const body = init?.body;
|
||||||
|
const payload =
|
||||||
|
typeof body === "string" ? (JSON.parse(body) as unknown) : null;
|
||||||
|
expect(payload).toEqual({
|
||||||
|
siteUrl: "sc-domain:example.com",
|
||||||
|
inspectionUrl: "https://example.com/post",
|
||||||
|
languageCode: "en-US",
|
||||||
|
});
|
||||||
|
expect(result?.indexStatusResult?.verdict).toBe("PASS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 403 to a no-access GscApiError", async () => {
|
||||||
|
mocks.fetch.mockImplementation(async () =>
|
||||||
|
jsonResponse({ error: "forbidden" }, 403),
|
||||||
|
);
|
||||||
|
const { createGscClient, GscApiError } = await import("./gscClient");
|
||||||
|
await expect(
|
||||||
|
createGscClient({ userId: "u1" }).listSites(),
|
||||||
|
).rejects.toMatchObject({ status: 403 });
|
||||||
|
await expect(
|
||||||
|
createGscClient({ userId: "u1" }).listSites(),
|
||||||
|
).rejects.toBeInstanceOf(GscApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 429 to a rate-limit GscApiError", async () => {
|
||||||
|
mocks.fetch.mockResolvedValue(jsonResponse({ error: "slow down" }, 429));
|
||||||
|
const { createGscClient } = await import("./gscClient");
|
||||||
|
await expect(
|
||||||
|
createGscClient({ userId: "u1" }).listSites(),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws GscTokenError when no access token can be minted", async () => {
|
||||||
|
mocks.getAccessToken.mockRejectedValue(new Error("revoked"));
|
||||||
|
const { createGscClient, GscTokenError } = await import("./gscClient");
|
||||||
|
await expect(
|
||||||
|
createGscClient({ userId: "u1" }).listSites(),
|
||||||
|
).rejects.toBeInstanceOf(GscTokenError);
|
||||||
|
});
|
||||||
|
});
|
||||||
193
src/server/lib/gscClient.ts
Normal file
193
src/server/lib/gscClient.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { getAuth } from "@/lib/auth";
|
||||||
|
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||||
|
|
||||||
|
const GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
|
||||||
|
|
||||||
|
/** A GSC REST call returned a non-2xx status. `status` drives user-facing messaging. */
|
||||||
|
export class GscApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
public readonly body?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "GscApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No fresh access token could be minted — the user revoked the grant, or the
|
||||||
|
* refresh token expired (e.g. weekly in Google's OAuth "Testing" mode). */
|
||||||
|
export class GscTokenError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly cause?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "GscTokenError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GscSite = {
|
||||||
|
siteUrl: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GscSearchAnalyticsRow = {
|
||||||
|
keys?: string[];
|
||||||
|
clicks: number;
|
||||||
|
impressions: number;
|
||||||
|
ctr: number;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GscDimensionFilter = {
|
||||||
|
dimension: string;
|
||||||
|
operator: string;
|
||||||
|
expression: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GscSearchAnalyticsRequest = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
dimensions?: string[];
|
||||||
|
dimensionFilterGroups?: Array<{
|
||||||
|
groupType: "and" | "or";
|
||||||
|
filters: GscDimensionFilter[];
|
||||||
|
}>;
|
||||||
|
rowLimit?: number;
|
||||||
|
startRow?: number;
|
||||||
|
type?: string;
|
||||||
|
dataState?: string;
|
||||||
|
aggregationType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subset of the URL Inspection API `inspectionResult` we surface. The wire
|
||||||
|
* shape is richer; extra fields are ignored. */
|
||||||
|
export type UrlInspectionResult = {
|
||||||
|
indexStatusResult?: {
|
||||||
|
verdict?: string;
|
||||||
|
coverageState?: string;
|
||||||
|
robotsTxtState?: string;
|
||||||
|
indexingState?: string;
|
||||||
|
lastCrawlTime?: string;
|
||||||
|
pageFetchState?: string;
|
||||||
|
googleCanonical?: string;
|
||||||
|
userCanonical?: string;
|
||||||
|
crawledAs?: string;
|
||||||
|
sitemap?: string[];
|
||||||
|
referringUrls?: string[];
|
||||||
|
};
|
||||||
|
mobileUsabilityResult?: { verdict?: string };
|
||||||
|
richResultsResult?: { verdict?: string };
|
||||||
|
inspectionResultLink?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function messageForStatus(status: number, body: string): string {
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return "Search Console denied access to this property (no verified permission, or the connection was revoked).";
|
||||||
|
}
|
||||||
|
if (status === 429) {
|
||||||
|
return "Search Console rate limit reached. Retry shortly.";
|
||||||
|
}
|
||||||
|
if (status === 404) {
|
||||||
|
return "Search Console property not found. It may have been removed in Search Console.";
|
||||||
|
}
|
||||||
|
return `Search Console API error (${status}): ${body.slice(0, 300)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free Google Search Console client. Unlike the DataForSEO client it does NOT
|
||||||
|
* meter credits — GSC is first-party data with no per-call cost. Access tokens
|
||||||
|
* are minted (and auto-refreshed) by Better Auth from the connector's stored
|
||||||
|
* google-search-console grant. */
|
||||||
|
export function createGscClient(opts: { userId: string }) {
|
||||||
|
async function getToken(): Promise<string> {
|
||||||
|
let result: { accessToken?: string } | undefined;
|
||||||
|
try {
|
||||||
|
// Headerless call: getAccessToken trusts body.userId only when no request
|
||||||
|
// session is present, and auto-refreshes via the genericOAuth provider.
|
||||||
|
result = await getAuth().api.getAccessToken({
|
||||||
|
body: { providerId: GSC_OAUTH_PROVIDER_ID, userId: opts.userId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new GscTokenError(
|
||||||
|
"Could not mint a Search Console access token (grant revoked or expired).",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result?.accessToken) {
|
||||||
|
throw new GscTokenError(
|
||||||
|
"Search Console returned no access token (grant revoked or expired).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
url: string,
|
||||||
|
init?: { method?: string; body?: unknown },
|
||||||
|
): Promise<T> {
|
||||||
|
const token = await getToken();
|
||||||
|
const hasBody = init?.body !== undefined;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: hasBody ? JSON.stringify(init?.body) : undefined,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
throw new GscApiError(
|
||||||
|
response.status,
|
||||||
|
messageForStatus(response.status, body),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Webmasters API `sites.list` — the verified properties on the grant. */
|
||||||
|
async listSites(): Promise<GscSite[]> {
|
||||||
|
const data = await request<{ siteEntry?: GscSite[] }>(
|
||||||
|
`${GSC_API_BASE}/sites`,
|
||||||
|
);
|
||||||
|
return data.siteEntry ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Webmasters API `searchAnalytics.query`. siteUrl is used verbatim. */
|
||||||
|
async querySearchAnalytics(
|
||||||
|
siteUrl: string,
|
||||||
|
body: GscSearchAnalyticsRequest,
|
||||||
|
): Promise<GscSearchAnalyticsRow[]> {
|
||||||
|
const data = await request<{ rows?: GscSearchAnalyticsRow[] }>(
|
||||||
|
`${GSC_API_BASE}/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`,
|
||||||
|
{ method: "POST", body },
|
||||||
|
);
|
||||||
|
return data.rows ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** URL Inspection API `urlInspection.index.inspect`. This lives on a
|
||||||
|
* different host than the Webmasters v3 base, so the full URL is passed to
|
||||||
|
* the request helper. Same `webmasters.readonly` scope. */
|
||||||
|
async inspectUrl(
|
||||||
|
siteUrl: string,
|
||||||
|
inspectionUrl: string,
|
||||||
|
languageCode?: string,
|
||||||
|
): Promise<UrlInspectionResult | null> {
|
||||||
|
const data = await request<{ inspectionResult?: UrlInspectionResult }>(
|
||||||
|
"https://searchconsole.googleapis.com/v1/urlInspection/index:inspect",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
siteUrl,
|
||||||
|
inspectionUrl,
|
||||||
|
...(languageCode ? { languageCode } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data.inspectionResult ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -42,6 +42,11 @@ describe("withMcpProjectAuth", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
mocks.getProjectForOrganization.mockReset();
|
mocks.getProjectForOrganization.mockReset();
|
||||||
|
// Default: the project belongs to the org. Individual tests override.
|
||||||
|
mocks.getProjectForOrganization.mockResolvedValue({
|
||||||
|
id: "project_123",
|
||||||
|
name: "Test",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks project access for the authenticated organization", async () => {
|
it("checks project access for the authenticated organization", async () => {
|
||||||
@ -102,4 +107,20 @@ describe("withMcpProjectAuth", () => {
|
|||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defense-in-depth: even if the project lookup ever resolves falsy instead of
|
||||||
|
// throwing (e.g. a future refactor returns null), the wrapper must still deny
|
||||||
|
// access rather than run the handler with an unauthorized projectId.
|
||||||
|
it("rejects when the project lookup resolves no project, without calling the handler", async () => {
|
||||||
|
mocks.getProjectForOrganization.mockResolvedValue(null);
|
||||||
|
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
const wrapped = withMcpProjectAuth(handler);
|
||||||
|
await expect(
|
||||||
|
wrapped({ projectId: "someone-elses-project" }, toolExtra),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
import {
|
import {
|
||||||
buildBillingCustomer,
|
buildBillingCustomer,
|
||||||
requireMcpToolAuthContext,
|
requireMcpToolAuthContext,
|
||||||
@ -12,11 +13,16 @@ type ProjectScopedArgs = {
|
|||||||
async function requireProjectAccess(extra: ToolExtra, projectId: string) {
|
async function requireProjectAccess(extra: ToolExtra, projectId: string) {
|
||||||
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
|
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
|
||||||
|
|
||||||
// This lookup enforces that the project belongs to the authenticated org.
|
// Authorize the caller-supplied projectId against the token's organization.
|
||||||
await ProjectService.getProjectForOrganization(
|
// Assert on the result instead of relying on the lookup throwing, so this
|
||||||
|
// stays a hard gate even if the service's error behavior ever changes.
|
||||||
|
const project = await ProjectService.getProjectForOrganization(
|
||||||
auth.organizationId,
|
auth.organizationId,
|
||||||
projectId,
|
projectId,
|
||||||
);
|
);
|
||||||
|
if (!project) {
|
||||||
|
throw new AppError("FORBIDDEN");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
auth,
|
auth,
|
||||||
|
|||||||
@ -9,13 +9,17 @@ import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
|
|||||||
import {
|
import {
|
||||||
findSerpCompetitorsTool,
|
findSerpCompetitorsTool,
|
||||||
getGoogleBusinessQuestionsTool,
|
getGoogleBusinessQuestionsTool,
|
||||||
getKeywordSearchVolumeTool,
|
getKeywordMetricsTool,
|
||||||
getLocalSerpResultsTool,
|
getLocalSerpResultsTool,
|
||||||
getRankedKeywordsTool,
|
getRankedKeywordsTool,
|
||||||
searchLocalBusinessesTool,
|
searchLocalBusinessesTool,
|
||||||
} from "@/server/mcp/tools/dataforseo-research-tools";
|
} from "@/server/mcp/tools/dataforseo-research-tools";
|
||||||
import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
|
import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
|
||||||
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
|
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
|
||||||
|
import {
|
||||||
|
getSearchConsolePerformanceTool,
|
||||||
|
inspectUrlsTool,
|
||||||
|
} from "@/server/mcp/tools/search-console-tools";
|
||||||
import { whoamiTool } from "@/server/mcp/tools/whoami";
|
import { whoamiTool } from "@/server/mcp/tools/whoami";
|
||||||
|
|
||||||
export function registerOpenSeoMcpTools(server: McpServer) {
|
export function registerOpenSeoMcpTools(server: McpServer) {
|
||||||
@ -91,8 +95,18 @@ export function registerOpenSeoMcpTools(server: McpServer) {
|
|||||||
getGoogleBusinessQuestionsTool.handler,
|
getGoogleBusinessQuestionsTool.handler,
|
||||||
);
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
getKeywordSearchVolumeTool.name,
|
getKeywordMetricsTool.name,
|
||||||
getKeywordSearchVolumeTool.config,
|
getKeywordMetricsTool.config,
|
||||||
getKeywordSearchVolumeTool.handler,
|
getKeywordMetricsTool.handler,
|
||||||
|
);
|
||||||
|
server.registerTool(
|
||||||
|
getSearchConsolePerformanceTool.name,
|
||||||
|
getSearchConsolePerformanceTool.config,
|
||||||
|
getSearchConsolePerformanceTool.handler,
|
||||||
|
);
|
||||||
|
server.registerTool(
|
||||||
|
inspectUrlsTool.name,
|
||||||
|
inspectUrlsTool.config,
|
||||||
|
inspectUrlsTool.handler,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -273,24 +273,81 @@ describe("DataForSEO research MCP tools", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts keyword volume rows by numeric competition index", async () => {
|
it("normalizes keyword_overview rows with difficulty and intent", async () => {
|
||||||
const searchVolume = vi.fn().mockResolvedValue([
|
const keywordOverview = vi.fn().mockResolvedValue([
|
||||||
{ keyword: "low", competition: "LOW", competition_index: 10 },
|
{
|
||||||
{ keyword: "high", competition: "HIGH", competition_index: 90 },
|
keyword: "seo automation",
|
||||||
{ keyword: "medium", competition: "MEDIUM", competition_index: 50 },
|
keyword_info: {
|
||||||
|
search_volume: 2400,
|
||||||
|
cpc: 25.6,
|
||||||
|
competition: 0.24,
|
||||||
|
competition_level: "LOW",
|
||||||
|
},
|
||||||
|
keyword_properties: { keyword_difficulty: 18 },
|
||||||
|
search_intent_info: { main_intent: "commercial" },
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
mocks.createDataforseoClient.mockReturnValue({
|
mocks.createDataforseoClient.mockReturnValue({
|
||||||
keywordData: { searchVolume },
|
labs: { keywordOverview },
|
||||||
});
|
});
|
||||||
const { getKeywordSearchVolumeTool } =
|
const { getKeywordMetricsTool } =
|
||||||
await import("./dataforseo-research-tools");
|
await import("./dataforseo-research-tools");
|
||||||
|
|
||||||
const result = await getKeywordSearchVolumeTool.handler(
|
const result = await getKeywordMetricsTool.handler(
|
||||||
|
{ projectId: "project_1", keywords: ["seo automation"] },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(keywordOverview).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
keywords: ["seo automation"],
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
creditFeature: "keyword_research",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rows = z
|
||||||
|
.object({
|
||||||
|
keywords: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
keyword: z.string(),
|
||||||
|
search_volume: z.number().nullable(),
|
||||||
|
keyword_difficulty: z.number().nullable(),
|
||||||
|
main_intent: z.string().nullable(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.parse(result.structuredContent).keywords;
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
keyword: "seo automation",
|
||||||
|
search_volume: 2400,
|
||||||
|
keyword_difficulty: 18,
|
||||||
|
main_intent: "commercial",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts keyword metric rows by the requested numeric field", async () => {
|
||||||
|
const keywordOverview = vi.fn().mockResolvedValue([
|
||||||
|
{ keyword: "low", keyword_info: { search_volume: 10 } },
|
||||||
|
{ keyword: "high", keyword_info: { search_volume: 90 } },
|
||||||
|
{ keyword: "medium", keyword_info: { search_volume: 50 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
mocks.createDataforseoClient.mockReturnValue({
|
||||||
|
labs: { keywordOverview },
|
||||||
|
});
|
||||||
|
const { getKeywordMetricsTool } =
|
||||||
|
await import("./dataforseo-research-tools");
|
||||||
|
|
||||||
|
const result = await getKeywordMetricsTool.handler(
|
||||||
{
|
{
|
||||||
projectId: "project_1",
|
projectId: "project_1",
|
||||||
keywords: ["low", "high", "medium"],
|
keywords: ["low", "high", "medium"],
|
||||||
sortBy: "competition",
|
sortBy: "search_volume",
|
||||||
},
|
},
|
||||||
toolExtra,
|
toolExtra,
|
||||||
);
|
);
|
||||||
@ -302,42 +359,36 @@ describe("DataForSEO research MCP tools", () => {
|
|||||||
expect(rows.map((row) => row.keyword)).toEqual(["high", "medium", "low"]);
|
expect(rows.map((row) => row.keyword)).toEqual(["high", "medium", "low"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults empty keyword volume market objects to United States", async () => {
|
it("drops monthly trends when includeMonthlyTrends is false", async () => {
|
||||||
const searchVolume = vi
|
const keywordOverview = vi.fn().mockResolvedValue([
|
||||||
.fn()
|
{
|
||||||
.mockResolvedValue([{ keyword: "storage units", search_volume: 1000 }]);
|
keyword: "seo",
|
||||||
|
keyword_info: {
|
||||||
|
search_volume: 100,
|
||||||
|
monthly_searches: [{ year: 2026, month: 1, search_volume: 100 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
mocks.createDataforseoClient.mockReturnValue({
|
mocks.createDataforseoClient.mockReturnValue({
|
||||||
keywordData: { searchVolume },
|
labs: { keywordOverview },
|
||||||
});
|
});
|
||||||
const { getKeywordSearchVolumeTool } =
|
const { getKeywordMetricsTool } =
|
||||||
await import("./dataforseo-research-tools");
|
await import("./dataforseo-research-tools");
|
||||||
|
|
||||||
await getKeywordSearchVolumeTool.handler(
|
const result = await getKeywordMetricsTool.handler(
|
||||||
{
|
{
|
||||||
projectId: "project_1",
|
projectId: "project_1",
|
||||||
keywords: ["storage units"],
|
keywords: ["seo"],
|
||||||
market: {},
|
includeMonthlyTrends: false,
|
||||||
},
|
},
|
||||||
toolExtra,
|
toolExtra,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(searchVolume).toHaveBeenCalledWith(
|
const rows = z
|
||||||
expect.objectContaining({
|
.object({ keywords: z.array(z.record(z.string(), z.unknown())) })
|
||||||
locationCode: 2840,
|
.passthrough()
|
||||||
}),
|
.parse(result.structuredContent).keywords;
|
||||||
);
|
expect(rows[0]).not.toHaveProperty("monthly_searches");
|
||||||
});
|
|
||||||
|
|
||||||
it("does not accept keyword volume location names", async () => {
|
|
||||||
const { getKeywordSearchVolumeTool } =
|
|
||||||
await import("./dataforseo-research-tools");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
getKeywordSearchVolumeTool.config.inputSchema.market?.safeParse({
|
|
||||||
country: "US",
|
|
||||||
locationName: "Pittsburgh,PA,United States",
|
|
||||||
}).success,
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
/* eslint-disable max-lines */
|
/* eslint-disable max-lines */
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createDataforseoClient } from "@/server/lib/dataforseo";
|
import {
|
||||||
|
createDataforseoClient,
|
||||||
|
type KeywordOverviewItem,
|
||||||
|
} from "@/server/lib/dataforseo";
|
||||||
import { buildProjectMeta } from "@/server/mcp/context";
|
import { buildProjectMeta } from "@/server/mcp/context";
|
||||||
import { mcpResponse } from "@/server/mcp/formatters";
|
import { mcpResponse } from "@/server/mcp/formatters";
|
||||||
import {
|
import {
|
||||||
@ -12,6 +15,7 @@ import {
|
|||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
DEFAULT_LOCATION_CODE,
|
DEFAULT_LOCATION_CODE,
|
||||||
languageCodeSchema,
|
languageCodeSchema,
|
||||||
|
locationCodeSchema,
|
||||||
projectIdSchema,
|
projectIdSchema,
|
||||||
} from "@/server/mcp/schemas";
|
} from "@/server/mcp/schemas";
|
||||||
|
|
||||||
@ -51,16 +55,6 @@ const localSerpNearSchema = z.object({
|
|||||||
zoom: z.number().int().min(4).max(18).optional(),
|
zoom: z.number().int().min(4).max(18).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const keywordMarketSchema = z
|
|
||||||
.object({
|
|
||||||
country: z
|
|
||||||
.enum(["US", "USA", "United States", "United States of America"])
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional Google Ads market. Defaults to United States.");
|
|
||||||
|
|
||||||
const domainTargetSchema = z
|
const domainTargetSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
@ -146,13 +140,20 @@ const findSerpCompetitorsInputSchema = {
|
|||||||
offset: z.number().int().min(0).max(1000).optional(),
|
offset: z.number().int().min(0).max(1000).optional(),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const getKeywordSearchVolumeInputSchema = {
|
const keywordMetricsSortSchema = z.enum([
|
||||||
|
"search_volume",
|
||||||
|
"keyword_difficulty",
|
||||||
|
"cpc",
|
||||||
|
"competition",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getKeywordMetricsInputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
keywords: z.array(z.string().min(1).max(80)).min(1).max(100),
|
keywords: z.array(z.string().min(1).max(80)).min(1).max(700),
|
||||||
market: keywordMarketSchema,
|
locationCode: locationCodeSchema.optional(),
|
||||||
includeMonthlyTrends: z.boolean().optional(),
|
|
||||||
sortBy: z.enum(["search_volume", "cpc", "competition"]).optional(),
|
|
||||||
languageCode: languageCodeSchema.optional(),
|
languageCode: languageCodeSchema.optional(),
|
||||||
|
includeMonthlyTrends: z.boolean().optional(),
|
||||||
|
sortBy: keywordMetricsSortSchema.optional(),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Market = z.infer<typeof marketSchema>;
|
type Market = z.infer<typeof marketSchema>;
|
||||||
@ -162,8 +163,8 @@ type GetRankedKeywordsArgs = z.infer<
|
|||||||
type FindSerpCompetitorsArgs = z.infer<
|
type FindSerpCompetitorsArgs = z.infer<
|
||||||
z.ZodObject<typeof findSerpCompetitorsInputSchema>
|
z.ZodObject<typeof findSerpCompetitorsInputSchema>
|
||||||
>;
|
>;
|
||||||
type GetKeywordSearchVolumeArgs = z.infer<
|
type GetKeywordMetricsArgs = z.infer<
|
||||||
z.ZodObject<typeof getKeywordSearchVolumeInputSchema>
|
z.ZodObject<typeof getKeywordMetricsInputSchema>
|
||||||
>;
|
>;
|
||||||
type SearchLocalBusinessesArgs = z.infer<
|
type SearchLocalBusinessesArgs = z.infer<
|
||||||
z.ZodObject<typeof searchLocalBusinessesInputSchema>
|
z.ZodObject<typeof searchLocalBusinessesInputSchema>
|
||||||
@ -302,20 +303,32 @@ function sortCompetitors(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortKeywordRows(
|
function normalizeKeywordOverview(item: KeywordOverviewItem) {
|
||||||
items: Record<string, unknown>[],
|
const info = item.keyword_info;
|
||||||
sortBy: GetKeywordSearchVolumeArgs["sortBy"],
|
return {
|
||||||
|
keyword: item.keyword,
|
||||||
|
search_volume: info?.search_volume ?? null,
|
||||||
|
keyword_difficulty: item.keyword_properties?.keyword_difficulty ?? null,
|
||||||
|
main_intent: item.search_intent_info?.main_intent ?? null,
|
||||||
|
cpc: info?.cpc ?? null,
|
||||||
|
competition: info?.competition ?? null,
|
||||||
|
competition_level: info?.competition_level ?? null,
|
||||||
|
monthly_searches: info?.monthly_searches ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeywordMetricRow = ReturnType<typeof normalizeKeywordOverview>;
|
||||||
|
|
||||||
|
function sortKeywordMetricRows(
|
||||||
|
rows: KeywordMetricRow[],
|
||||||
|
sortBy: NonNullable<GetKeywordMetricsArgs["sortBy"]> = "search_volume",
|
||||||
) {
|
) {
|
||||||
const field =
|
return rows.toSorted((a, b) => {
|
||||||
sortBy === "cpc"
|
const aValue = a[sortBy];
|
||||||
? "cpc"
|
const bValue = b[sortBy];
|
||||||
: sortBy === "competition"
|
const aNum = typeof aValue === "number" ? aValue : 0;
|
||||||
? "competition_index"
|
const bNum = typeof bValue === "number" ? bValue : 0;
|
||||||
: "search_volume";
|
return bNum - aNum;
|
||||||
return items.toSorted((a, b) => {
|
|
||||||
const aValue = typeof a[field] === "number" ? a[field] : 0;
|
|
||||||
const bValue = typeof b[field] === "number" ? b[field] : 0;
|
|
||||||
return bValue - aValue;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -552,13 +565,13 @@ export const findSerpCompetitorsTool = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getKeywordSearchVolumeTool = {
|
export const getKeywordMetricsTool = {
|
||||||
name: "get_keyword_search_volume",
|
name: "get_keyword_metrics",
|
||||||
config: {
|
config: {
|
||||||
title: "Get keyword search volume",
|
title: "Get keyword metrics",
|
||||||
description:
|
description:
|
||||||
"Checks Google Ads keyword planner-style search volume, CPC, competition, and monthly trends for known keywords. This is demand prioritization data, not local-radius rank data. Charges DataForSEO Keywords Data credits.",
|
"Hydrate up to 700 known keywords with search volume, keyword difficulty (KD), search intent, CPC, competition, and monthly trends in a single call. Use it to score candidate or known keywords — including Search Console striking-distance queries — by real demand and ranking difficulty. Charges DataForSEO Labs credits.",
|
||||||
inputSchema: getKeywordSearchVolumeInputSchema,
|
inputSchema: getKeywordMetricsInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
keywords: z.array(looseObjectOutputSchema),
|
keywords: z.array(looseObjectOutputSchema),
|
||||||
...optionalMetaOutputSchema,
|
...optionalMetaOutputSchema,
|
||||||
@ -569,29 +582,27 @@ export const getKeywordSearchVolumeTool = {
|
|||||||
destructiveHint: false,
|
destructiveHint: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(
|
handler: withMcpProjectAuth(async (args: GetKeywordMetricsArgs, context) => {
|
||||||
async (args: GetKeywordSearchVolumeArgs, context) => {
|
|
||||||
const client = createDataforseoClient(context.billing);
|
const client = createDataforseoClient(context.billing);
|
||||||
const keywords = await client.keywordData.searchVolume({
|
const items = await client.labs.keywordOverview({
|
||||||
keywords: args.keywords,
|
keywords: args.keywords,
|
||||||
locationCode: resolveMarketLocationCode(args.market),
|
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
|
||||||
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
||||||
|
creditFeature: "keyword_research",
|
||||||
});
|
});
|
||||||
const rows = sortKeywordRows(
|
const rows = sortKeywordMetricRows(
|
||||||
keywords,
|
items.map(normalizeKeywordOverview),
|
||||||
args.sortBy ?? "search_volume",
|
args.sortBy ?? "search_volume",
|
||||||
).map((item) =>
|
).map((row) =>
|
||||||
args.includeMonthlyTrends === false
|
args.includeMonthlyTrends === false
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(item).filter(
|
Object.entries(row).filter(([key]) => key !== "monthly_searches"),
|
||||||
([key]) => key !== "monthly_searches",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: item,
|
: row,
|
||||||
);
|
);
|
||||||
|
|
||||||
return mcpResponse({
|
return mcpResponse({
|
||||||
text: `Fetched search volume for ${rows.length} keyword rows.`,
|
text: `Fetched metrics (volume, difficulty, intent) for ${rows.length} keywords.`,
|
||||||
meta: buildProjectMeta(
|
meta: buildProjectMeta(
|
||||||
context,
|
context,
|
||||||
args.projectId,
|
args.projectId,
|
||||||
@ -599,6 +610,5 @@ export const getKeywordSearchVolumeTool = {
|
|||||||
),
|
),
|
||||||
structuredContent: { keywords: rows },
|
structuredContent: { keywords: rows },
|
||||||
});
|
});
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|||||||
300
src/server/mcp/tools/search-console-tools.test.ts
Normal file
300
src/server/mcp/tools/search-console-tools.test.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ToolExtra } from "@/server/mcp/context";
|
||||||
|
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getProjectForOrganization: vi.fn(),
|
||||||
|
isHostedServerAuthMode: vi.fn(),
|
||||||
|
GscService: {
|
||||||
|
getPerformance: vi.fn(),
|
||||||
|
inspectUrls: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
class GscNotConnectedError extends Error {
|
||||||
|
constructor(public readonly projectId: string) {
|
||||||
|
super("not connected");
|
||||||
|
this.name = "GscNotConnectedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class GscApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "GscApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class GscTokenError extends Error {}
|
||||||
|
|
||||||
|
vi.mock("cloudflare:workers", () => ({ env: {} }));
|
||||||
|
vi.mock("@/server/lib/runtime-env", () => ({
|
||||||
|
isHostedServerAuthMode: mocks.isHostedServerAuthMode,
|
||||||
|
}));
|
||||||
|
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||||
|
ProjectService: {
|
||||||
|
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/server/features/gsc/services/GscService", () => ({
|
||||||
|
GscService: mocks.GscService,
|
||||||
|
GscNotConnectedError,
|
||||||
|
}));
|
||||||
|
vi.mock("@/server/lib/gscClient", () => ({ GscApiError, GscTokenError }));
|
||||||
|
|
||||||
|
const authContext = {
|
||||||
|
userId: "user_123",
|
||||||
|
userEmail: "alice@example.com",
|
||||||
|
organizationId: "org_123",
|
||||||
|
clientId: "client_123",
|
||||||
|
scopes: ["mcp"],
|
||||||
|
audience: "https://open-seo.test/mcp",
|
||||||
|
subject: "user_123",
|
||||||
|
baseUrl: "https://open-seo.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolExtra: ToolExtra = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
requestId: 1,
|
||||||
|
sendNotification: vi.fn(),
|
||||||
|
sendRequest: vi.fn(),
|
||||||
|
authInfo: {
|
||||||
|
token: "token",
|
||||||
|
clientId: "client_123",
|
||||||
|
scopes: ["mcp"],
|
||||||
|
resource: new URL("https://open-seo.test/mcp"),
|
||||||
|
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
|
||||||
|
} satisfies AuthInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("search console MCP tools", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.getProjectForOrganization.mockReset();
|
||||||
|
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
|
||||||
|
mocks.isHostedServerAuthMode.mockReset();
|
||||||
|
mocks.isHostedServerAuthMode.mockResolvedValue(true);
|
||||||
|
mocks.GscService.getPerformance.mockReset();
|
||||||
|
mocks.GscService.inspectUrls.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns performance rows on success and passes filters through", async () => {
|
||||||
|
mocks.GscService.getPerformance.mockResolvedValue({
|
||||||
|
siteUrl: "https://example.com/",
|
||||||
|
connectedBy: "alice@example.com",
|
||||||
|
request: {
|
||||||
|
dimensions: ["query"],
|
||||||
|
startDate: "2026-04-27",
|
||||||
|
endDate: "2026-05-25",
|
||||||
|
rowLimit: 1000,
|
||||||
|
},
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
keys: ["seo tools"],
|
||||||
|
clicks: 12,
|
||||||
|
impressions: 300,
|
||||||
|
ctr: 0.04,
|
||||||
|
position: 7.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { getSearchConsolePerformanceTool } =
|
||||||
|
await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await getSearchConsolePerformanceTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
dimensions: ["query"],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "equals",
|
||||||
|
expression: "https://example.com/x",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.GscService.getPerformance).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
projectId: "project_1",
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
dimension: "page",
|
||||||
|
operator: "equals",
|
||||||
|
expression: "https://example.com/x",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
siteUrl: "https://example.com/",
|
||||||
|
rowCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a not-connected message with a connect URL", async () => {
|
||||||
|
mocks.GscService.getPerformance.mockRejectedValue(
|
||||||
|
new GscNotConnectedError("project_1"),
|
||||||
|
);
|
||||||
|
const { getSearchConsolePerformanceTool } =
|
||||||
|
await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await getSearchConsolePerformanceTool.handler(
|
||||||
|
{ projectId: "project_1" },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
reason: "not_connected",
|
||||||
|
});
|
||||||
|
const first = result.content[0];
|
||||||
|
expect(first.type).toBe("text");
|
||||||
|
expect(first.type === "text" && first.text).toContain(
|
||||||
|
"/p/project_1/integrations",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an api_error with a reconnect URL on a GSC API failure", async () => {
|
||||||
|
mocks.GscService.getPerformance.mockRejectedValue(
|
||||||
|
new GscApiError(403, "no access"),
|
||||||
|
);
|
||||||
|
const { getSearchConsolePerformanceTool } =
|
||||||
|
await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await getSearchConsolePerformanceTool.handler(
|
||||||
|
{ projectId: "project_1" },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
reason: "api_error",
|
||||||
|
});
|
||||||
|
const first = result.content[0];
|
||||||
|
expect(first.type === "text" && first.text).toContain(
|
||||||
|
"/p/project_1/integrations",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects searchAppearance combined with another dimension", async () => {
|
||||||
|
const { getSearchConsolePerformanceTool } =
|
||||||
|
await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await getSearchConsolePerformanceTool.handler(
|
||||||
|
{ projectId: "project_1", dimensions: ["query", "searchAppearance"] },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
reason: "invalid_request",
|
||||||
|
});
|
||||||
|
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a half-specified explicit date range", async () => {
|
||||||
|
const { getSearchConsolePerformanceTool } =
|
||||||
|
await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await getSearchConsolePerformanceTool.handler(
|
||||||
|
{ projectId: "project_1", startDate: "2026-01-01" },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
reason: "invalid_request",
|
||||||
|
});
|
||||||
|
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a hosted-only message in self-hosted mode", async () => {
|
||||||
|
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
||||||
|
const { getSearchConsolePerformanceTool } =
|
||||||
|
await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await getSearchConsolePerformanceTool.handler(
|
||||||
|
{ projectId: "project_1" },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.structuredContent).toMatchObject({ reason: "hosted_only" });
|
||||||
|
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inspects multiple URLs and reports partial failures inline", async () => {
|
||||||
|
mocks.GscService.inspectUrls.mockResolvedValue({
|
||||||
|
siteUrl: "sc-domain:example.com",
|
||||||
|
connectedBy: "alice@example.com",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
url: "https://example.com/a",
|
||||||
|
result: {
|
||||||
|
indexStatusResult: { verdict: "PASS", coverageState: "Indexed" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://example.com/bad",
|
||||||
|
result: null,
|
||||||
|
error: "Search Console API error (400)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { inspectUrlsTool } = await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await inspectUrlsTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
urls: ["https://example.com/a", "https://example.com/bad"],
|
||||||
|
},
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.GscService.inspectUrls).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
projectId: "project_1",
|
||||||
|
urls: ["https://example.com/a", "https://example.com/bad"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
siteUrl: "sc-domain:example.com",
|
||||||
|
});
|
||||||
|
const first = result.content[0];
|
||||||
|
expect(first.type === "text" && first.text).toContain("PASS");
|
||||||
|
expect(first.type === "text" && first.text).toContain("error:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a not-connected message from inspect_urls", async () => {
|
||||||
|
mocks.GscService.inspectUrls.mockRejectedValue(
|
||||||
|
new GscNotConnectedError("project_1"),
|
||||||
|
);
|
||||||
|
const { inspectUrlsTool } = await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await inspectUrlsTool.handler(
|
||||||
|
{ projectId: "project_1", urls: ["https://example.com/a"] },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
reason: "not_connected",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a hosted-only message for inspect_urls in self-hosted mode", async () => {
|
||||||
|
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
||||||
|
const { inspectUrlsTool } = await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await inspectUrlsTool.handler(
|
||||||
|
{ projectId: "project_1", urls: ["https://example.com/a"] },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.structuredContent).toMatchObject({ reason: "hosted_only" });
|
||||||
|
expect(mocks.GscService.inspectUrls).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
365
src/server/mcp/tools/search-console-tools.ts
Normal file
365
src/server/mcp/tools/search-console-tools.ts
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
/* eslint-disable max-lines */
|
||||||
|
import { z } from "zod";
|
||||||
|
import { buildProjectMeta } from "@/server/mcp/context";
|
||||||
|
import { mcpResponse } from "@/server/mcp/formatters";
|
||||||
|
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
||||||
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||||
|
import { projectIdSchema } from "@/server/mcp/schemas";
|
||||||
|
import { buildDashboardUrl } from "@/server/mcp/urls";
|
||||||
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
|
import {
|
||||||
|
GscNotConnectedError,
|
||||||
|
GscService,
|
||||||
|
} from "@/server/features/gsc/services/GscService";
|
||||||
|
import {
|
||||||
|
GSC_DATE_RANGES,
|
||||||
|
GSC_DEFAULT_ROW_LIMIT,
|
||||||
|
GSC_DIMENSIONS,
|
||||||
|
GSC_FILTER_OPERATORS,
|
||||||
|
GSC_MAX_ROW_LIMIT,
|
||||||
|
GSC_SEARCH_TYPES,
|
||||||
|
type GscPerformanceInput,
|
||||||
|
} from "@/server/features/gsc/searchAnalytics";
|
||||||
|
import { GscApiError, GscTokenError } from "@/server/lib/gscClient";
|
||||||
|
|
||||||
|
const TEXT_SUMMARY_ROWS = 15;
|
||||||
|
|
||||||
|
type ProjectAuthContext = {
|
||||||
|
auth: { organizationId: string };
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function integrationsUrl(baseUrl: string, projectId: string): string {
|
||||||
|
return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GSC connect requires Better Auth, which only runs in hosted mode. In
|
||||||
|
* self-hosted deployments the tools return this instead of a broken flow. */
|
||||||
|
async function hostedOnlyResponse(
|
||||||
|
context: ProjectAuthContext,
|
||||||
|
projectId: string,
|
||||||
|
) {
|
||||||
|
if (await isHostedServerAuthMode()) return null;
|
||||||
|
return mcpResponse({
|
||||||
|
text: "Google Search Console connect is only available on the hosted OpenSEO service, not in self-hosted mode. Use a GSC CSV export instead.",
|
||||||
|
meta: buildProjectMeta(context, projectId),
|
||||||
|
structuredContent: { ok: false, connected: false, reason: "hosted_only" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidRequest(
|
||||||
|
meta: ReturnType<typeof buildProjectMeta>,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
return mcpResponse({
|
||||||
|
text: message,
|
||||||
|
meta,
|
||||||
|
structuredContent: { ok: false, reason: "invalid_request" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeGscError(error: unknown): string {
|
||||||
|
if (error instanceof GscNotConnectedError) {
|
||||||
|
return "Search Console is not connected for this project.";
|
||||||
|
}
|
||||||
|
if (error instanceof GscTokenError) {
|
||||||
|
return "The Search Console connection has expired or was revoked. Reconnect it to continue.";
|
||||||
|
}
|
||||||
|
if (error instanceof GscApiError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// get_search_console_performance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const filterSchema = z.object({
|
||||||
|
dimension: z.enum(GSC_DIMENSIONS),
|
||||||
|
operator: z.enum(GSC_FILTER_OPERATORS).default("equals"),
|
||||||
|
expression: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const perfInputSchema = {
|
||||||
|
projectId: projectIdSchema,
|
||||||
|
dimensions: z
|
||||||
|
.array(z.enum(GSC_DIMENSIONS))
|
||||||
|
.min(1)
|
||||||
|
.max(4)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Group rows by these dimensions. Default ['query']. Use ['page'] for top pages, ['query','page'] to map queries to pages / spot cannibalization, ['date'] for a time series.",
|
||||||
|
),
|
||||||
|
dateRange: z
|
||||||
|
.enum(GSC_DATE_RANGES)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Convenience window (default last_28_days). End is set ~3 days back for GSC data lag. Ignored if startDate+endDate are given. Max lookback is 16 months.",
|
||||||
|
),
|
||||||
|
startDate: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional()
|
||||||
|
.describe("Explicit start (YYYY-MM-DD, Pacific Time). Use with endDate."),
|
||||||
|
endDate: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional()
|
||||||
|
.describe("Explicit end (YYYY-MM-DD, Pacific Time). Use with startDate."),
|
||||||
|
filters: z
|
||||||
|
.array(filterSchema)
|
||||||
|
.max(5)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"AND-combined filters. To get the queries for one page: [{dimension:'page',operator:'equals',expression:'https://example.com/post'}] with dimensions ['query'].",
|
||||||
|
),
|
||||||
|
rowLimit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(GSC_MAX_ROW_LIMIT)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Rows per call (default 1000, max 1000). GSC sorts by clicks desc and can't filter by position — filter 'striking distance' positions client-side, and paginate with startRow when hasMore is true.",
|
||||||
|
),
|
||||||
|
startRow: z.number().int().min(0).optional().describe("Pagination offset."),
|
||||||
|
type: z
|
||||||
|
.enum(GSC_SEARCH_TYPES)
|
||||||
|
.optional()
|
||||||
|
.describe("Search type (default web)."),
|
||||||
|
dataState: z
|
||||||
|
.enum(["all", "final"])
|
||||||
|
.optional()
|
||||||
|
.describe("'all' (default) includes fresh/incomplete recent data."),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type PerfArgs = z.infer<z.ZodObject<typeof perfInputSchema>>;
|
||||||
|
|
||||||
|
export const getSearchConsolePerformanceTool = {
|
||||||
|
name: "get_search_console_performance",
|
||||||
|
config: {
|
||||||
|
title: "Get Google Search Console performance",
|
||||||
|
description:
|
||||||
|
"Query the connected Search Console property's Search Analytics: clicks, impressions, CTR, and average position by query/page/country/device/date. First-party data — use it for what already ranks, near-ranking queries, and pages with real demand. ctr is a 0-1 fraction; position is a 1-based average; dates are Pacific Time; the last ~3 days may be incomplete. Read-only and free (no credits).",
|
||||||
|
inputSchema: perfInputSchema,
|
||||||
|
outputSchema: {
|
||||||
|
ok: z.boolean(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
connectUrl: z.string().optional(),
|
||||||
|
siteUrl: z.string().optional(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
dimensions: z.array(z.string()).optional(),
|
||||||
|
rowCount: z.number().optional(),
|
||||||
|
rows: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
keys: z.array(z.string()).optional(),
|
||||||
|
clicks: z.number(),
|
||||||
|
impressions: z.number(),
|
||||||
|
ctr: z.number(),
|
||||||
|
position: z.number(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
hasMore: z.boolean().optional(),
|
||||||
|
nextStartRow: z.number().optional(),
|
||||||
|
...optionalMetaOutputSchema,
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: withMcpProjectAuth(async (args: PerfArgs, context) => {
|
||||||
|
const blocked = await hostedOnlyResponse(context, args.projectId);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
|
||||||
|
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
||||||
|
const meta = buildProjectMeta(
|
||||||
|
context,
|
||||||
|
args.projectId,
|
||||||
|
`/p/${args.projectId}/integrations`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// GSC rejects searchAppearance combined with any other dimension.
|
||||||
|
if (
|
||||||
|
args.dimensions &&
|
||||||
|
args.dimensions.includes("searchAppearance") &&
|
||||||
|
args.dimensions.length > 1
|
||||||
|
) {
|
||||||
|
return invalidRequest(
|
||||||
|
meta,
|
||||||
|
"searchAppearance must be the only dimension when used.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A half-specified explicit range would silently fall back to a default window.
|
||||||
|
if (Boolean(args.startDate) !== Boolean(args.endDate)) {
|
||||||
|
return invalidRequest(
|
||||||
|
meta,
|
||||||
|
"Provide both startDate and endDate, or neither (use dateRange instead).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await GscService.getPerformance(
|
||||||
|
args satisfies GscPerformanceInput,
|
||||||
|
);
|
||||||
|
const dimensions = result.request.dimensions ?? ["query"];
|
||||||
|
// rowLimit is clamped to the agent cap in buildSearchAnalyticsRequest, so
|
||||||
|
// result.rows is already <= the cap — every count below reflects what we return.
|
||||||
|
const rows = result.rows;
|
||||||
|
const requestedLimit = result.request.rowLimit ?? GSC_DEFAULT_ROW_LIMIT;
|
||||||
|
const hasMore = rows.length >= requestedLimit;
|
||||||
|
const nextStartRow = (result.request.startRow ?? 0) + rows.length;
|
||||||
|
|
||||||
|
const summaryLines = rows.slice(0, TEXT_SUMMARY_ROWS).map((r) => {
|
||||||
|
const label = r.keys?.join(" / ") ?? "(total)";
|
||||||
|
const ctrPct = (r.ctr * 100).toFixed(1);
|
||||||
|
return ` ${label} — ${r.clicks} clicks, ${r.impressions} impr, ${ctrPct}% CTR, pos ${r.position.toFixed(1)}`;
|
||||||
|
});
|
||||||
|
const header =
|
||||||
|
`${result.siteUrl} · ${dimensions.join("+")} · ${result.request.startDate}→${result.request.endDate} · ` +
|
||||||
|
`${rows.length} row${rows.length === 1 ? "" : "s"}${hasMore ? " (more available — paginate with startRow)" : ""}`;
|
||||||
|
const text =
|
||||||
|
summaryLines.length > 0
|
||||||
|
? `${header}\n${summaryLines.join("\n")}${rows.length > summaryLines.length ? `\n …and ${rows.length - summaryLines.length} more` : ""}`
|
||||||
|
: `${header}\nNo rows for this query/date range.`;
|
||||||
|
|
||||||
|
return mcpResponse({
|
||||||
|
text,
|
||||||
|
meta,
|
||||||
|
structuredContent: {
|
||||||
|
ok: true,
|
||||||
|
siteUrl: result.siteUrl,
|
||||||
|
startDate: result.request.startDate,
|
||||||
|
endDate: result.request.endDate,
|
||||||
|
dimensions,
|
||||||
|
rowCount: rows.length,
|
||||||
|
rows,
|
||||||
|
hasMore,
|
||||||
|
nextStartRow: hasMore ? nextStartRow : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const isNotConnected = error instanceof GscNotConnectedError;
|
||||||
|
return mcpResponse({
|
||||||
|
text: `${describeGscError(error)}${isNotConnected ? ` Connect it here: ${connectUrl}` : ` (reconnect at ${connectUrl})`}`,
|
||||||
|
meta,
|
||||||
|
structuredContent: {
|
||||||
|
ok: false,
|
||||||
|
reason: isNotConnected ? "not_connected" : "api_error",
|
||||||
|
connectUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// inspect_urls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const inspectInputSchema = {
|
||||||
|
projectId: projectIdSchema,
|
||||||
|
urls: z
|
||||||
|
.array(z.string().url())
|
||||||
|
.min(1)
|
||||||
|
.max(10)
|
||||||
|
.describe(
|
||||||
|
"1–10 absolute URLs to inspect. Each must belong to the connected property.",
|
||||||
|
),
|
||||||
|
languageCode: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("BCP-47 language for the inspection result (e.g. 'en-US')."),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type InspectArgs = z.infer<z.ZodObject<typeof inspectInputSchema>>;
|
||||||
|
|
||||||
|
export const inspectUrlsTool = {
|
||||||
|
name: "inspect_urls",
|
||||||
|
config: {
|
||||||
|
title: "Inspect URLs in Google Search Console",
|
||||||
|
description:
|
||||||
|
"Run Google Search Console's URL Inspection on up to 10 URLs of the connected property: index/coverage state, last crawl time, Google-selected vs declared canonical, and mobile/rich-results verdicts. Use it to answer 'is this page indexed? why not?'. Per-URL failures are reported inline. Read-only and free (no credits).",
|
||||||
|
inputSchema: inspectInputSchema,
|
||||||
|
outputSchema: {
|
||||||
|
ok: z.boolean(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
connectUrl: z.string().optional(),
|
||||||
|
siteUrl: z.string().optional(),
|
||||||
|
results: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
url: z.string(),
|
||||||
|
result: z.unknown().nullable().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
...optionalMetaOutputSchema,
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: withMcpProjectAuth(async (args: InspectArgs, context) => {
|
||||||
|
const blocked = await hostedOnlyResponse(context, args.projectId);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
|
||||||
|
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
||||||
|
const meta = buildProjectMeta(
|
||||||
|
context,
|
||||||
|
args.projectId,
|
||||||
|
`/p/${args.projectId}/integrations`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { siteUrl, results } = await GscService.inspectUrls({
|
||||||
|
projectId: args.projectId,
|
||||||
|
urls: args.urls,
|
||||||
|
languageCode: args.languageCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryLines = results.slice(0, TEXT_SUMMARY_ROWS).map((r) => {
|
||||||
|
if (r.error) return ` ${r.url} — error: ${r.error}`;
|
||||||
|
const index = r.result?.indexStatusResult;
|
||||||
|
const verdict = index?.verdict ?? "UNKNOWN";
|
||||||
|
const coverage = index?.coverageState ?? "—";
|
||||||
|
const canonical = index?.googleCanonical
|
||||||
|
? `, google-canonical ${index.googleCanonical}`
|
||||||
|
: "";
|
||||||
|
return ` ${r.url} — ${verdict}: ${coverage}${canonical}`;
|
||||||
|
});
|
||||||
|
const text =
|
||||||
|
`${siteUrl} · inspected ${results.length} URL${results.length === 1 ? "" : "s"}\n` +
|
||||||
|
summaryLines.join("\n");
|
||||||
|
|
||||||
|
return mcpResponse({
|
||||||
|
text,
|
||||||
|
meta,
|
||||||
|
structuredContent: { ok: true, siteUrl, results },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const isNotConnected = error instanceof GscNotConnectedError;
|
||||||
|
return mcpResponse({
|
||||||
|
text: `${describeGscError(error)}${isNotConnected ? ` Connect it here: ${connectUrl}` : ` (reconnect at ${connectUrl})`}`,
|
||||||
|
meta,
|
||||||
|
structuredContent: {
|
||||||
|
ok: false,
|
||||||
|
reason: isNotConnected ? "not_connected" : "api_error",
|
||||||
|
connectUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
99
src/serverFunctions/gsc.ts
Normal file
99
src/serverFunctions/gsc.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { waitUntil } from "cloudflare:workers";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { GscService } from "@/server/features/gsc/services/GscService";
|
||||||
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
|
import {
|
||||||
|
requireAuthenticatedContext,
|
||||||
|
requireProjectContext,
|
||||||
|
} from "@/serverFunctions/middleware";
|
||||||
|
|
||||||
|
const projectScopedSchema = z.object({ projectId: z.string().min(1) });
|
||||||
|
const setSiteSchema = projectScopedSchema.extend({
|
||||||
|
siteUrl: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Account-level grant check (no project needed) for surfaces like onboarding
|
||||||
|
// where the user hasn't picked a project yet. The OAuth grant is per-account;
|
||||||
|
// binding a property to a project happens later in Integrations.
|
||||||
|
export const getGscGrantStatus = createServerFn({ method: "GET" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
return { connected: await GscService.userHasGrant(context.userId) };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getGscConnection = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => projectScopedSchema.parse(data))
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const [connection, currentUserHasGrant] = await Promise.all([
|
||||||
|
GscService.getConnection(context.projectId),
|
||||||
|
GscService.userHasGrant(context.userId),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
connected: Boolean(connection),
|
||||||
|
currentUserHasGrant,
|
||||||
|
siteUrl: connection?.siteUrl ?? null,
|
||||||
|
connectedByEmail: connection?.connectedAccountEmail ?? null,
|
||||||
|
connectedAt: connection?.createdAt ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listGscSites = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => projectScopedSchema.parse(data))
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const [sites, connection] = await Promise.all([
|
||||||
|
GscService.listSitesForUser(context.userId),
|
||||||
|
GscService.getConnection(context.projectId),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
sites: sites.map((s) => ({
|
||||||
|
siteUrl: s.siteUrl,
|
||||||
|
permissionLevel: s.permissionLevel,
|
||||||
|
selectable: s.permissionLevel !== "siteUnverifiedUser",
|
||||||
|
isSelected: s.siteUrl === connection?.siteUrl,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setGscSite = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => setSiteSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
const connection = await GscService.setSite({
|
||||||
|
projectId: context.projectId,
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
siteUrl: data.siteUrl,
|
||||||
|
userId: context.userId,
|
||||||
|
userEmail: context.userEmail,
|
||||||
|
});
|
||||||
|
waitUntil(
|
||||||
|
captureServerEvent({
|
||||||
|
distinctId: context.userId,
|
||||||
|
event: "gsc:property_select",
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
properties: { project_id: context.projectId, site_url: data.siteUrl },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { connected: true as const, siteUrl: connection.siteUrl };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const disconnectGsc = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => projectScopedSchema.parse(data))
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
await GscService.disconnect({
|
||||||
|
projectId: context.projectId,
|
||||||
|
userId: context.userId,
|
||||||
|
});
|
||||||
|
waitUntil(
|
||||||
|
captureServerEvent({
|
||||||
|
distinctId: context.userId,
|
||||||
|
event: "gsc:disconnect",
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
properties: { project_id: context.projectId },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { connected: false as const };
|
||||||
|
});
|
||||||
@ -20,6 +20,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" })
|
|||||||
const answers = await db.query.userOnboardingAnswers.findFirst({
|
const answers = await db.query.userOnboardingAnswers.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
completedAt: true,
|
completedAt: true,
|
||||||
|
gscNudgeDismissedAt: true,
|
||||||
interestedFeatures: true,
|
interestedFeatures: true,
|
||||||
workFor: true,
|
workFor: true,
|
||||||
clientWebsiteCount: true,
|
clientWebsiteCount: true,
|
||||||
@ -51,6 +52,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" })
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
completedAt: answers?.completedAt ?? null,
|
completedAt: answers?.completedAt ?? null,
|
||||||
|
gscNudgeDismissedAt: answers?.gscNudgeDismissedAt ?? null,
|
||||||
userCreatedAt: hostedUser?.createdAt?.toISOString() ?? null,
|
userCreatedAt: hostedUser?.createdAt?.toISOString() ?? null,
|
||||||
answers: {
|
answers: {
|
||||||
interestedFeatures,
|
interestedFeatures,
|
||||||
@ -104,3 +106,25 @@ export const saveOnboardingAnswers = createServerFn({ method: "POST" })
|
|||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Records that the one-time "connect Search Console" nudge has been shown and
|
||||||
|
// resolved (dismissed or acted on) so it never reappears for this user.
|
||||||
|
export const dismissGscNudge = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.insert(userOnboardingAnswers)
|
||||||
|
.values({
|
||||||
|
userId: context.userId,
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
gscNudgeDismissedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: userOnboardingAnswers.userId,
|
||||||
|
set: { gscNudgeDismissedAt: now, updatedAt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|||||||
4
src/shared/gsc.ts
Normal file
4
src/shared/gsc.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** Better Auth providerId for the incremental Google Search Console connection.
|
||||||
|
* Kept in `shared` so both server (auth config, GSC client) and client (connect
|
||||||
|
* button) can reference it without importing the server-only auth config. */
|
||||||
|
export const GSC_OAUTH_PROVIDER_ID = "google-search-console";
|
||||||
@ -3,7 +3,7 @@ title: "Set up OpenSEO MCP"
|
|||||||
description: "Connect OpenSEO MCP to Claude, Codex, and other AI clients."
|
description: "Connect OpenSEO MCP to Claude, Codex, and other AI clients."
|
||||||
---
|
---
|
||||||
|
|
||||||
OpenSEO MCP lets compatible AI clients call OpenSEO tools for keyword research, SERP inspection, local business research, competitive search intelligence, domain research, backlink overview, saved keywords, and rank tracking.
|
OpenSEO MCP lets compatible AI clients call OpenSEO tools for keyword research, SERP inspection, local business research, competitive search intelligence, domain research, backlink overview, saved keywords, rank tracking, and Google Search Console performance and URL inspection.
|
||||||
|
|
||||||
The hosted MCP server URL is:
|
The hosted MCP server URL is:
|
||||||
|
|
||||||
@ -60,13 +60,15 @@ OpenSEO MCP exposes tools for SEO research workflows:
|
|||||||
- Find exact keyword, page, rank, volume, CPC, intent, and traffic rows for a domain or page.
|
- Find exact keyword, page, rank, volume, CPC, intent, and traffic rows for a domain or page.
|
||||||
- Compare SERP competitors across a supplied keyword set.
|
- Compare SERP competitors across a supplied keyword set.
|
||||||
- Search local businesses near a coordinate, fetch one Maps or Local Finder SERP, and read Google Business Q&A when needed.
|
- Search local businesses near a coordinate, fetch one Maps or Local Finder SERP, and read Google Business Q&A when needed.
|
||||||
- Check Google Ads keyword volume and CPC for demand prioritization.
|
- Hydrate keywords with search volume, difficulty, intent, CPC, and trends.
|
||||||
- List saved keywords from an OpenSEO project.
|
- List saved keywords from an OpenSEO project.
|
||||||
- Save useful keywords back to OpenSEO.
|
- Save useful keywords back to OpenSEO.
|
||||||
- Read rank tracker configs and latest keyword positions.
|
- Read rank tracker configs and latest keyword positions.
|
||||||
- Summarize a domain's organic footprint.
|
- Summarize a domain's organic footprint.
|
||||||
- Find keywords a domain already ranks for.
|
- Find keywords a domain already ranks for.
|
||||||
- Check backlink and referring-domain overview data.
|
- Check backlink and referring-domain overview data.
|
||||||
|
- Read first-party Google Search Console performance (clicks, impressions, CTR, position).
|
||||||
|
- Inspect index status, crawl, and canonical for specific URLs (up to 10 per call).
|
||||||
|
|
||||||
## What to do after setup
|
## What to do after setup
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user