@gscdump/sdk 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +7 -1
  2. package/dist/_chunks/errors.d.mts +22 -0
  3. package/dist/_chunks/request.d.mts +25 -0
  4. package/dist/_chunks/request.mjs +84 -0
  5. package/dist/_chunks/search-console-signals.mjs +9 -0
  6. package/dist/analytics-client.d.mts +53 -0
  7. package/dist/analytics-client.mjs +128 -0
  8. package/dist/analyzer-defs.d.mts +73 -0
  9. package/dist/analyzer-defs.mjs +4 -0
  10. package/dist/anonymization.d.mts +6 -0
  11. package/dist/anonymization.mjs +12 -0
  12. package/dist/archetype-compile.d.mts +48 -0
  13. package/dist/archetype-compile.mjs +124 -0
  14. package/dist/client.d.mts +122 -0
  15. package/dist/client.mjs +365 -0
  16. package/dist/country-names.d.mts +2 -0
  17. package/dist/country-names.mjs +56 -0
  18. package/dist/cwv-thresholds.d.mts +13 -0
  19. package/dist/cwv-thresholds.mjs +23 -0
  20. package/dist/errors.d.mts +2 -0
  21. package/dist/errors.mjs +47 -0
  22. package/dist/gsc-console-url.d.mts +12 -0
  23. package/dist/gsc-console-url.mjs +15 -0
  24. package/dist/gsc-constants.d.mts +17 -0
  25. package/dist/gsc-constants.mjs +2 -0
  26. package/dist/gsc-error.d.mts +12 -0
  27. package/dist/gsc-error.mjs +42 -0
  28. package/dist/gsc-period-presets.d.mts +31 -0
  29. package/dist/gsc-period-presets.mjs +115 -0
  30. package/dist/gsc-rows.d.mts +54 -0
  31. package/dist/gsc-rows.mjs +48 -0
  32. package/dist/hosted-query.d.mts +31 -0
  33. package/dist/hosted-query.mjs +105 -0
  34. package/dist/index.d.mts +21 -852
  35. package/dist/index.mjs +21 -2275
  36. package/dist/indexing-issues.d.mts +29 -0
  37. package/dist/indexing-issues.mjs +168 -0
  38. package/dist/lifecycle.d.mts +14 -0
  39. package/dist/lifecycle.mjs +84 -0
  40. package/dist/period.d.mts +49 -0
  41. package/dist/period.mjs +138 -0
  42. package/dist/search-console-stage.d.mts +102 -0
  43. package/dist/search-console-stage.mjs +292 -0
  44. package/dist/site-baseline.d.mts +69 -0
  45. package/dist/site-baseline.mjs +112 -0
  46. package/dist/site-triage.d.mts +86 -0
  47. package/dist/site-triage.mjs +268 -0
  48. package/dist/v1/index.d.mts +1 -1
  49. package/dist/v1/index.mjs +2 -2
  50. package/dist/webhook.d.mts +29 -0
  51. package/dist/webhook.mjs +118 -0
  52. package/package.json +110 -5
@@ -0,0 +1,29 @@
1
+ interface IndexingIssueDetail {
2
+ description: string;
3
+ fix: string;
4
+ }
5
+ type IssueSeverity = 'error' | 'warning' | 'info';
6
+ interface IndexingIssue {
7
+ type: string;
8
+ label: string;
9
+ severity: IssueSeverity;
10
+ count: number;
11
+ }
12
+ declare const issueDetails: Record<string, IndexingIssueDetail>;
13
+ declare const severityOrder: IssueSeverity[];
14
+ interface IssueGroup {
15
+ id: string;
16
+ label: string;
17
+ icon: string;
18
+ description: string;
19
+ /** How hard are these to fix? Shown as a badge */
20
+ effort: 'quick' | 'moderate' | 'involved';
21
+ /** Does the user have direct control over these? */
22
+ controlLevel: 'full' | 'partial' | 'none';
23
+ /** Educational explanation shown in the group header */
24
+ education: string;
25
+ /** Issue types belonging to this group */
26
+ issueTypes: string[];
27
+ }
28
+ declare const issueGroups: IssueGroup[];
29
+ export { IndexingIssue, IndexingIssueDetail, IssueGroup, IssueSeverity, issueDetails, issueGroups, severityOrder };
@@ -0,0 +1,168 @@
1
+ const issueDetails = {
2
+ crawled_not_indexed: {
3
+ description: "Google crawled these pages but decided not to add them to the index. This often means the content was deemed low-quality, duplicate, or not useful enough.",
4
+ fix: "Improve content quality and uniqueness. Add internal links pointing to these pages. Ensure they have clear, distinct value compared to other pages on your site."
5
+ },
6
+ discovered_not_indexed: {
7
+ description: "Google knows these URLs exist but hasn't crawled them yet. This is usually a crawl-budget or priority signal — Google deemed other pages more important.",
8
+ fix: "Add strong internal links from indexed pages. Submit the URL via Search Console's URL Inspection > Request Indexing. Improve site authority and crawl-budget signals; check no resource constraints (slow server, large response sizes) are deterring the crawl."
9
+ },
10
+ server_error: {
11
+ description: "Google encountered 5xx server errors when trying to crawl these URLs. The pages were unreachable at crawl time.",
12
+ fix: "Check your server logs for errors. Ensure your hosting can handle Googlebot traffic. Fix any backend issues causing 500/502/503 errors."
13
+ },
14
+ access_forbidden: {
15
+ description: "Your server returned 403 Forbidden to Googlebot. Usually a WAF, bot-protection rule, or firewall treating the crawler as an attacker — a human visitor may see the page fine.",
16
+ fix: "Allowlist Googlebot in your WAF / bot-protection rules and verify by IP, not user agent. Check Cloudflare bot-fight mode, rate limits, and any geo or ASN blocking. Re-test with the URL Inspection tool's \"Test live URL\"."
17
+ },
18
+ access_denied: {
19
+ description: "Your server returned 401 Unauthorized to Googlebot. The URL sits behind an authentication wall.",
20
+ fix: "If the page should rank, remove the auth requirement for crawlers or move the content to a public URL. If it is genuinely private, block it in robots.txt or noindex it so it stops being reported as an indexing failure."
21
+ },
22
+ blocked_4xx: {
23
+ description: "Google got a 4xx response other than 401, 403, or 404 — commonly 410 Gone, 429 Too Many Requests, or 451.",
24
+ fix: "Check which status the URL actually returns. A 429 means Googlebot is being rate-limited: raise or exempt the crawler limit. A 410 is intentional deletion and will clear on its own."
25
+ },
26
+ redirect_error: {
27
+ description: "Google could not follow the redirect — a redirect chain that is too long, a loop, an empty Location header, or a URL that exceeds the maximum length.",
28
+ fix: "Collapse redirect chains to a single hop. Look for loops (A→B→A) and self-redirects. Point internal links and sitemap entries at the final destination URL."
29
+ },
30
+ crawl_error: {
31
+ description: "Google hit an internal crawl error or considered the URL malformed. Often transient on Google's side, but a persistent count means the URL itself is invalid.",
32
+ fix: "Verify the URL parses and resolves. Remove malformed URLs from your sitemap and internal links. If the URLs are valid, re-inspect in a few days — transient crawl errors clear themselves."
33
+ },
34
+ sitemap_redirect: {
35
+ description: "These URLs are submitted in your sitemap but redirect elsewhere. A sitemap should only list final, canonical, 200-status URLs — a redirecting entry wastes crawl budget and tells Google your sitemap is stale.",
36
+ fix: "Replace each redirecting entry with its destination URL, or drop it from the sitemap entirely. Then resubmit the sitemap."
37
+ },
38
+ alternate_canonical: {
39
+ description: "These pages declare a canonical pointing at another page, and Google honoured it. The canonical target is what gets indexed. Usually intentional.",
40
+ fix: "Nothing to fix if the canonical is deliberate. If these pages should rank on their own, make each one self-canonical and give it genuinely distinct content."
41
+ },
42
+ duplicate_no_canonical: {
43
+ description: "Google decided these pages duplicate another URL and picked the canonical itself, because the page declared no preference. You did not choose which URL ranks — Google did.",
44
+ fix: "Add a canonical link to every page. Point it at itself for pages that should rank, or at the preferred URL for genuine duplicates. Leaving it unset hands the decision to Google."
45
+ },
46
+ page_removed: {
47
+ description: "These URLs are suppressed by a request in Search Console's Removals tool. The block is temporary — roughly six months — and then they become eligible again.",
48
+ fix: "If the removal was intentional, back it with a real signal: a noindex tag, a 404/410, or authentication. The removal tool alone does not keep a page out of Search permanently."
49
+ },
50
+ unknown_to_google: {
51
+ description: "These URLs exist on your site but Google hasn't discovered them yet. They may be orphaned pages or missing from your sitemap.",
52
+ fix: "Add these URLs to your sitemap. Create internal links to them from well-indexed pages. Submit the sitemap in Google Search Console."
53
+ },
54
+ stale_crawl: {
55
+ description: "Google hasn't re-crawled these pages in over 30 days. They may have low perceived value or your crawl budget may be exhausted.",
56
+ fix: "Update content on these pages to signal freshness. Improve internal linking. Ensure your site loads quickly to maximize crawl budget efficiency."
57
+ },
58
+ very_stale_crawl: {
59
+ description: "Google hasn't visited these pages in over 60 days. They are at risk of being dropped from the index entirely.",
60
+ fix: "Prioritize updating these pages immediately. Add fresh internal links. Consider requesting re-indexing via Google Search Console's URL Inspection tool."
61
+ },
62
+ not_found: {
63
+ description: "These URLs return 404 errors. Google previously knew about them but they no longer exist.",
64
+ fix: "If the content moved, add 301 redirects to the new URLs. If intentionally removed, ensure no internal links still point to them. The 404s will clear over time."
65
+ },
66
+ soft_404: {
67
+ description: "These pages return a 200 status but Google detects them as effectively empty or error pages — \"soft\" 404s.",
68
+ fix: "Return a proper 404 status code for missing pages. If the pages should exist, add meaningful content. Avoid thin placeholder pages."
69
+ },
70
+ blocked_robots: {
71
+ description: "Your robots.txt file is preventing Google from crawling these URLs.",
72
+ fix: "Review your robots.txt rules. Remove Disallow directives for pages you want indexed. Remember that blocked pages can't be indexed even if linked."
73
+ },
74
+ noindex: {
75
+ description: "These pages have a noindex meta tag or X-Robots-Tag header, telling Google not to include them in search results.",
76
+ fix: "If these pages should be indexed, remove the noindex directive. Check for noindex in meta tags, HTTP headers, and any SEO plugin configuration."
77
+ },
78
+ redirect: {
79
+ description: "These URLs redirect to other pages. Google follows the redirect and indexes the destination instead.",
80
+ fix: "This is usually expected behavior. Ensure redirects point to the correct destination. Update internal links to point directly to the final URL to save crawl budget."
81
+ },
82
+ canonical_mismatch: {
83
+ description: "The canonical URL declared on these pages points to a different URL. Google may index the canonical target instead.",
84
+ fix: "Ensure each page's canonical tag points to itself, or intentionally to the preferred version. Fix any unintended canonical tags added by CMS plugins."
85
+ },
86
+ fragment_url: {
87
+ description: "These URLs contain fragment identifiers (#). Googlebot typically ignores fragments as they're client-side only.",
88
+ fix: "Avoid using fragment URLs as unique pages. If using client-side routing with hashes, migrate to proper URL paths for better indexability."
89
+ },
90
+ not_indexed: {
91
+ description: "These URLs are not in Google's index. This is a general category — the specific reason may vary.",
92
+ fix: "Check individual URLs in Google Search Console's URL Inspection tool for specific reasons. Common causes include quality, duplicate content, or crawl issues."
93
+ }
94
+ };
95
+ const severityOrder = [
96
+ "error",
97
+ "warning",
98
+ "info"
99
+ ];
100
+ const issueGroups = [
101
+ {
102
+ id: "quick-wins",
103
+ label: "Quick Wins",
104
+ icon: "i-lucide-zap",
105
+ description: "Configuration changes you can make right now",
106
+ effort: "quick",
107
+ controlLevel: "full",
108
+ education: "These issues are caused by your site's configuration preventing Google from indexing certain pages. If these pages should be indexed, the fix is usually a one-line config change — remove a robots.txt rule, fix a canonical URL, or drop a redirecting entry from your sitemap. Highest-ROI fixes, zero content work.",
109
+ issueTypes: [
110
+ "blocked_robots",
111
+ "canonical_mismatch",
112
+ "duplicate_no_canonical",
113
+ "sitemap_redirect"
114
+ ]
115
+ },
116
+ {
117
+ id: "technical",
118
+ label: "Technical Fixes",
119
+ icon: "i-lucide-wrench",
120
+ description: "Server and URL issues to resolve",
121
+ effort: "moderate",
122
+ controlLevel: "full",
123
+ education: "These are infrastructure problems — your server is returning errors, blocking Googlebot at the edge, failing to follow redirects, or serving pages that look empty. Fix crawl blocks and server errors first (they cost crawl budget and can drop indexed pages), then handle 404s with redirects.",
124
+ issueTypes: [
125
+ "server_error",
126
+ "not_found",
127
+ "soft_404",
128
+ "access_forbidden",
129
+ "access_denied",
130
+ "blocked_4xx",
131
+ "redirect_error",
132
+ "crawl_error"
133
+ ]
134
+ },
135
+ {
136
+ id: "content-discovery",
137
+ label: "Content & Discovery",
138
+ icon: "i-lucide-file-search",
139
+ description: "Help Google find and value your pages",
140
+ effort: "involved",
141
+ controlLevel: "partial",
142
+ education: "Google found these pages but either didn't think they were worth indexing, or hasn't discovered them yet. For crawled-but-not-indexed pages, improving content quality and internal linking helps — but Google ultimately decides what to index. For undiscovered pages, adding them to your sitemap and linking to them from indexed pages is the fix.",
143
+ issueTypes: [
144
+ "crawled_not_indexed",
145
+ "discovered_not_indexed",
146
+ "unknown_to_google",
147
+ "stale_crawl",
148
+ "very_stale_crawl"
149
+ ]
150
+ },
151
+ {
152
+ id: "expected",
153
+ label: "Expected Behavior",
154
+ icon: "i-lucide-info",
155
+ description: "Usually intentional — review but likely fine",
156
+ effort: "quick",
157
+ controlLevel: "none",
158
+ education: "These aren't really \"issues\" — they're usually intentional. Noindex tags are set deliberately to keep pages out of search. Redirects are normal when you move pages. An alternate page with a proper canonical is consolidation working as designed. Fragment URLs are stripped by Google. Review to make sure nothing unexpected is here.",
159
+ issueTypes: [
160
+ "noindex",
161
+ "redirect",
162
+ "alternate_canonical",
163
+ "page_removed",
164
+ "fragment_url"
165
+ ]
166
+ }
167
+ ];
168
+ export { issueDetails, issueGroups, severityOrder };
@@ -0,0 +1,14 @@
1
+ import { GscdumpSyncStatusResponse, GscdumpUserSite, PartnerLifecycleSite } from "@gscdump/contracts";
2
+ /**
3
+ * Stable lifecycle fields shared by the legacy partner response and public v1.
4
+ * Keeping the adapters structural lets consumers migrate to v1 without
5
+ * re-introducing legacy-only fields such as `intId` or `lifecycleRevision`.
6
+ */
7
+ type LifecycleSiteLike = Pick<PartnerLifecycleSite, 'siteId' | 'externalSiteId' | 'requestedUrl' | 'gscPropertyUrl' | 'permissionLevel' | 'analytics' | 'indexing' | 'latestError' | 'updatedAt'>;
8
+ declare function analyticsStatusToSyncStatus(status: LifecycleSiteLike['analytics']['status']): GscdumpUserSite['syncStatus'];
9
+ declare function lifecycleSiteToUserSite(site: LifecycleSiteLike): GscdumpUserSite;
10
+ declare function lifecycleSiteToSyncStatus(site: LifecycleSiteLike): GscdumpSyncStatusResponse;
11
+ declare function findLifecycleSite<TSite extends LifecycleSiteLike>(lifecycle: {
12
+ sites: readonly TSite[];
13
+ }, siteIdOrPropertyUrl: string): TSite | null;
14
+ export { LifecycleSiteLike, analyticsStatusToSyncStatus, findLifecycleSite, lifecycleSiteToSyncStatus, lifecycleSiteToUserSite };
@@ -0,0 +1,84 @@
1
+ function normalizeLifecycleUrl(url) {
2
+ return (url || "").replace(/^sc-domain:/, "").replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "").toLowerCase();
3
+ }
4
+ function normalizeGscPropertyKey(url) {
5
+ const value = url || "";
6
+ if (!value) return "";
7
+ if (value.startsWith("sc-domain:")) return `domain:${normalizeLifecycleUrl(value)}`;
8
+ if (!/^https?:\/\//.test(value)) return "";
9
+ return `url:${normalizeLifecycleUrl(value)}`;
10
+ }
11
+ function analyticsStatusToSyncStatus(status) {
12
+ switch (status) {
13
+ case "ready":
14
+ case "queryable_live":
15
+ case "queryable_partial": return "synced";
16
+ case "syncing":
17
+ case "preparing": return "syncing";
18
+ case "failed": return "error";
19
+ default: return "pending";
20
+ }
21
+ }
22
+ function lifecycleSiteToUserSite(site) {
23
+ const syncStatus = analyticsStatusToSyncStatus(site.analytics.status);
24
+ return {
25
+ siteId: site.siteId,
26
+ siteUrl: site.gscPropertyUrl || site.requestedUrl,
27
+ analyticsSyncStatus: syncStatus,
28
+ analyticsSyncProgress: site.analytics.progress,
29
+ syncStatus,
30
+ syncProgress: site.analytics.progress,
31
+ indexingEligible: site.indexing.eligible,
32
+ indexingIneligibleReason: site.indexing.reason,
33
+ indexingPermissionLevel: site.permissionLevel,
34
+ indexingStatus: site.indexing.status === "ready" ? "complete" : site.indexing.status === "not_requested" ? "not_started" : "indexing",
35
+ indexingProgress: site.indexing.progress,
36
+ lastSyncAt: site.updatedAt ? Date.parse(site.updatedAt) : null,
37
+ newestDateSynced: site.analytics.syncedRange.newest,
38
+ oldestDateSynced: site.analytics.syncedRange.oldest
39
+ };
40
+ }
41
+ function lifecycleSiteToSyncStatus(site) {
42
+ const syncStatus = analyticsStatusToSyncStatus(site.analytics.status);
43
+ const completed = site.analytics.progress.completed;
44
+ const failed = site.analytics.progress.failed;
45
+ const total = site.analytics.progress.total;
46
+ const queued = Math.max(total - completed - failed, 0);
47
+ return {
48
+ siteUrl: site.gscPropertyUrl || site.requestedUrl,
49
+ syncStatus,
50
+ oldestDateAvailable: site.analytics.syncedRange.oldest,
51
+ oldestDateSynced: site.analytics.syncedRange.oldest,
52
+ newestDateSynced: site.analytics.syncedRange.newest,
53
+ lastSyncAt: site.updatedAt ? Date.parse(site.updatedAt) : null,
54
+ lastError: site.latestError?.message ?? null,
55
+ jobs: {
56
+ queued,
57
+ processing: [
58
+ "queued",
59
+ "preparing",
60
+ "syncing"
61
+ ].includes(site.analytics.status) ? 1 : 0,
62
+ completed,
63
+ failed
64
+ },
65
+ progress: site.analytics.progress.percent,
66
+ jobProgress: site.analytics.progress.percent,
67
+ daysSynced: completed,
68
+ daysAvailable: total,
69
+ isSyncing: [
70
+ "queued",
71
+ "preparing",
72
+ "syncing"
73
+ ].includes(site.analytics.status),
74
+ hasData: site.analytics.queryable,
75
+ isComplete: site.analytics.queryable && site.analytics.status === "ready",
76
+ tables: {}
77
+ };
78
+ }
79
+ function findLifecycleSite(lifecycle, siteIdOrPropertyUrl) {
80
+ const normalized = normalizeLifecycleUrl(siteIdOrPropertyUrl);
81
+ const propertyKey = normalizeGscPropertyKey(siteIdOrPropertyUrl);
82
+ return lifecycle.sites.find((site) => site.siteId === siteIdOrPropertyUrl || site.externalSiteId === siteIdOrPropertyUrl || !!propertyKey && normalizeGscPropertyKey(site.gscPropertyUrl) === propertyKey || !site.gscPropertyUrl && normalizeLifecycleUrl(site.requestedUrl) === normalized || !propertyKey && normalizeLifecycleUrl(site.requestedUrl) === normalized) ?? null;
83
+ }
84
+ export { analyticsStatusToSyncStatus, findLifecycleSite, lifecycleSiteToSyncStatus, lifecycleSiteToUserSite };
@@ -0,0 +1,49 @@
1
+ type RollingPeriod = '7d' | '28d' | '3m' | '6m' | '12m';
2
+ type CalendarPeriod = 'this-week' | 'this-month' | 'last-month' | 'this-quarter' | 'this-year';
3
+ /**
4
+ * Custom date range from drag-to-zoom.
5
+ * - `custom:CS:CE` - current range only; prev range resolved by compareMode.
6
+ * - `custom:CS:CE:PS:PE` - explicit prev range.
7
+ */
8
+ type CustomPeriod = `custom:${string}:${string}` | `custom:${string}:${string}:${string}:${string}`;
9
+ type Period = RollingPeriod | CalendarPeriod | CustomPeriod;
10
+ type CompareMode = 'previous' | 'year' | 'none';
11
+ interface DateRangeResult {
12
+ start: string;
13
+ end: string;
14
+ prevStart: string;
15
+ prevEnd: string;
16
+ yearStart: string;
17
+ yearEnd: string;
18
+ days: number;
19
+ }
20
+ interface PeriodOptions {
21
+ /** Subtract GSC's stable-data latency from `end`. Default `true`. */
22
+ stableData?: boolean;
23
+ /** IANA timezone used to resolve today's calendar date. Default GSC/Pacific time. */
24
+ timezone?: string;
25
+ /** Clock used to resolve today's calendar date. Defaults to the current time. */
26
+ now?: Date;
27
+ }
28
+ declare function isCustomPeriod(p: Period | string): p is CustomPeriod;
29
+ declare function parseCustomPeriod(p: Period | string): {
30
+ start: string;
31
+ end: string;
32
+ prevStart?: string;
33
+ prevEnd?: string;
34
+ } | null;
35
+ declare function periodToDateRange(period: Period | string, stableDataOrOptions?: boolean | PeriodOptions): DateRangeResult;
36
+ declare function periodToDays(period: Period | string, stableDataOrOptions?: boolean | PeriodOptions): number;
37
+ declare function compareRange(range: DateRangeResult, mode: CompareMode): {
38
+ start: string;
39
+ end: string;
40
+ } | null;
41
+ /**
42
+ * GSC data within the last `GSC_STABLE_LATENCY_DAYS` (PST) is potentially
43
+ * incomplete. Returns the cutoff date (YYYY-MM-DD); compare row dates against
44
+ * this to dim/strike unstable points in charts.
45
+ *
46
+ * Uses YYYY-MM-DD string math directly to avoid UTC/local timezone shifts.
47
+ */
48
+ declare function getGscUnstableCutoffDate(): string;
49
+ export { CalendarPeriod, CompareMode, CustomPeriod, DateRangeResult, Period, PeriodOptions, RollingPeriod, compareRange, getGscUnstableCutoffDate, isCustomPeriod, parseCustomPeriod, periodToDateRange, periodToDays };
@@ -0,0 +1,138 @@
1
+ import "./gsc-constants.mjs";
2
+ import { resolveWindow } from "@gscdump/engine/period";
3
+ import { endOfMonth } from "date-fns/endOfMonth";
4
+ import { format } from "date-fns/format";
5
+ import { startOfMonth } from "date-fns/startOfMonth";
6
+ import { startOfQuarter } from "date-fns/startOfQuarter";
7
+ import { startOfWeek } from "date-fns/startOfWeek";
8
+ import { subDays } from "date-fns/subDays";
9
+ import { subMonths } from "date-fns/subMonths";
10
+ function isCustomPeriod(p) {
11
+ return typeof p === "string" && p.startsWith("custom:");
12
+ }
13
+ function parseCustomPeriod(p) {
14
+ if (!isCustomPeriod(p)) return null;
15
+ const [, start, end, prevStart, prevEnd] = p.split(":");
16
+ if (!start || !end) return null;
17
+ if (prevStart && prevEnd) return {
18
+ start,
19
+ end,
20
+ prevStart,
21
+ prevEnd
22
+ };
23
+ return {
24
+ start,
25
+ end
26
+ };
27
+ }
28
+ function todayInTimezone(timezone = "America/Los_Angeles", now = /* @__PURE__ */ new Date()) {
29
+ const date = new Intl.DateTimeFormat("en-CA", {
30
+ timeZone: timezone,
31
+ year: "numeric",
32
+ month: "2-digit",
33
+ day: "2-digit"
34
+ }).format(now);
35
+ return /* @__PURE__ */ new Date(`${date}T00:00:00`);
36
+ }
37
+ const ROLLING_TO_UPSTREAM = {
38
+ "7d": "last-7d",
39
+ "28d": "last-28d",
40
+ "3m": "last-90d",
41
+ "6m": "last-180d",
42
+ "12m": "last-365d"
43
+ };
44
+ const CALENDAR_TO_UPSTREAM = {
45
+ "this-month": "mtd",
46
+ "this-year": "ytd"
47
+ };
48
+ function fmt(d) {
49
+ return format(d, "yyyy-MM-dd");
50
+ }
51
+ function buildResultFromIso(start, end) {
52
+ const startDate = /* @__PURE__ */ new Date(`${start}T00:00:00`);
53
+ const endDate = /* @__PURE__ */ new Date(`${end}T00:00:00`);
54
+ const days = Math.round((endDate.getTime() - startDate.getTime()) / 864e5) + 1;
55
+ const prev = resolveWindow({
56
+ preset: "custom",
57
+ start,
58
+ end,
59
+ comparison: "prev-period"
60
+ });
61
+ const yoy = resolveWindow({
62
+ preset: "custom",
63
+ start,
64
+ end,
65
+ comparison: "yoy"
66
+ });
67
+ return {
68
+ start,
69
+ end,
70
+ prevStart: prev.comparison.start,
71
+ prevEnd: prev.comparison.end,
72
+ yearStart: yoy.comparison.start,
73
+ yearEnd: yoy.comparison.end,
74
+ days
75
+ };
76
+ }
77
+ function periodToDateRange(period, stableDataOrOptions = true) {
78
+ const options = typeof stableDataOrOptions === "boolean" ? { stableData: stableDataOrOptions } : stableDataOrOptions;
79
+ const stableData = options.stableData ?? true;
80
+ const custom = parseCustomPeriod(period);
81
+ if (custom) {
82
+ const result = buildResultFromIso(custom.start, custom.end);
83
+ if (custom.prevStart && custom.prevEnd) return {
84
+ ...result,
85
+ prevStart: custom.prevStart,
86
+ prevEnd: custom.prevEnd,
87
+ yearStart: custom.prevStart,
88
+ yearEnd: custom.prevEnd
89
+ };
90
+ return result;
91
+ }
92
+ const today = todayInTimezone(options.timezone, options.now);
93
+ const end = stableData ? subDays(today, 3) : subDays(today, 1);
94
+ const endIso = fmt(end);
95
+ const upstreamPreset = ROLLING_TO_UPSTREAM[period] ?? CALENDAR_TO_UPSTREAM[period];
96
+ if (upstreamPreset) {
97
+ const win = resolveWindow({
98
+ preset: upstreamPreset,
99
+ anchor: endIso
100
+ });
101
+ return buildResultFromIso(win.start, win.end);
102
+ }
103
+ let start;
104
+ switch (period) {
105
+ case "this-week":
106
+ start = startOfWeek(end, { weekStartsOn: 1 });
107
+ break;
108
+ case "last-month": {
109
+ const prevMonth = subMonths(end, 1);
110
+ return buildResultFromIso(fmt(startOfMonth(prevMonth)), fmt(endOfMonth(prevMonth)));
111
+ }
112
+ case "this-quarter":
113
+ start = startOfQuarter(end);
114
+ break;
115
+ default: start = subDays(end, 27);
116
+ }
117
+ return buildResultFromIso(fmt(start), endIso);
118
+ }
119
+ function periodToDays(period, stableDataOrOptions = true) {
120
+ return periodToDateRange(period, stableDataOrOptions).days;
121
+ }
122
+ function compareRange(range, mode) {
123
+ if (mode === "none") return null;
124
+ if (mode === "year") return {
125
+ start: range.yearStart,
126
+ end: range.yearEnd
127
+ };
128
+ return {
129
+ start: range.prevStart,
130
+ end: range.prevEnd
131
+ };
132
+ }
133
+ function getGscUnstableCutoffDate() {
134
+ const [y, m, d] = (/* @__PURE__ */ new Date()).toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" }).split("-").map(Number);
135
+ const cutoff = new Date(y, m - 1, d - 3);
136
+ return `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`;
137
+ }
138
+ export { compareRange, getGscUnstableCutoffDate, isCustomPeriod, parseCustomPeriod, periodToDateRange, periodToDays };
@@ -0,0 +1,102 @@
1
+ type SearchConsoleStageKey = 'not_connected' | 'waiting_for_data' | 'weak_discovery' | 'discovery_backlog' | 'crawl_blocked' | 'indexability_blocked' | 'index_rejection' | 'partially_indexed' | 'indexed_invisible' | 'visible_not_clicked' | 'ranking_stalled' | 'declining_visibility' | 'healthy_growth_ready';
2
+ type SearchConsoleStageSeverity = 'success' | 'error' | 'warning' | 'info' | 'neutral';
3
+ interface SearchConsoleStageEvidence {
4
+ label: string;
5
+ value: string;
6
+ source: 'connection' | 'indexing' | 'sitemap' | 'performance' | 'inspection' | 'canonical';
7
+ }
8
+ interface SearchConsoleStage {
9
+ key: SearchConsoleStageKey;
10
+ label: string;
11
+ severity: SearchConsoleStageSeverity;
12
+ summary: string;
13
+ primaryAction: string;
14
+ nextStage: SearchConsoleStageKey | null;
15
+ evidence: SearchConsoleStageEvidence[];
16
+ sprintFindingTypes: string[];
17
+ }
18
+ interface SearchConsoleStageIssue {
19
+ type: string;
20
+ label: string;
21
+ severity?: 'error' | 'warning' | 'info';
22
+ count: number;
23
+ }
24
+ interface SearchConsoleStageSummary {
25
+ totalUrls: number;
26
+ indexed: number;
27
+ indexedPercent: number;
28
+ change7d?: number | null;
29
+ change28d?: number | null;
30
+ }
31
+ /**
32
+ * Trajectory + maturity signals (v2). These are first-class axes: a site that
33
+ * is growing over the robust 90-day window is told to keep expanding, never to
34
+ * "fix indexing", regardless of coverage%. Percent fields are whole numbers
35
+ * (e.g. 42.6 for +42.6%); `positionDelta90d` is current − prior (negative =
36
+ * rank improved). Window contract: callers MUST drop the trailing ~3 GSC lag
37
+ * days before computing these, and the 7-day window is intentionally absent —
38
+ * it is too lag-contaminated to classify on.
39
+ */
40
+ interface SearchConsoleStageTrajectory {
41
+ clicksPct90d?: number | null;
42
+ impressionsPct90d?: number | null;
43
+ positionDelta90d?: number | null;
44
+ clicksPct28d?: number | null;
45
+ /**
46
+ * Absolute clicks in the PRIOR 28-day window — the baseline a decline would
47
+ * be measured against. Gates decline detection so a percentage crash on
48
+ * trivial traffic (8 → 2 clicks) is not mistaken for a real loss.
49
+ */
50
+ clicksPrior28d?: number | null;
51
+ }
52
+ interface SearchConsoleStageSitemap {
53
+ errors?: number | null;
54
+ warnings?: number | null;
55
+ lastError?: string | null;
56
+ urlCount?: number | null;
57
+ }
58
+ interface SearchConsoleStagePage {
59
+ impressions: number;
60
+ clicks: number;
61
+ position?: number | null;
62
+ }
63
+ interface ClassifySearchConsoleStageInput {
64
+ connected: boolean;
65
+ indexingStatus?: 'pending' | 'partial' | 'complete' | 'unknown' | string | null;
66
+ summary?: SearchConsoleStageSummary | null;
67
+ issues?: SearchConsoleStageIssue[] | null;
68
+ sitemaps?: SearchConsoleStageSitemap[] | null;
69
+ canonicalMismatchCount?: number | null;
70
+ pageInventory?: SearchConsoleStagePage[] | null;
71
+ ctrOutlierCount?: number | null;
72
+ pageMoverDropCount?: number | null;
73
+ /** v2 trajectory axis — when present, drives the growth override + decline detection. */
74
+ trajectory?: SearchConsoleStageTrajectory | null;
75
+ /** v2 maturity axis — impressions over the trailing 28 days. Gates whether coverage% is even meaningful. */
76
+ impressions28d?: number | null;
77
+ /**
78
+ * v2 on-page technical faults from the crawl audit (broken links/images,
79
+ * server errors, access failures) — counted as hard blockers alongside GSC
80
+ * crawl reasons. Excludes intentional noindex.
81
+ */
82
+ crawlAuditBlockerCount?: number | null;
83
+ /** v2 authority signal — open recoverable broken backlinks (expansion lever, not a defect). */
84
+ recoverableBacklinkCount?: number | null;
85
+ /** v2 authority signal — cross-competitor content-gap topics (expansion readiness). */
86
+ competitorGapCount?: number | null;
87
+ /**
88
+ * v2 site purpose (AI profile `type`). Benchmarks the verdict against intent:
89
+ * informational types (docs/blog/portfolio) earn structurally low CTR, so the
90
+ * visible-not-clicked bar is raised for them. Unknown/null → `other`.
91
+ */
92
+ siteType?: string | null;
93
+ }
94
+ /**
95
+ * v2 classifier. Trajectory and maturity are first-class axes that run BEFORE
96
+ * the coverage/discovery rungs, so a growing site is told to keep expanding —
97
+ * never to "fix indexing". Reuses the existing stage-key enum (growth →
98
+ * `healthy_growth_ready`, nascent → `waiting_for_data`, mass crawled-not-indexed
99
+ * → `index_rejection`, on-page/crawl faults → `crawl_blocked`).
100
+ */
101
+ declare function classifySearchConsoleStage(input: ClassifySearchConsoleStageInput): SearchConsoleStage;
102
+ export { ClassifySearchConsoleStageInput, SearchConsoleStage, SearchConsoleStageEvidence, SearchConsoleStageIssue, SearchConsoleStageKey, SearchConsoleStagePage, SearchConsoleStageSeverity, SearchConsoleStageSitemap, SearchConsoleStageSummary, SearchConsoleStageTrajectory, classifySearchConsoleStage };