Repository: harlan-zw/request-indexing Branch: main Commit: 219014292a49 Files: 90 Total size: 388.7 KB Directory structure: gitextract_zzq12ri9/ ├── .editorconfig ├── .eslintignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.yml │ └── workflows/ │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── app/ │ └── router.options.ts ├── app.d.ts ├── app.vue ├── components/ │ ├── Footer.vue │ ├── GithubStar.vue │ ├── GoogleSvg.vue │ ├── Gradient.vue │ ├── GraphClicks.vue │ ├── Header.vue │ ├── Icon/ │ │ ├── IconClicks.vue │ │ └── IconImpressions.vue │ ├── InspectionResult.vue │ ├── MetricGuage.vue │ ├── OgImage/ │ │ └── Home.vue │ ├── PositionMetric.vue │ ├── SiteCard.vue │ ├── Table/ │ │ ├── TableData.vue │ │ ├── TableKeywords.vue │ │ ├── TableNonIndexedUrls.vue │ │ └── TablePages.vue │ └── TrendPercentage.vue ├── composables/ │ ├── auth.ts │ ├── fetch.ts │ ├── formatting.ts │ └── loader.ts ├── data/ │ └── home.ts ├── error.vue ├── eslint.config.js ├── layouts/ │ ├── account.vue │ ├── auth.vue │ └── default.vue ├── middleware/ │ └── auth.global.ts ├── nuxt.config.ts ├── package.json ├── pages/ │ ├── account/ │ │ ├── index.vue │ │ └── upgrade.vue │ ├── admin/ │ │ └── index.vue │ ├── dashboard/ │ │ ├── index.vue │ │ └── site/ │ │ └── [slug].vue │ ├── get-started.vue │ ├── index.vue │ ├── privacy.vue │ └── terms.vue ├── robots.txt ├── server/ │ ├── api/ │ │ ├── admin/ │ │ │ └── usage.get.ts │ │ ├── github/ │ │ │ └── repo.get.ts │ │ ├── indexing/ │ │ │ ├── [url].post.ts │ │ │ └── auth.delete.ts │ │ ├── sites/ │ │ │ ├── [siteUrl]/ │ │ │ │ ├── [url].get.ts │ │ │ │ └── crawl.post.ts │ │ │ ├── [siteUrl].get.ts │ │ │ └── list.get.ts │ │ └── user/ │ │ ├── me.delete.ts │ │ └── me.post.ts │ ├── composables/ │ │ └── auth.ts │ ├── email/ │ │ └── welcome.ts │ ├── middleware/ │ │ └── auth.ts │ ├── routes/ │ │ └── auth/ │ │ ├── google-indexing.get.ts │ │ └── google.get.ts │ ├── tsconfig.json │ └── utils/ │ ├── api/ │ │ └── googleSearchConsole.ts │ ├── auth/ │ │ └── googleAuthEventHandler.ts │ ├── crawler/ │ │ ├── crawl.ts │ │ └── robotsTxt.ts │ ├── crypto.ts │ ├── date.ts │ ├── formatting.ts │ ├── oauthPool.ts │ ├── quota.ts │ ├── session.ts │ ├── sharedCache.ts │ └── storage.ts ├── tailwind.config.ts ├── tsconfig.json ├── types/ │ ├── auth.ts │ ├── data.ts │ ├── index.ts │ ├── nitro.d.ts │ └── util.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # editorconfig.org root = true [*] indent_size = 2 indent_style = space end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false ================================================ FILE: .eslintignore ================================================ dist node_modules test/fixtures playground/* saas .unlighthouse ================================================ FILE: .github/FUNDING.yml ================================================ github: [harlan-zw] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐞 Bug report description: Report an issue labels: [pending triage] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: textarea id: bug-description attributes: label: Describe the bug description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks! placeholder: Bug description validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 🚀 New feature proposal description: Propose a new feature labels: [enhancement] body: - type: markdown attributes: value: | Thanks for your interest in the project and taking the time to fill out this feature report! - type: textarea id: feature-description attributes: label: Clear and concise description of the problem description: 'As a developer using VueUse I want [goal / wish] so that [benefit]. If you intend to submit a PR for this issue, tell us in the description. Thanks!' validations: required: true ================================================ FILE: .github/workflows/release.yml ================================================ name: Release permissions: contents: write on: push: tags: - 'v*' jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Install pnpm uses: pnpm/action-setup@v2 - name: Set node uses: actions/setup-node@v3 with: node-version: 18.x - run: npx changelogithub env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} ================================================ FILE: .gitignore ================================================ # Nuxt dev/build outputs .output .data ../.nuxt .nuxt .nitro .cache dist .saas # Node dependencies node_modules # Logs logs *.log # Misc .DS_Store .fleet .idea # Local env files .env .env.* !.env.example .tokens.js .db ================================================ FILE: .npmrc ================================================ shamefully-hoist=true ignore-workspace-root-check=true ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Harlan Wilton Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Request Indexing

Get your pages indexed on Google within 48 hours. (on average)


requestindexing.com 🥳
Please report any issues 🐛
Made possible by my Sponsor Program 💖
Follow me @harlan_zw 🐦 • Join Discord for help

> [!NOTE] > These docs are a work in progress. Please check back soon for updates. ## Features - ⚡ Request indexing on new sites and pages, have them appear on Google in 48 hours. - 📊 Dashboard to see the search performance of all your Google Search Console sites. - 🗓️ Keep all your site data. Google Search Console data deletes site data longer than 16 months, start keeping it. (soon) ## Background Building a SaaS is quick and easy with [Nuxt](https://nuxt.com). This project is an effort to prove that and was a success. I shipped the first version in 64 hours total. Built With: - [Nuxt](https://nuxt.com) - [Nuxt UI Pro](https://ui.nuxt.com/pro?aff=5zj9e) - [Nuxt SEO](https://nuxtseo.com) - [Google APIs](https://developers.google.com/apis-explorer) Credits to [google-indexing-script](https://github.com/goenning/google-indexing-script) is the inspiration for this project. Learn more about how it works by reading the [Google Indexing Script](https://seogets.com/blog/google-indexing-script). ## Run Locally 1. Git clone the project: ```bash git clone git@github.com:harlan-zw/request-indexing.git ``` 2. Install deps: ```bash pnpm i ``` 3. Configure the keys: You will need to create a Google OAuth Client ID and Secret. You can do this by visiting the [Google Developer Console](https://console.developers.google.com/). The following scopes are required: - `userinfo.email` - `webmasters.readonly` - `indexing` You will need to add the redirect URL to your OAuth client. This will be `http://localhost:3000/auth/google` and `http://localhost:3000/auth/google-indexing`. ```bash NUXT_OAUTH_GOOGLE_CLIENT_ID= NUXT_OAUTH_GOOGLE_CLIENT_SECRET= ``` You should also set a unique 32 character string for the security keys: ```bash NUXT_KEY= NUXT_SESSION_PASSWORD= ``` 4. Start the server: ```bash pnpm dev ``` 5. Building your site: To build and deploy site you will need to purchase a [Nuxt UI Pro](https://ui.nuxt.com/pro?aff=5zj9e) license. Once you have your license update your `.env` file with your license key: ```bash NUXT_UI_PRO_LICENSE_KEY= ``` Then run the build command: ```bash pnpm build ``` That's it! ## Sponsors

## License MIT License © 2022-PRESENT [Harlan Wilton](https://github.com/harlan-zw) ================================================ FILE: app/router.options.ts ================================================ import type { RouterConfig } from '@nuxt/schema' function findHashPosition(hash): { el: any, behavior: ScrollBehavior, top: number } { const el = document.querySelector(hash) // vue-router does not incorporate scroll-margin-top on its own. if (el) { const top = Number.parseFloat(getComputedStyle(el).scrollMarginTop) return { el: hash, behavior: 'smooth', top, } } } // https://router.vuejs.org/api/#routeroptions export default { scrollBehavior(to, from, savedPosition) { const nuxtApp = useNuxtApp() // If history back if (savedPosition) { // Handle Suspense resolution return new Promise((resolve) => { nuxtApp.hooks.hookOnce('page:finish', () => { setTimeout(() => resolve(savedPosition), 50) }) }) } // Scroll to heading on click if (to.hash) { return new Promise((resolve) => { if (to.path === from.path) { setTimeout(() => resolve(findHashPosition(to.hash)), 50) } else { nuxtApp.hooks.hookOnce('page:finish', () => { setTimeout(() => resolve(findHashPosition(to.hash)), 50) }) } }) } // Scroll to top of window return { top: 0 } }, } ================================================ FILE: app.d.ts ================================================ module '#auth-utils' { export interface UserSession { user?: { userId: string picture: string indexingTokenId?: string } } } export {} ================================================ FILE: app.vue ================================================ ================================================ FILE: components/Footer.vue ================================================ ================================================ FILE: components/GithubStar.vue ================================================ ================================================ FILE: components/GoogleSvg.vue ================================================ ================================================ FILE: components/Gradient.vue ================================================ ================================================ FILE: components/GraphClicks.vue ================================================ ================================================ FILE: components/Header.vue ================================================ ================================================ FILE: components/Icon/IconClicks.vue ================================================ ================================================ FILE: components/Icon/IconImpressions.vue ================================================ ================================================ FILE: components/InspectionResult.vue ================================================ ================================================ FILE: components/MetricGuage.vue ================================================ ================================================ FILE: components/OgImage/Home.vue ================================================ ================================================ FILE: components/PositionMetric.vue ================================================ ================================================ FILE: components/SiteCard.vue ================================================ ================================================ FILE: components/Table/TableData.vue ================================================ ================================================ FILE: components/Table/TableKeywords.vue ================================================ ================================================ FILE: components/Table/TableNonIndexedUrls.vue ================================================ ================================================ FILE: components/Table/TablePages.vue ================================================ ================================================ FILE: components/TrendPercentage.vue ================================================ ================================================ FILE: composables/auth.ts ================================================ import type { ComputedRef } from 'vue' import type { User } from '~/types' export function useAuthenticatedUser() { const { loggedIn, user } = useUserSession() if (!loggedIn) { throw createError({ statusCode: 401, message: 'Unauthorized', }) } return user as ComputedRef } export function createSessionReloader() { const { session } = useUserSession() return async () => { session.value = await $fetch('/api/_auth/session') } } // work around nuxt-auth-utils async context bug export function createLogoutHandler() { const { session } = useUserSession() const toast = useToast() const nextTickFn = nextTick return async (force?: boolean) => { if (!force) { toast.add({ id: 'logout', title: 'See you next time!', description: 'You have logged out of the site.', color: 'green' }) await navigateTo('/') } else { await navigateTo('/get-started') } await nextTickFn(() => { // can't access clear API here $fetch('/api/_auth/session', { method: 'DELETE' }) .then(() => { session.value = {} }) .catch(() => {}) }) } } ================================================ FILE: composables/fetch.ts ================================================ import { createLogoutHandler } from '~/composables/auth' import type { GoogleSearchConsoleSite } from '~/types' import { useAsyncData } from '#imports' export async function fetchSite(site: GoogleSearchConsoleSite, isMock?: boolean) { const toast = useToast() const logout = createLogoutHandler() const force = ref() const fetchFn = useRequestFetch() const res = await useAsyncData( `sites:${site.siteUrl}`, async () => await fetchFn(`/api/sites/${encodeURIComponent(site.siteUrl)}`, { query: { force: force.value }, onResponseError: async ({ response }) => { if (response.status === 401) { // make sure we have context await logout(true) toast.add({ id: 'unauthorized-error', title: 'Oops, looks like session has expired.', description: 'Please login again to continue.', color: 'red', }) } else { toast.add({ id: `sites:${site.siteUrl}:error`, title: `Failed to fetch ${site.siteUrl}`, description: response.statusText, color: 'red', }) } }, }), { server: false, deep: false, immediate: !isMock, }, ) return { ...res, async forceRefresh() { return callFnSyncToggleRef(res.refresh, force) }, } } export async function fetchSites() { const force = ref() const toast = useToast() const logout = createLogoutHandler() const fetchFn = useRequestFetch() const res = await useAsyncData( `sites`, async () => await fetchFn('/api/sites/list', { query: { force: force.value }, async onResponseError(res) { if ([401, 400].includes(res.response.status)) { // make sure we have context await logout(true) toast.add({ id: 'unauthorized-error', title: 'Oops, looks like session has expired.', description: 'Please login again to continue.', color: 'red', }) } else { toast.add({ id: 'unauthorized-error', title: 'Error fetching sites', description: res.error?.message, color: 'red' }) } }, }), { server: true, deep: false, }, ) return { ...res, forceRefresh() { force.value = true res.refresh().then(() => { force.value = false }) }, } } ================================================ FILE: composables/formatting.ts ================================================ import type { Ref } from '@vue/reactivity' import type { type ComputedRef, MaybeRef } from 'vue' import { withoutTrailingSlash } from 'ufo' function useHumanFriendlyNumber(number: Ref): ComputedRef function useHumanFriendlyNumber(number: number): string export function useHumanFriendlyNumber(number: MaybeRef) { const format = (number: number) => new Intl.NumberFormat('en', { notation: 'compact' }).format(number) if (isRef(number)) { return computed(() => { return format(number.value) }) } // use intl to format the number, should have `k` or `m` suffix if needed return format(number) } export function useFriendlySiteUrl(url: string): string export function useFriendlySiteUrl(url: MaybeRef) { const format = (s: string) => withoutTrailingSlash(s.replace('https://', '').replace('sc-domain:', '')) if (isRef(url)) { return computed(() => { return format(url.value) }) } // use intl to format the number, should have `k` or `m` suffix if needed return format(url) } export function useTimeAgo(date: string, absAgo?: boolean): string export function useTimeAgo(date: MaybeRef, absAgo?: boolean): string { const format = (_d: string) => { const d = useDayjs()(_d) const hourDiff = useDayjs()().diff(d, 'hour') if (hourDiff < 1 || absAgo) return d.fromNow() return `${hourDiff} hours ago` } if (isRef(date)) { return computed(() => { return format(date.value) }) } return format(date) } export function useTimeHoursAgo(date: string): string export function useTimeHoursAgo(date: MaybeRef) { const format = (_d: string) => { const d = useDayjs()(_d) return useDayjs()().diff(d, 'hour') } if (isRef(date)) { return computed(() => { return format(date.value) }) } return format(date) } ================================================ FILE: composables/loader.ts ================================================ import type { Ref } from 'vue' export async function callFnSyncToggleRef (Promise | any))>( fn: T, toggleRef: Ref, thresholdMs: number = 550, ): Promise> { toggleRef.value = true return callFnDelayedResolve(fn, thresholdMs).finally(() => { toggleRef.value = false }) } export async function callFnDelayedResolve (Promise | any))>( fn: T, thresholdMs: number = 550, ): Promise> { const res = await Promise.all([ fn(), new Promise(resolve => setTimeout(resolve, thresholdMs)), ]) return Array.isArray(res) ? res[0] : res } ================================================ FILE: data/home.ts ================================================ export const NuxtSeoPages = [ { url: '/', clicks: 654, prevClicks: 665, clicksPercent: -1.6679302501895377, impressions: 2414, impressionsPercent: -2.5761602944183193, prevImpressions: 2477, }, { url: '/sitemap/getting-started/installation', clicks: 206, prevClicks: 242, clicksPercent: -16.071428571428573, impressions: 2807, impressionsPercent: 5.9427732942039615, prevImpressions: 2645, }, { url: '/robots/getting-started/installation', clicks: 62, prevClicks: 58, clicksPercent: 6.666666666666667, impressions: 2216, impressionsPercent: 56.11095059231436, prevImpressions: 1245, }, { url: '/og-image/guides/cache', clicks: 46, prevClicks: 49, clicksPercent: -6.315789473684211, impressions: 550, impressionsPercent: -6.848112379280071, prevImpressions: 589, }, { url: '/experiments/guides/open-graph-images', clicks: 40, prevClicks: 22, clicksPercent: 58.06451612903226, impressions: 2332, impressionsPercent: 18.14780168381665, prevImpressions: 1944, }, { url: '/sitemap/guides/prerendering', clicks: 39, prevClicks: 37, clicksPercent: 5.263157894736842, impressions: 821, impressionsPercent: 19.812583668005352, prevImpressions: 673, }, { url: '/og-image/api/define-og-image', clicks: 32, prevClicks: 10, clicksPercent: 104.76190476190477, impressions: 207, impressionsPercent: 118.46153846153847, prevImpressions: 53, }, { url: '/site-config/getting-started/installation', clicks: 27, prevClicks: 28, clicksPercent: -3.6363636363636362, impressions: 349, impressionsPercent: -124.98656636217088, prevImpressions: 1512, }, { url: '/og-image/getting-started/installation', clicks: 26, prevClicks: 33, clicksPercent: -23.728813559322035, impressions: 1182, impressionsPercent: 21.867667761614264, prevImpressions: 949, }, { url: '/sitemap/guides/dynamic-urls', clicks: 24, prevClicks: 17, clicksPercent: 34.146341463414636, impressions: 757, impressionsPercent: 24.296296296296298, prevImpressions: 593, }, { url: '/nuxt-seo/guides/redirect-canonical', clicks: 22, prevClicks: 2, clicksPercent: 166.66666666666669, impressions: 339, impressionsPercent: 94.14316702819957, prevImpressions: 122, }, { url: '/robots/guides/disable-indexing', clicks: 21, prevClicks: 3, clicksPercent: 150, impressions: 132, impressionsPercent: 123.92638036809815, prevImpressions: 31, }, { url: '/sitemap/releases/v4', clicks: 20, prevClicks: 16, clicksPercent: 22.22222222222222, impressions: 405, impressionsPercent: 8.494208494208493, prevImpressions: 372, }, { url: '/nuxt-seo/guides/title-templates', clicks: 19, prevClicks: 12, clicksPercent: 45.16129032258064, impressions: 641, impressionsPercent: -2.1604938271604937, prevImpressions: 655, }, { url: '/schema-org/getting-started/installation', clicks: 19, prevClicks: 18, clicksPercent: 5.405405405405405, impressions: 654, impressionsPercent: 10.120481927710843, prevImpressions: 591, }, { url: '/sitemap/api/config', clicks: 19, prevClicks: 10, clicksPercent: 62.06896551724138, impressions: 263, impressionsPercent: 41.284403669724774, prevImpressions: 173, }, { url: '/robots/guides/robots-txt', clicks: 13, prevClicks: 6, clicksPercent: 73.68421052631578, impressions: 386, impressionsPercent: 129.21108742004265, prevImpressions: 83, }, { url: '/robots/guides/route-rules', clicks: 13, prevClicks: 10, clicksPercent: 26.08695652173913, impressions: 350, impressionsPercent: -1.9801980198019802, prevImpressions: 357, }, { url: '/nuxt-seo/api/breadcrumbs', clicks: 12, prevClicks: 4, clicksPercent: 100, impressions: 42, impressionsPercent: 50.74626865671642, prevImpressions: 25, }, { url: '/site-config/getting-started/how-it-works', clicks: 12, prevClicks: 6, clicksPercent: 66.66666666666666, impressions: 1102, impressionsPercent: 6.077606358111267, prevImpressions: 1037, }, { url: '/site-config/guides/setting-site-config', clicks: 12, prevClicks: 6, clicksPercent: 66.66666666666666, impressions: 80, impressionsPercent: -7.228915662650602, prevImpressions: 86, }, { url: '/sitemap/guides/best-practices', clicks: 12, prevClicks: 2, clicksPercent: 142.85714285714286, impressions: 701, impressionsPercent: -20.601407549584135, prevImpressions: 862, }, { url: '/sitemap/guides/multi-sitemaps', clicks: 12, prevClicks: 6, clicksPercent: 66.66666666666666, impressions: 324, impressionsPercent: -9.131075110456553, prevImpressions: 355, }, { url: '/sitemap/integrations/i18n', clicks: 12, prevClicks: 4, clicksPercent: 100, impressions: 635, impressionsPercent: 0.949367088607595, prevImpressions: 629, }, { url: '/robots/api/config', clicks: 11, prevClicks: 8, clicksPercent: 31.57894736842105, impressions: 371, impressionsPercent: 6.397774687065369, prevImpressions: 348, }, { url: '/og-image/api/define-og-image-component', clicks: 10, prevClicks: 6, clicksPercent: 50, impressions: 45, impressionsPercent: 43.24324324324324, prevImpressions: 29, }, { url: '/link-checker/getting-started/installation', clicks: 9, prevClicks: 4, clicksPercent: 76.92307692307693, impressions: 419, impressionsPercent: 24.966442953020135, prevImpressions: 326, }, { url: '/nuxt-seo/guides/configuring-modules', clicks: 9, prevClicks: 5, clicksPercent: 57.14285714285714, impressions: 107, impressionsPercent: -7.207207207207207, prevImpressions: 115, }, { url: '/og-image/guides/custom-fonts', clicks: 9, prevClicks: 6, clicksPercent: 40, impressions: 401, impressionsPercent: 71.40439932318104, prevImpressions: 190, }, { url: '/nuxt-seo/migration-guide/nuxt-seo-kit', clicks: 8, prevClicks: 0, clicksPercent: 0, impressions: 343, impressionsPercent: 158.22454308093995, prevImpressions: 40, }, { url: '/sitemap/guides/cache', clicks: 8, prevClicks: 4, clicksPercent: 66.66666666666666, impressions: 226, impressionsPercent: 41.06666666666667, prevImpressions: 149, }, { url: '/sitemap/releases/v3', clicks: 8, prevClicks: 4, clicksPercent: 66.66666666666666, impressions: 181, impressionsPercent: 16.766467065868262, prevImpressions: 153, }, { url: '/og-image/guides/satori', clicks: 7, prevClicks: 1, clicksPercent: 150, impressions: 54, impressionsPercent: 154.0983606557377, prevImpressions: 7, }, { url: '/og-image/releases/v3', clicks: 7, prevClicks: 7, clicksPercent: 0, impressions: 273, impressionsPercent: -21.859706362153343, prevImpressions: 340, }, { url: '/experiments/getting-started/installation', clicks: 6, prevClicks: 2, clicksPercent: 100, impressions: 1862, impressionsPercent: -5.8900182434193376, prevImpressions: 1975, }, { url: '/robots/guides/disable-page-indexing', clicks: 6, prevClicks: 2, clicksPercent: 100, impressions: 108, impressionsPercent: 132.3076923076923, prevImpressions: 22, }, { url: '/nuxt-seo/guides/fallback-title', clicks: 5, prevClicks: 0, clicksPercent: 0, impressions: 253, impressionsPercent: 80.33240997229917, prevImpressions: 108, }, { url: '/nuxt-seo/guides/trailing-slashes', clicks: 5, prevClicks: 2, clicksPercent: 85.71428571428571, impressions: 18, impressionsPercent: -36.36363636363637, prevImpressions: 26, }, { url: '/site-config/api/site-link', clicks: 5, prevClicks: 2, clicksPercent: 85.71428571428571, impressions: 30, impressionsPercent: 124.32432432432432, prevImpressions: 7, }, { url: '/site-config/api/use-site-config', clicks: 5, prevClicks: 3, clicksPercent: 50, impressions: 27, impressionsPercent: 34.78260869565217, prevImpressions: 19, }, { url: '/experiments/guides/nuxt-config-seo-meta', clicks: 4, prevClicks: 0, clicksPercent: 0, impressions: 343, impressionsPercent: 187.57062146892656, prevImpressions: 11, }, { url: '/nuxt-seo/api/config', clicks: 4, prevClicks: 1, clicksPercent: 120, impressions: 20, impressionsPercent: 66.66666666666666, prevImpressions: 10, }, { url: '/nuxt-seo/getting-started/installation', clicks: 4, prevClicks: 6, clicksPercent: -40, impressions: 1788, impressionsPercent: -5.281786005989654, prevImpressions: 1885, }, { url: '/schema-org/guides/nodes', clicks: 4, prevClicks: 3, clicksPercent: 28.57142857142857, impressions: 84, impressionsPercent: 30.136986301369863, prevImpressions: 62, }, { url: '/site-config/integrations/i18n', clicks: 4, prevClicks: 0, clicksPercent: 0, impressions: 505, impressionsPercent: 175.4646840148699, prevImpressions: 33, }, { url: '/sitemap/guides/route-rules', clicks: 4, prevClicks: 5, clicksPercent: -22.22222222222222, impressions: 287, impressionsPercent: -13.333333333333334, prevImpressions: 328, }, { url: '/ui', clicks: 4, prevClicks: 6, clicksPercent: -40, impressions: 47, impressionsPercent: -88.09523809523809, prevImpressions: 121, }, { url: '/link-checker/releases/v2', clicks: 3, prevClicks: 6, clicksPercent: -66.66666666666666, impressions: 177, impressionsPercent: 42.465753424657535, prevImpressions: 115, }, { url: '/robots/getting-started/how-it-works', clicks: 3, prevClicks: 4, clicksPercent: -28.57142857142857, impressions: 283, impressionsPercent: 161.66134185303514, prevImpressions: 30, }, { url: '/schema-org/guides/default-schema-org', clicks: 3, prevClicks: 2, clicksPercent: 40, impressions: 23, impressionsPercent: 78.78787878787878, prevImpressions: 10, }, { url: '/site-config/api/config', clicks: 3, prevClicks: 3, clicksPercent: 0, impressions: 8, impressionsPercent: -47.61904761904761, prevImpressions: 13, }, { url: '/site-config/api/nuxt-hooks', clicks: 3, prevClicks: 0, clicksPercent: 0, impressions: 117, impressionsPercent: 174.4, prevImpressions: 8, }, { url: '/site-config/guides/runtime-site-config', clicks: 3, prevClicks: 0, clicksPercent: 0, impressions: 110, impressionsPercent: 10.526315789473683, prevImpressions: 99, }, { url: '/sitemap/api/schema', clicks: 3, prevClicks: 0, clicksPercent: 0, impressions: 104, impressionsPercent: 188.78504672897196, prevImpressions: 3, }, { url: '/sitemap/guides/debugging', clicks: 3, prevClicks: 2, clicksPercent: 40, impressions: 72, impressionsPercent: -5.405405405405405, prevImpressions: 76, }, { url: '/sitemap/guides/images-videos', clicks: 3, prevClicks: 0, clicksPercent: 0, impressions: 35, impressionsPercent: 50, prevImpressions: 21, }, { url: '/sitemap/integrations/content', clicks: 3, prevClicks: 1, clicksPercent: 100, impressions: 205, impressionsPercent: 131.9838056680162, prevImpressions: 42, }, { url: '/sitemap/releases/v5', clicks: 3, prevClicks: 1, clicksPercent: 100, impressions: 67, impressionsPercent: 12.698412698412698, prevImpressions: 59, }, { url: '/link-checker/guides/live-inspections', clicks: 2, prevClicks: 4, clicksPercent: -66.66666666666666, impressions: 43, impressionsPercent: -6.741573033707865, prevImpressions: 46, }, { url: '/og-image/api/components', clicks: 2, prevClicks: 1, clicksPercent: 66.66666666666666, impressions: 25, impressionsPercent: 170.37037037037038, prevImpressions: 2, }, { url: '/og-image/api/define-og-image-screenshot', clicks: 2, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/getting-started/getting-familar-with-nuxt-og-image', clicks: 2, prevClicks: 1, clicksPercent: 66.66666666666666, impressions: 95, impressionsPercent: 130.43478260869566, prevImpressions: 20, }, { url: '/og-image/guides/chromium', clicks: 2, prevClicks: 0, clicksPercent: 0, impressions: 24, impressionsPercent: 82.35294117647058, prevImpressions: 10, }, { url: '/robots/guides/nuxt-config', clicks: 2, prevClicks: 2, clicksPercent: 0, impressions: 101, impressionsPercent: -12.093023255813954, prevImpressions: 114, }, { url: '/sitemap/getting-started/data-sources', clicks: 2, prevClicks: 0, clicksPercent: 0, impressions: 8, impressionsPercent: 0, prevImpressions: 8, }, { url: '/sitemap/guides/customising-ui', clicks: 2, prevClicks: 1, clicksPercent: 66.66666666666666, impressions: 8, impressionsPercent: 0, prevImpressions: 8, }, { url: '/sitemap/guides/filtering-urls', clicks: 2, prevClicks: 1, clicksPercent: 66.66666666666666, impressions: 9, impressionsPercent: -20, prevImpressions: 11, }, { url: '/sitemap/guides/submitting-sitemap', clicks: 2, prevClicks: 0, clicksPercent: 0, impressions: 17, impressionsPercent: 0, prevImpressions: 0, }, { url: '/sitemap/nitro-api/nitro-hooks', clicks: 2, prevClicks: 2, clicksPercent: 0, impressions: 44, impressionsPercent: -6.593406593406594, prevImpressions: 47, }, { url: '/link-checker/api/config', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 3, impressionsPercent: 0, prevImpressions: 0, }, { url: '/link-checker/guides/exclude-links', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo/getting-started', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo/getting-started/what-is-nuxt-seo', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 99, impressionsPercent: -7.766990291262135, prevImpressions: 107, }, { url: '/nuxt-seo/guides/default-meta', clicks: 1, prevClicks: 1, clicksPercent: 0, impressions: 35, impressionsPercent: 69.23076923076923, prevImpressions: 17, }, { url: '/nuxt-seo/guides/disabling-modules', clicks: 1, prevClicks: 2, clicksPercent: -66.66666666666666, impressions: 4, impressionsPercent: -85.71428571428571, prevImpressions: 10, }, { url: '/og-image/api/config', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 20, impressionsPercent: 75.86206896551724, prevImpressions: 9, }, { url: '/og-image/guides/emojis', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 11, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/guides/icons-and-images', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/integrations/content', clicks: 1, prevClicks: 2, clicksPercent: -66.66666666666666, impressions: 54, impressionsPercent: 22.68041237113402, prevImpressions: 43, }, { url: '/og-image/nitro-api/nitro-hooks', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 53, impressionsPercent: 0, prevImpressions: 0, }, { url: '/robots/api/nuxt-hooks', clicks: 1, prevClicks: 5, clicksPercent: -133.33333333333331, impressions: 124, impressionsPercent: 65.24064171122996, prevImpressions: 63, }, { url: '/robots/getting-started/features', clicks: 1, prevClicks: 3, clicksPercent: -100, impressions: 26, impressionsPercent: -105.45454545454544, prevImpressions: 84, }, { url: '/robots/integrations/i18n', clicks: 1, prevClicks: 1, clicksPercent: 0, impressions: 703, impressionsPercent: 175.43391188251002, prevImpressions: 46, }, { url: '/robots/nitro-api/get-site-indexable', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: 0, prevImpressions: 0, }, { url: '/robots/releases/v4', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 67, impressionsPercent: 0, prevImpressions: 0, }, { url: '/schema-org/api/nuxt-hooks', clicks: 1, prevClicks: 2, clicksPercent: -66.66666666666666, impressions: 42, impressionsPercent: 111.11111111111111, prevImpressions: 12, }, { url: '/site-config/getting-started/background', clicks: 1, prevClicks: 2, clicksPercent: -66.66666666666666, impressions: 611, impressionsPercent: 27.137546468401485, prevImpressions: 465, }, { url: '/site-config/guides/debugging', clicks: 1, prevClicks: 0, clicksPercent: 0, impressions: 15, impressionsPercent: -46.15384615384615, prevImpressions: 24, }, { url: '/experiments/api/config', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: 0, prevImpressions: 0, }, { url: '/experiments/getting-started/features', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 9, impressionsPercent: 0, prevImpressions: 0, }, { url: '/experiments/guides/app-icons', clicks: 0, prevClicks: 1, clicksPercent: 0, impressions: 2, impressionsPercent: -85.71428571428571, prevImpressions: 5, }, { url: '/experiments/guides/open-graph-images', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 56, impressionsPercent: 83.54430379746836, prevImpressions: 23, }, { url: '/experiments/guides/open-graph-images', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 56, impressionsPercent: 83.54430379746836, prevImpressions: 23, }, { url: '/experiments/guides/open-graph-images', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 56, impressionsPercent: 83.54430379746836, prevImpressions: 23, }, { url: '/experiments/guides/route-rules', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 2, impressionsPercent: -40, prevImpressions: 3, }, { url: '/experiments/releases/v3', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 63, impressionsPercent: 0, prevImpressions: 0, }, { url: '/learn', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: -150, prevImpressions: 7, }, { url: '/link-checker/guides/build-scans', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 2, impressionsPercent: 66.66666666666666, prevImpressions: 1, }, { url: '/link-checker/guides/skip-inspections', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 2, impressionsPercent: 0, prevImpressions: 0, }, { url: '/link-checker/integrations/sitemap', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 7, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo/getting-started/faq', clicks: 0, prevClicks: 1, clicksPercent: 0, impressions: 12, impressionsPercent: -147.25274725274727, prevImpressions: 79, }, { url: '/nuxt-seo/guides/going-live', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 17, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo/guides/i18n', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 17, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo/guides/using-the-modules', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 12, impressionsPercent: 142.85714285714286, prevImpressions: 2, }, { url: '/nuxt-seo/migration-guide/nuxt-seo-kit', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo/migration-guide/nuxt-seo-kit', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo/migration-guide/nuxt-seo-kit', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/nuxt-seo/migration-guide/nuxt-seo-kit', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/api/define-og-image', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 36, impressionsPercent: 160, prevImpressions: 4, }, { url: '/og-image/api/define-og-image', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 36, impressionsPercent: 160, prevImpressions: 4, }, { url: '/og-image/api/define-og-image', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 36, impressionsPercent: 160, prevImpressions: 4, }, { url: '/og-image/api/nuxt-hooks', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 6, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/api/nuxt-seo-template', clicks: 0, prevClicks: 1, clicksPercent: 0, impressions: 2, impressionsPercent: -66.66666666666666, prevImpressions: 4, }, { url: '/og-image/getting-started/examples', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 2, impressionsPercent: -66.66666666666666, prevImpressions: 4, }, { url: '/og-image/getting-started/stackblitz', clicks: 0, prevClicks: 2, clicksPercent: 0, impressions: 20, impressionsPercent: -36.734693877551024, prevImpressions: 29, }, { url: '/og-image/guides/compatibility', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/guides/jpegs', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 2, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/guides/styling', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/integrations/color-mode', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 59, impressionsPercent: 147.05882352941177, prevImpressions: 9, }, { url: '/og-image/migration-guide/v3', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 2, impressionsPercent: 0, prevImpressions: 0, }, { url: '/og-image/releases/v3', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: -160, prevImpressions: 9, }, { url: '/og-image/releases/v3', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: -160, prevImpressions: 9, }, { url: '/og-image/releases/v3', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: -160, prevImpressions: 9, }, { url: '/robots/api/config', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 4, impressionsPercent: 0, prevImpressions: 0, }, { url: '/robots/api/config', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 4, impressionsPercent: 0, prevImpressions: 0, }, { url: '/robots/getting-started/stackblitz', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: -18.181818181818183, prevImpressions: 6, }, { url: '/robots/integrations', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 4, impressionsPercent: 0, prevImpressions: 0, }, { url: '/robots/integrations/content', clicks: 0, prevClicks: 1, clicksPercent: 0, impressions: 118, impressionsPercent: -9.67741935483871, prevImpressions: 130, }, { url: '/robots/nitro-api/nitro-hooks', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 9, impressionsPercent: 0, prevImpressions: 0, }, { url: '/robots/releases', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: 0, prevImpressions: 0, }, { url: '/robots/releases/v3', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 3, impressionsPercent: 0, prevImpressions: 0, }, { url: '/schema-org/api/config', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 68, impressionsPercent: 21.138211382113823, prevImpressions: 55, }, { url: '/schema-org/getting-started', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 1, impressionsPercent: 0, prevImpressions: 0, }, { url: '/schema-org/getting-started/stackblitz', clicks: 0, prevClicks: 1, clicksPercent: 0, impressions: 9, impressionsPercent: 127.27272727272727, prevImpressions: 2, }, { url: '/schema-org/guides/debugging', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 11, impressionsPercent: 0, prevImpressions: 0, }, { url: '/schema-org/guides/full-documentation', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 133.33333333333331, prevImpressions: 1, }, { url: '/schema-org/guides/quick-setup', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 47, impressionsPercent: 32.098765432098766, prevImpressions: 34, }, { url: '/site-config/api/create-site-path-resolver', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 5, impressionsPercent: 0, prevImpressions: 0, }, { url: '/sitemap/getting-started/stackblitz', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 4, impressionsPercent: 120, prevImpressions: 1, }, { url: '/sitemap/guides/multi-sitemaps', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 6, impressionsPercent: 142.85714285714286, prevImpressions: 1, }, { url: '/sitemap/guides/multi-sitemaps', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 6, impressionsPercent: 142.85714285714286, prevImpressions: 1, }, { url: '/sitemap/guides/multi-sitemaps', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 6, impressionsPercent: 142.85714285714286, prevImpressions: 1, }, { url: '/sitemap/releases/v3', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 3, impressionsPercent: 100, prevImpressions: 1, }, { url: '/sitemap/releases/v3', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 3, impressionsPercent: 100, prevImpressions: 1, }, { url: '/sitemap/releases/v4', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 47, impressionsPercent: 23.809523809523807, prevImpressions: 37, }, { url: '/sitemap/releases/v4', clicks: 0, prevClicks: 0, clicksPercent: 0, impressions: 47, impressionsPercent: 23.809523809523807, prevImpressions: 37, }, ] export const NuxtSeoKeywords = [ { keyword: 'nuxt seo', position: 2.369410861638841, positionPercent: 2.9748164400652812, prevPosition: 2.2999582811848143, ctr: 0.07944732297063903, ctrPercent: 2.895430519328157, prevCtr: 0.07717980809345015, clicks: 414, impressions: 5211, }, { keyword: 'nuxtseo', position: 2.087403598971722, positionPercent: 2.2153622662066197, prevPosition: 2.041666666666667, ctr: 0.14395886889460155, ctrPercent: 15.9566158584213, prevCtr: 0.12268518518518519, clicks: 56, impressions: 389, }, { keyword: 'nuxt-simple-sitemap', position: 2.8443579766536966, positionPercent: -4.286851327435932, prevPosition: 2.968962172647915, ctr: 0.03501945525291829, ctrPercent: -87.2642225799593, prevCtr: 0.0892337536372454, clicks: 45, impressions: 1285, }, { keyword: 'nuxt simple sitemap', position: 2.896030245746692, positionPercent: -2.823958737766958, prevPosition: 2.978984238178634, ctr: 0.06427221172022685, ctrPercent: -39.91424271784616, prevCtr: 0.09632224168126094, clicks: 34, impressions: 529, }, { keyword: 'nuxt seo kit', position: 2.7309941520467835, positionPercent: -9.916090188189237, prevPosition: 3.015929203539823, ctr: 0.08771929824561403, ctrPercent: 31.699815460323965, prevCtr: 0.06371681415929203, clicks: 30, impressions: 342, }, { keyword: 'nuxt sitemap', position: 8.979700854700855, positionPercent: 2.8856875209472865, prevPosition: 8.724260355029585, ctr: 0.029914529914529916, ctrPercent: 39.15900131406045, prevCtr: 0.020118343195266272, clicks: 28, impressions: 936, }, { keyword: 'nuxt-simple-robots', position: 3.494071146245059, positionPercent: 1.986636855913256, prevPosition: 3.425339366515837, ctr: 0.10276679841897234, ctrPercent: 3.1824611032531926, prevCtr: 0.09954751131221719, clicks: 26, impressions: 253, }, { keyword: 'definesitemapeventhandler', position: 1.9903846153846154, positionPercent: -1.0014019627478599, prevPosition: 2.010416666666667, ctr: 0.20192307692307693, ctrPercent: 2.0040080160320706, prevCtr: 0.19791666666666666, clicks: 21, impressions: 104, }, { keyword: 'nuxt seo module', position: 1.6939890710382515, positionPercent: 0.16617210682493275, prevPosition: 1.6911764705882353, ctr: 0.11475409836065574, ctrPercent: 18.22349570200574, prevCtr: 0.09558823529411764, clicks: 21, impressions: 183, }, { keyword: 'nuxt-seo', position: 2.1271676300578033, positionPercent: -1.6917755871342168, prevPosition: 2.1634615384615383, ctr: 0.12138728323699421, ctrPercent: -29.541463414634155, prevCtr: 0.16346153846153846, clicks: 21, impressions: 173, }, { keyword: 'nuxt seo sitemap', position: 2.166666666666667, positionPercent: -15.647921760391178, prevPosition: 2.5344827586206895, ctr: 0.16666666666666666, ctrPercent: -21.538461538461544, prevCtr: 0.20689655172413793, clicks: 20, impressions: 120, }, { keyword: 'defineogimage', position: 1.2972972972972974, positionPercent: -46.41428704306576, prevPosition: 2.0813953488372094, ctr: 0.12162162162162163, ctrPercent: -21.658986175115196, prevCtr: 0.1511627906976744, clicks: 18, impressions: 148, }, { keyword: '@nuxtseo/module', position: 1.353448275862069, positionPercent: -23.17009093971035, prevPosition: 1.7081339712918662, ctr: 0.10344827586206896, ctrPercent: 12.903225806451607, prevCtr: 0.09090909090909091, clicks: 12, impressions: 116, }, { keyword: 'nuxt simple robots', position: 3.4324324324324325, positionPercent: 8.887606406002021, prevPosition: 3.1403508771929824, ctr: 0.16216216216216217, ctrPercent: 20.823244552058124, prevCtr: 0.13157894736842105, clicks: 12, impressions: 74, }, { keyword: 'nuxt image cache', position: 4.5, positionPercent: 40, prevPosition: 3, ctr: 0.22727272727272727, ctrPercent: -0.56980056980057, prevCtr: 0.22857142857142856, clicks: 10, impressions: 44, }, { keyword: '@nuxtjs/sitemap', position: 7.691029900332226, positionPercent: -15.370164931425029, prevPosition: 8.971563981042653, ctr: 0.026578073089700997, ctrPercent: 139.46706887883357, prevCtr: 0.004739336492890996, clicks: 8, impressions: 301, }, { keyword: 'nuxt site config', position: 5.916666666666667, positionPercent: -5.200191418089011, prevPosition: 6.232558139534884, ctr: 0.14583333333333334, ctrPercent: 4.4142614601018755, prevCtr: 0.13953488372093023, clicks: 7, impressions: 48, }, { keyword: 'nuxt 3 seo', position: 9.937007874015748, positionPercent: 46.33621386143734, prevPosition: 6.198675496688741, ctr: 0.047244094488188976, ctrPercent: -42.64003473729917, prevCtr: 0.0728476821192053, clicks: 6, impressions: 127, }, { keyword: 'nuxt-seo-kit', position: 2.7515151515151515, positionPercent: -5.361482570819672, prevPosition: 2.9031007751937983, ctr: 0.030303030303030304, ctrPercent: -56.666666666666664, prevCtr: 0.05426356589147287, clicks: 5, impressions: 165, }, { keyword: 'nuxt_public_site_url', position: 5.16, positionPercent: 8.115375213884134, prevPosition: 4.757575757575758, ctr: 0.1, ctrPercent: 49.056603773584904, prevCtr: 0.06060606060606061, clicks: 5, impressions: 50, }, { keyword: 'nuxtjs sitemap', position: 7.231343283582089, positionPercent: -23.622118196194688, prevPosition: 9.168316831683168, ctr: 0.03731343283582089, ctrPercent: 0, prevCtr: 0, clicks: 5, impressions: 134, }, { keyword: '@nuxtjs/seo', position: 6.404761904761905, positionPercent: -67.18571971135586, prevPosition: 12.884615384615385, ctr: 0.09523809523809523, ctrPercent: 84.93150684931507, prevCtr: 0.038461538461538464, clicks: 4, impressions: 42, }, { keyword: 'nuxt robots', position: 6.512195121951219, positionPercent: 8.747926350541194, prevPosition: 5.966386554621849, ctr: 0.032520325203252036, ctrPercent: 25.325443786982255, prevCtr: 0.025210084033613446, clicks: 4, impressions: 123, }, { keyword: 'nuxtjs/sitemap', position: 7.146067415730337, positionPercent: -11.2759643916914, prevPosition: 8, ctr: 0.0449438202247191, ctrPercent: 99.15014164305948, prevCtr: 0.015151515151515152, clicks: 4, impressions: 89, }, { keyword: 'seo nuxt', position: 3.633663366336634, positionPercent: 40.62553919031262, prevPosition: 2.4066985645933014, ctr: 0.039603960396039604, ctrPercent: -18.851570964247017, prevCtr: 0.04784688995215311, clicks: 4, impressions: 101, }, { keyword: 'seo nuxt 3', position: 7.666666666666667, positionPercent: 15.006150061500618, prevPosition: 6.5964912280701755, ctr: 0.12121212121212122, ctrPercent: 0, prevCtr: 0, clicks: 4, impressions: 33, }, { keyword: 'nuxt og image', position: 6.7615384615384615, positionPercent: -3.6255090403055106, prevPosition: 7.011204481792717, ctr: 0.007692307692307693, ctrPercent: -74.40633245382585, prevCtr: 0.01680672268907563, clicks: 3, impressions: 390, }, { keyword: 'nuxt prerender routes', position: 5.65625, positionPercent: 64.42658875091307, prevPosition: 2.9, ctr: 0.09375, ctrPercent: -95.95375722543352, prevCtr: 0.26666666666666666, clicks: 3, impressions: 32, }, { keyword: 'nuxt schema org', position: 4.87719298245614, positionPercent: -1.2289129706178068, prevPosition: 4.9375, ctr: 0.05263157894736842, ctrPercent: 23.255813953488374, prevCtr: 0.041666666666666664, clicks: 3, impressions: 57, }, { keyword: 'nuxt titletemplate', position: 8.147058823529413, positionPercent: -12.253634174618739, prevPosition: 9.210526315789474, ctr: 0.08823529411764706, ctrPercent: 108.10810810810811, prevCtr: 0.02631578947368421, clicks: 3, impressions: 34, }, { keyword: 'nuxt v4', position: 5.735849056603773, positionPercent: 0, prevPosition: 0, ctr: 0.05660377358490566, ctrPercent: 0, prevCtr: 0, clicks: 3, impressions: 53, }, { keyword: 'nuxt3 seo', position: 13.166666666666666, positionPercent: 43.07692307692307, prevPosition: 8.5, ctr: 0.5, ctrPercent: 0, prevCtr: 0, clicks: 3, impressions: 6, }, { keyword: 'useseometa', position: 8.297777777777778, positionPercent: -67.9955402061295, prevPosition: 16.846153846153847, ctr: 0.013333333333333334, ctrPercent: 0, prevCtr: 0, clicks: 3, impressions: 225, }, { keyword: 'nuxt hooks', position: 13.938461538461539, positionPercent: -59.12688557514767, prevPosition: 25.63888888888889, ctr: 0.015384615384615385, ctrPercent: 0, prevCtr: 0, clicks: 2, impressions: 130, }, { keyword: 'nuxt robots.txt', position: 10.084615384615384, positionPercent: -20.275550071972035, prevPosition: 12.36, ctr: 0.015384615384615385, ctrPercent: 14.285714285714285, prevCtr: 0.013333333333333334, clicks: 2, impressions: 130, }, { keyword: 'nuxt routerules', position: 10.96938775510204, positionPercent: -0.5565862708719879, prevPosition: 11.03061224489796, ctr: 0.02040816326530612, ctrPercent: 66.66666666666666, prevCtr: 0.01020408163265306, clicks: 2, impressions: 98, }, { keyword: 'nuxt-site-config', position: 5.477611940298507, positionPercent: 5.474795544980093, prevPosition: 5.185714285714286, ctr: 0.029850746268656716, ctrPercent: -17.88617886178861, prevCtr: 0.03571428571428571, clicks: 2, impressions: 67, }, { keyword: 'nuxtseo/module', position: 1, positionPercent: 0, prevPosition: 0, ctr: 0.16666666666666666, ctrPercent: 0, prevCtr: 0, clicks: 2, impressions: 12, }, { keyword: 'sitemap nuxt', position: 7.5058823529411764, positionPercent: -22.541087649974063, prevPosition: 9.412698412698413, ctr: 0.023529411764705882, ctrPercent: 0, prevCtr: 0, clicks: 2, impressions: 85, }, { keyword: 'defineogimagecomponent', position: 1.4, positionPercent: 0, prevPosition: 0, ctr: 0.2, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 5, }, { keyword: 'google fonts nuxt', position: 26, positionPercent: -6.511627906976744, prevPosition: 27.75, ctr: 0.3333333333333333, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 3, }, { keyword: 'i18n nuxt', position: 24.033898305084747, positionPercent: -17.237805780769662, prevPosition: 28.56756756756757, ctr: 0.00847457627118644, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 118, }, { keyword: 'nitro prerender', position: 10.108695652173912, positionPercent: 20.2039121446115, prevPosition: 8.253731343283583, ctr: 0.010869565217391304, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 92, }, { keyword: 'nitro route rules', position: 10.484848484848484, positionPercent: 4.022322838791196, prevPosition: 10.071428571428571, ctr: 0.030303030303030304, ctrPercent: -16.39344262295081, prevCtr: 0.03571428571428571, clicks: 1, impressions: 33, }, { keyword: 'nuxt canonical', position: 12.181818181818182, positionPercent: -14.675374567806374, prevPosition: 14.11111111111111, ctr: 0.030303030303030304, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 33, }, { keyword: 'nuxt canonical url', position: 8.26, positionPercent: -23.544670786602573, prevPosition: 10.464285714285714, ctr: 0.02, ctrPercent: -56.4102564102564, prevCtr: 0.03571428571428571, clicks: 1, impressions: 50, }, { keyword: 'nuxt config hooks', position: 9.947368421052632, positionPercent: 12.807881773399018, prevPosition: 8.75, ctr: 0.05263157894736842, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 19, }, { keyword: 'nuxt config routerules', position: 11.9, positionPercent: 32.76283618581906, prevPosition: 8.55, ctr: 0.05, ctrPercent: 0, prevCtr: 0.05, clicks: 1, impressions: 20, }, { keyword: 'nuxt content i18n', position: 24.627906976744185, positionPercent: -8.753386094438842, prevPosition: 26.88235294117647, ctr: 0.011627906976744186, ctrPercent: -23.376623376623375, prevCtr: 0.014705882352941176, clicks: 1, impressions: 86, }, { keyword: 'nuxt js seo', position: 12.421052631578947, positionPercent: 60.429877295561226, prevPosition: 6.656716417910448, ctr: 0.02631578947368421, ctrPercent: -95.71984435797664, prevCtr: 0.07462686567164178, clicks: 1, impressions: 38, }, { keyword: 'nuxt js sitemap', position: 8, positionPercent: -6.0606060606060606, prevPosition: 8.5, ctr: 0.02040816326530612, ctrPercent: -84.05797101449278, prevCtr: 0.05, clicks: 1, impressions: 49, }, { keyword: 'nuxt publicruntimeconfig', position: 20.125, positionPercent: 15.673141326188883, prevPosition: 17.2, ctr: 0.125, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 8, }, { keyword: 'nuxt schema', position: 8.765625, positionPercent: 6.935233258801679, prevPosition: 8.178082191780822, ctr: 0.015625, ctrPercent: 13.138686131386867, prevCtr: 0.0136986301369863, clicks: 1, impressions: 64, }, { keyword: 'nuxt title template', position: 7.409090909090909, positionPercent: 4.69849518241854, prevPosition: 7.068965517241379, ctr: 0.045454545454545456, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 22, }, { keyword: 'nuxt-og-image', position: 6.4021739130434785, positionPercent: -9.060324413221634, prevPosition: 7.009756097560976, ctr: 0.0036231884057971015, ctrPercent: -120.61955469506292, prevCtr: 0.014634146341463415, clicks: 1, impressions: 276, }, { keyword: 'nuxt-schema-org', position: 5.565656565656566, positionPercent: 15.088139738854355, prevPosition: 4.784810126582279, ctr: 0.010101010101010102, ctrPercent: -115.95744680851064, prevCtr: 0.0379746835443038, clicks: 1, impressions: 99, }, { keyword: 'nuxt.config.ts', position: 40, positionPercent: 19.17808219178082, prevPosition: 33, ctr: 0.5, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 2, }, { keyword: 'nuxt_site_env', position: 1.8333333333333335, positionPercent: 0, prevPosition: 0, ctr: 0.16666666666666666, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 6, }, { keyword: 'nuxtjs seo', position: 8.971428571428572, positionPercent: -10.408873137756784, prevPosition: 9.956521739130435, ctr: 0.02857142857142857, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 35, }, { keyword: 'nuxtjs/seo', position: 8.333333333333332, positionPercent: 0, prevPosition: 0, ctr: 0.3333333333333333, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 3, }, { keyword: 'opengraph image url', position: 17, positionPercent: 0, prevPosition: 0, ctr: 1, ctrPercent: 0, prevCtr: 0, clicks: 1, impressions: 1, }, { keyword: '"seo"', position: 92, positionPercent: 0, prevPosition: 0, ctr: 0, ctrPercent: 0, prevCtr: 0, clicks: 0, impressions: 1, }, { keyword: '', position: 87, positionPercent: 0, prevPosition: 0, ctr: 0, ctrPercent: 0, prevCtr: 0, clicks: 0, impressions: 3, }, { keyword: '', position: 16, positionPercent: 0, prevPosition: 0, ctr: 0, ctrPercent: 0, prevCtr: 0, clicks: 0, impressions: 1, }, ] ================================================ FILE: error.vue ================================================ ================================================ FILE: eslint.config.js ================================================ import antfu from '@antfu/eslint-config' export default antfu({ rules: { 'unicorn/prefer-node-protocol': 'off', 'node/prefer-global': 'off', 'node/prefer-global/buffer': 'off', }, }) ================================================ FILE: layouts/account.vue ================================================ ================================================ FILE: layouts/auth.vue ================================================ ================================================ FILE: layouts/default.vue ================================================ ================================================ FILE: middleware/auth.global.ts ================================================ const AuthenticatedRoutePrefixes = ['/dashboard', '/account'] const AdminRoutePrefixes = ['/admin'] export default defineNuxtRouteMiddleware((to) => { const { loggedIn, user } = useUserSession() if (AuthenticatedRoutePrefixes.some(p => to.path.startsWith(p))) { if (!loggedIn.value) return navigateTo('/get-started') } if (AdminRoutePrefixes.some(p => to.path.startsWith(p))) { // TODO refactor this out to use env if (!loggedIn.value || user.value?.email !== 'harlan@harlanzw.com') return navigateTo('/dashboard') } }) ================================================ FILE: nuxt.config.ts ================================================ import { env } from 'std-env' import { hash } from 'ohash' import type { OAuthPoolToken } from '~/types' let tokens: Partial[] = env.NUXT_OAUTH_POOL ? JSON.parse(env.NUXT_OAUTH_POOL) : [] const privateTokens: Partial[] = env.NUXT_OAUTH_PRIVATE_POOL ? JSON.parse(env.NUXT_OAUTH_PRIVATE_POOL) : [] export default defineNuxtConfig({ extends: ['@nuxt/ui-pro'], modules: [ 'nuxt-auth-utils', 'dayjs-nuxt', '@nuxt/image', '@nuxt/ui', '@nuxtjs/fontaine', '@nuxtjs/google-fonts', '@vueuse/nuxt', '@nuxtjs/seo', (_, nuxt) => { // seed the main tokens if there isn't a pool available if (tokens.length === 0) { tokens = [{ label: 'primary', client_id: env.NUXT_OAUTH_GOOGLE_CLIENT_ID!, client_secret: env.NUXT_OAUTH_GOOGLE_CLIENT_SECRET!, }] } nuxt.options.nitro!.virtual = nuxt.options.nitro!.virtual || {} nuxt.options.nitro.virtual['#app/token-pool.mjs'] = [ `export const tokens = ${JSON.stringify(tokens.map((t) => { t.id = t.id || hash(t) return t }))}`, `export const privateTokens = ${JSON.stringify(privateTokens.map((t) => { t.id = t.id || hash(t) return t }))}`, ].join('\n') }, ], runtimeConfig: { key: '', // .env NUXT_KEY session: { cookie: { maxAge: 60 * 60 * 24 * 90, // 3mo }, }, postmark: { apiKey: '', // .env NUXT_POSTMARK_API_KEY }, public: { features: { keyLogin: false, crawler: false, }, indexing: { usageLimitPerUser: 15, }, }, indexing: { maxUsersPerOAuth: 15, // we over provision slightly (25 over), }, }, nitro: { vercel: { functions: { maxDuration: 90, // second timeout for API calls }, }, devStorage: { app: { base: '.db', driver: 'fs', }, }, storage: { app: { driver: 'vercelKV', }, }, }, ui: { icons: ['heroicons', 'simple-icons', 'ph'], }, app: { pageTransition: { name: 'page', mode: 'out-in', }, seoMeta: { themeColor: [ { content: '#18181b', media: '(prefers-color-scheme: dark)' }, { content: 'white', media: '(prefers-color-scheme: light)' }, ], }, head: { templateParams: { separator: '·', }, script: [ { 'src': 'https://cdn.usefathom.com/script.js', 'data-spa': 'auto', 'data-site': 'UHBNWPCP', 'defer': true, }, ], }, }, site: { name: 'Request Indexing', url: 'requestindexing.com', }, // Fonts fontMetrics: { fonts: ['DM Sans'], }, googleFonts: { display: 'swap', download: true, families: { 'DM+Sans': [300, 400, 500, 600, 700], }, }, dayjs: { locales: ['en'], plugins: ['relativeTime', 'utc'], defaultLocale: 'en', }, devtools: { enabled: true }, }) ================================================ FILE: package.json ================================================ { "name": "requestindexing.com", "type": "module", "version": "0.0.1", "private": true, "packageManager": "pnpm@8.15.2", "scripts": { "build": "nuxt build", "dev": "nuxt dev", "lint": "eslint . --fix", "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare", "release": "bumpp" }, "engines": { "node": "^20" }, "dependencies": { "@googleapis/indexing": "^2.0.0", "@googleapis/searchconsole": "^1.0.3", "@iconify-json/heroicons": "^1.1.19", "@iconify-json/ph": "^1.1.11", "@iconify-json/simple-icons": "^1.1.90", "@nuxt/content": "^2.12.0", "@nuxt/image": "^1.3.0", "@nuxt/ui-pro": "^0.7.5", "@nuxtjs/fontaine": "^0.4.1", "@nuxtjs/google-fonts": "^3.1.3", "@nuxtjs/seo": "^2.0.0-rc.8", "@vercel/kv": "^1.0.1", "@vueuse/nuxt": "^10.7.2", "dayjs-nuxt": "^2.1.9", "fuse.js": "^7.0.0", "lightweight-charts": "^4.1.3", "nuxt": "^3.10.1", "nuxt-auth-utils": "^0.0.15", "postmark": "^4.0.2", "sitemapper": "^3.2.8", "vue": "^3.4.18", "vue-router": "^4.2.5" }, "devDependencies": { "@antfu/eslint-config": "^2.6.4", "@nuxt/test-utils": "^3.11.0", "bumpp": "^9.3.0", "eslint": "^8.56.0", "typescript": "^5.3.3", "vitest": "^1.2.2" } } ================================================ FILE: pages/account/index.vue ================================================ ================================================ FILE: pages/account/upgrade.vue ================================================ ================================================ FILE: pages/admin/index.vue ================================================ ================================================ FILE: pages/dashboard/index.vue ================================================ ================================================ FILE: pages/dashboard/site/[slug].vue ================================================ ================================================ FILE: pages/get-started.vue ================================================ ================================================ FILE: pages/index.vue ================================================ ================================================ FILE: pages/privacy.vue ================================================ ================================================ FILE: pages/terms.vue ================================================ ================================================ FILE: robots.txt ================================================ User-agent: * Disallow: /account Disallow: /admin Disallow: /dashboard ================================================ FILE: server/api/admin/usage.get.ts ================================================ import { getMetric } from '~/server/utils/storage' export default defineEventHandler(async () => { const pool = createOAuthPool() return { signups: await getMetric('signups'), webIndexingApi: await pool.usage(), } }) ================================================ FILE: server/api/github/repo.get.ts ================================================ import { getQuery } from 'h3' import { $fetch } from 'ofetch' export interface GitHubRepo { id: number name: string repo: string description: string createdAt: string updatedAt: string pushedAt: string stars: number watchers: number forks: number } const cachedGitHubRepo = cachedFunction( repo => $fetch(`https://ungh.cc/repos/${repo}`).then(r => r.repo as GitHubRepo), { base: 'app', maxAge: 60 * 60, group: 'app/cache', name: 'github-repo', getKey: (repo: string) => repo, }, ) export default defineEventHandler(async (event) => { const repoWithOwner = getQuery(event).repo as string if (!repoWithOwner) return sendError(event, new Error('Missing repo name.')) return cachedGitHubRepo(repoWithOwner) }) ================================================ FILE: server/api/indexing/[url].post.ts ================================================ import { indexing } from '@googleapis/indexing' import type { GaxiosError } from 'googleapis-common' import { OAuth2Client } from 'googleapis-common' import type { indexing_v3 } from '@googleapis/indexing/v3' import { getUserToken, updateUserSite } from '~/server/utils/storage' import { getUserQuotaUsage, incrementUserQuota } from '~/server/utils/quota' import type { SitePage, UserSession } from '~/types' export default defineEventHandler(async (event) => { const { user } = event.context.authenticatedData const { url } = getRouterParams(event, { decode: true }) const { siteUrl: _siteUrl } = getQuery(event) const siteUrl = decodeURIComponent(_siteUrl as string) const tokens = await getUserToken(user.userId, 'indexing') const { indexing: indexingConfig } = useRuntimeConfig().public const quotaLimit = user.access === 'pro' ? 200 : indexingConfig.usageLimitPerUser // increment users usage const quota = await getUserQuotaUsage(user.userId, 'indexingApi') if (quota >= quotaLimit) { return sendError(event, createError({ statusCode: 429, statusText: 'Daily API Quota exceeded.', })) } const pool = createOAuthPool().get(user.indexingOAuthIdNext || '') if (!user.indexingOAuthIdNext || !pool) { return sendError(event, createError({ statusCode: 401, statusText: 'Invalid Google account. Please reconnect your account.', })) } const oauth2Client = new OAuth2Client({ clientId: pool.client_id, clientSecret: pool.client_secret, }) oauth2Client.setCredentials(tokens!) const api = indexing({ version: 'v3', auth: oauth2Client, }) const metadata = await api.urlNotifications.getMetadata({ url: decodeURIComponent(url), }) .then(res => res.data) .catch((e: GaxiosError) => { if (e.status === 404) return { latestUpdate: { type: 'URL_MISSING' } } as indexing_v3.Schema$UrlNotificationMetadata else return { latestUpdate: { type: 'URL_ERROR' } } as indexing_v3.Schema$UrlNotificationMetadata }) // notify time looks like "2024-02-05T05:11:09.069175248Z" // check if the notify time was within the last 48 hours, if so we can skip const submittedLast48Hours = metadata.latestUpdate?.notifyTime ? new Date(metadata.latestUpdate.notifyTime) > new Date(Date.now() - 1000 * 60 * 60 * 48) : false if (metadata.latestUpdate?.type === 'URL_UPDATED' && submittedLast48Hours) { const page: SitePage = { urlNotificationMetadata: metadata, url } // already published, update link and return it await updateUserSite(user.userId, siteUrl, { urls: [page] }) return { status: 'already-submitted', url: page, } } const res = await api.urlNotifications.publish({ requestBody: { type: 'URL_UPDATED', url: decodeURIComponent(url), }, }) .then(res => res.data) await setUserSession(event, { // public data only! user: { quota: { indexingApi: await incrementUserQuota(user.userId, 'indexingApi'), }, }, }) const page: SitePage = { ...res, url } await updateUserSite(user.userId, siteUrl, { urls: [page] }) return { status: 'submitted', url: page, } }) ================================================ FILE: server/api/indexing/auth.delete.ts ================================================ import { OAuth2Client } from 'googleapis-common' import { setUserSession } from '#imports' import { deleteUserToken, getUserToken } from '~/server/utils/storage' export default defineEventHandler(async (event) => { const { user } = event.context.authenticatedData if (!user.indexingOAuthIdNext!) { return createError({ statusCode: 400, message: 'No indexing OAuth found.', }) } // need to claim back the token from the pool const pool = createOAuthPool() const oAuth = pool.get(user.indexingOAuthIdNext) if (oAuth) await pool.release(oAuth.id, user.userId) // keep a reference of the last indexingOAuthId await setUserSession(event, { user: { indexingOAuthIdNext: '', lastIndexingOAuthIdNext: user.indexingOAuthIdNext, }, }) // delete tokens const tokens = await getUserToken(user.userId, 'indexing') await deleteUserToken(user.userId, 'indexing') if (!tokens) { // already deleted return { status: 'ok' } } // revoke the token with google const oauth2Client = new OAuth2Client() oauth2Client.setCredentials(tokens!) return oauth2Client.revokeToken(tokens!.refresh_token || tokens.access_token!) .then(res => res.data) }) ================================================ FILE: server/api/sites/[siteUrl]/[url].get.ts ================================================ import { searchconsole } from '@googleapis/searchconsole' import type { GaxiosError } from 'googleapis-common' import { parseURL } from 'ufo' import { createGoogleOAuthClient } from '~/server/utils/api/googleSearchConsole' import { getUserSite, updateUserSite } from '~/server/utils/storage' import type { SitePage } from '~/types' export default defineEventHandler(async (event) => { const { tokens, user } = event.context.authenticatedData const { siteUrl, url } = getRouterParams(event, { decode: true }) const { urls } = await getUserSite(user.userId, siteUrl) // find for url const lastInspected = urls.filter(Boolean).find(u => parseURL(u.url).pathname === url)?.lastInspected if (lastInspected) { // compare with current time, if it's within 1 hour then we block them if (Date.now() - lastInspected < 1000 * 60 * 60) { return sendError(event, createError({ statusCode: 429, statusText: `You can only check the URL index status once per hour. Try again in ${Math.round((1000 * 60 * 60 - (Date.now() - lastInspected)) / 1000 / 60)} minutes.`, })) } } const gscApi = searchconsole({ version: 'v1', auth: createGoogleOAuthClient(tokens), }) return gscApi.urlInspection.index.inspect({ requestBody: { inspectionUrl: url, siteUrl, }, }) .then(async (res) => { const page: SitePage = { ...res.data, url, lastInspected: Date.now() } await updateUserSite(user.userId, siteUrl, { urls: [page] }) // only save lastInspected if it was successful return page }) .catch((e: GaxiosError) => { // if we have a 400 error, it means we've been unauthorized, send proper error if (e.response?.status === 400) { throw createError({ statusCode: 401, statusText: 'Unauthorized', }) } else if (e.response?.status === 500) { throw createError({ statusCode: 500, statusText: 'Google Server is rate limiting us. Please try again in a minute.', }) } throw e }) }) ================================================ FILE: server/api/sites/[siteUrl]/crawl.post.ts ================================================ export default defineEventHandler(async () => { // TODO re-implement // const { tokens, user } = event.context.authenticatedData // // const { siteUrl } = getRouterParams(event, { decode: true }) // const googleSearchConsoleAnalytics = await fetchGoogleSearchConsoleAnalytics( // tokens, // user.analyticsPeriod, // siteUrl, // ) // // const { indexedUrls } = googleSearchConsoleAnalytics // // const indexedPaths = indexedUrls.map(url => withoutTrailingSlash(parseURL(url).pathname)) // // const siteCacheKey = `user:${user.userId}:sites:${normalizeSiteUrlForKey(siteUrl)}` // const robots = await fetchRobots({ // cacheKey: siteCacheKey, // siteUrl, // }) // // const nonIndexedUrls = new Set() // // const { crawl } = await getUserSite(user.userId, siteUrl) // let crawledUrls: string[] = [] // // cache // crawledUrls = (await crawlSite({ // robots, // siteUrl: normalizeSiteUrl(siteUrl), // })) // await crawlStorage.setItem('', { updatedAt: Date.now(), urls: crawledUrls }) // crawledUrls // .filter(url => !indexedPaths.includes(url)) // .forEach(url => nonIndexedUrls.add(url)) // interface CrawlCache { updatedAt: number, urls: string[] } // const crawlStorage = userSiteAppStorage(user.userId, siteUrl, 'crawledUrls') // let crawledUrls: string[] = [] // if (crawl) { // // cache // crawledUrls = (await crawlSite({ // robots, // siteUrl: normalizedSiteUrl(siteUrl), // })) // await crawlStorage.setItem('', { updatedAt: Date.now(), urls: crawledUrls }) // } // else { // const hasCrawlCached = await crawlStorage.hasItem(``) // if (hasCrawlCached) { // const cachedCrawl = await crawlStorage.getItem(``) // crawledUrls = cachedCrawl?.urls || [] // res.lastCrawl = cachedCrawl?.updatedAt // } // else if (!sitemapsUrls.length && !crawl) { // res.needsCrawl = true // } // } // crawledUrls // .filter(url => !indexedPaths.includes(url)) // .forEach(url => nonIndexedUrls.add(url)) return [] }) ================================================ FILE: server/api/sites/[siteUrl].get.ts ================================================ import { parseURL, withoutTrailingSlash } from 'ufo' import { getUserSite, normalizeSiteUrlForKey } from '~/server/utils/storage' import { fetchGoogleSearchConsoleAnalytics } from '~/server/utils/api/googleSearchConsole' import type { NitroAuthData, SiteAnalytics, SiteExpanded } from '~/types' import { fetchRobots, fetchSitemapUrls } from '~/server/utils/crawler/crawl' const fetchSite = cachedFunction( async ({ tokens, user }: NitroAuthData, siteUrl: string) => { const periodRange = user.analyticsPeriod || { days: 28 } return await fetchGoogleSearchConsoleAnalytics(tokens, periodRange, siteUrl, user.access === 'pro' ? 100001 : 2500) }, { maxAge: 60 * 60 * 6, // cache for 6 hours group: 'app', name: 'user', shouldInvalidateCache(_, force?: boolean) { return !!force || import.meta.dev }, getKey({ user }: NitroAuthData, siteUrl: string) { return `${user.userId}:sites:${normalizeSiteUrlForKey(siteUrl)}:analytics:${user.analyticsPeriod}` }, }, ) export default defineEventHandler(async (event) => { const { user } = event.context.authenticatedData const { siteUrl } = getRouterParams(event, { decode: true }) // TODO throttle force? const force = String(getQuery(event).force) === 'true' const sites = await fetchSitesCached(event.context.authenticatedData, force) const site = sites.find(s => s.siteUrl === siteUrl) if (!site) { return sendError(event, createError({ statusCode: 404, message: 'Site not found', })) } const googleSearchConsoleAnalytics = await fetchSite(event.context.authenticatedData, siteUrl, force) // compute the non-indexed urls const { indexedUrls, period, sitemaps } = googleSearchConsoleAnalytics if (user.access !== 'pro' && period.length >= 2500) { return { ...site, ...googleSearchConsoleAnalytics, nonIndexedPercent: -1, nonIndexedUrls: [], } satisfies SiteExpanded } const indexedPaths = indexedUrls.map(url => withoutTrailingSlash(parseURL(url).pathname)) const siteCacheKey = `user:${user.userId}:sites:${normalizeSiteUrlForKey(siteUrl)}` const robots = await fetchRobots({ cacheKey: siteCacheKey, siteUrl, }) const sitemapPaths = sitemaps!.map(s => s.path).filter(Boolean) as string[] const nonIndexedUrls = new Set() // easy case const sitemapsUrls = sitemapPaths.length ? await fetchSitemapUrls({ cacheKey: siteCacheKey, robots, siteUrl, sitemapPaths, }) : [] sitemapsUrls .map(url => withoutTrailingSlash(parseURL(url).pathname)) .filter(url => !indexedPaths.includes(url)) // .filter(u => u !== '/') // some trailing slash issues, should fix properly .forEach(url => nonIndexedUrls.add(url)) const { urls } = await getUserSite(user.userId, siteUrl) return { ...site, ...googleSearchConsoleAnalytics, nonIndexedPercent: indexedUrls.length / (indexedUrls.length + [...nonIndexedUrls].length), nonIndexedUrls: [...nonIndexedUrls].map((url) => { const entry = urls.filter(Boolean).find(u => parseURL(u.url).pathname === url) return { ...entry, url, } }), } satisfies SiteExpanded }) ================================================ FILE: server/api/sites/list.get.ts ================================================ export default defineEventHandler((event) => { return fetchSitesCached(event.context.authenticatedData, String(getQuery(event).force) === 'true') }) ================================================ FILE: server/api/user/me.delete.ts ================================================ import { OAuth2Client } from 'googleapis-common' import { clearUserStorage } from '~/server/utils/storage' export default defineEventHandler(async (event) => { const { user, tokens } = event.context.authenticatedData if (user.indexingOAuthIdNext) { // need to claim back the token from the pool const pool = createOAuthPool() const oAuth = pool.get(user.indexingOAuthIdNext!) if (oAuth) await pool.release(oAuth.id, user.userId) } await incrementMetric('deletedUsers') await clearUserStorage(user.userId) // // clear user session await clearUserSession(event) // revoke the token with google const oauth2Client = new OAuth2Client() oauth2Client.setCredentials(tokens!) return oauth2Client.revokeToken(tokens!.refresh_token || tokens.access_token!) .then(res => res.data) }) ================================================ FILE: server/api/user/me.post.ts ================================================ import type { User } from '~/types' import { updateUser, userMerger } from '~/server/utils/storage' import { mergeUserSessionData } from '~/server/utils/session' export default defineEventHandler(async (event) => { const { user: _user } = event.context.authenticatedData const { analyticsPeriod, hiddenSites } = await readBody>(event) await incrementMetric('updatedDetails') const user = await updateUser(_user.userId, { analyticsPeriod, hiddenSites }) return await mergeUserSessionData(event, { user }, userMerger) }) ================================================ FILE: server/composables/auth.ts ================================================ import type { H3Error, H3Event } from 'h3' import { hash } from 'ohash' import type { TokenPayload, UserSession } from '~/types' import { getUserToken } from '~/server/utils/storage' export function getHashSecure(input: any) { const appKey = useRuntimeConfig().key // make an object return hash({ input, appKey }) } export async function getAuthenticatedData(event: H3Event): Promise { const session = (await getUserSession(event)) as UserSession if (!session?.user) { // unauthorized return createError({ statusCode: 401, message: 'Unauthorized', }) } const token = await getUserToken(session.user.userId, 'login') if (!token) { // unauthorized return createError({ statusCode: 401, message: 'Unauthorized', }) } return { sub: token.sub, tokens: token.tokens, user: session.user, session, } } ================================================ FILE: server/email/welcome.ts ================================================ import type { H3Event } from 'h3' import { ServerClient } from 'postmark' export const WelcomeEmail = `Thanks for trying out Request Indexing. Here's the deal: You're one of the first to try it out, and I'm excited to have you on board. However, it's early days, and I'm still actively working on the project to make it the best it can be. I'd love to hear your thoughts on how I can make Request Indexing better for you. If you need any help, have any feedback or feature requests hit reply and let me know. P.s. You can find the source code for Request Indexing on GitHub: https://github.com/harlan-zw/request-indexing. Feel free to open an issue or a PR. Cheers Harlan` export function sendWelcomeEmail(event: H3Event, email: string) { const client = new ServerClient(useRuntimeConfig(event).postmark.apiKey) return client.sendEmail({ From: 'harlan@harlanzw.com', Bcc: 'harlan@harlanzw.com', To: email, Subject: 'Welcome to Request Indexing', TextBody: WelcomeEmail, }) } ================================================ FILE: server/middleware/auth.ts ================================================ import { isError } from 'h3' import { getAuthenticatedData } from '~/server/composables/auth' // TODO move to route rules // authenticated by default const NonAuthenticatedPaths = [ '/api/_auth/session', '/api/github', ] const AdminPaths = [ '/api/admin', ] export default defineEventHandler(async (event) => { if (NonAuthenticatedPaths.some(p => event.path.startsWith(p))) return // only need to auth the context once if (!event.path.startsWith('/api') || event.context.authenticatedData) return const data = await getAuthenticatedData(event) if (isError(data)) return sendError(event, data) if (!data.user?.userId) { throw createError({ statusCode: 401, message: 'Invalid user data.', }) } if (AdminPaths.some(p => event.path.startsWith(p)) && data.user.email !== 'harlan@harlanzw.com') { return sendError(event, createError({ statusCode: 401, message: 'Unauthorized', })) } event.context.authenticatedData = data }) ================================================ FILE: server/routes/auth/google-indexing.get.ts ================================================ import { createError, defineEventHandler, getQuery, getRequestURL, sendRedirect, } from 'h3' import { parsePath, withQuery } from 'ufo' import { ofetch } from 'ofetch' import { hash } from 'ohash' import { createOAuthPool } from '~/server/utils/oauthPool' import { updateUser, updateUserToken } from '~/server/utils/storage' import { setUserSession } from '#imports' import { getAuthenticatedData } from '~/server/composables/auth' import type { OAuthPoolToken } from '~/types' // this is a copy of the googleEventHandler from nuxt-auth-utils // we need to provide runtime config for the client id and client secret export default defineEventHandler(async (event) => { const authData = await getAuthenticatedData(event) if (isError(authData)) return sendError(event, authData) const { sub, user, session } = authData const pool = createOAuthPool() let tokenId = session.googleIndexingAuth?.indexingOAuthIdNext || user.indexingOAuthIdNext || user.lastIndexingOAuthIdNext let token: OAuthPoolToken | undefined if (!tokenId) { // generate one token = await pool.free() tokenId = token?.id } else { token = pool.get(tokenId) if (!token) { // claim a free one (pro user fallback) token = await pool.free() tokenId = token?.id } } if (!token || !tokenId) { // sent rate limted, too many users return sendError(event, createError({ statusCode: 429, statusMessage: 'Oops, looks like we have too many users right now. Please try again later.', })) } const config = { clientId: token!.client_id, clientSecret: token!.client_secret, authorizationURL: 'https://accounts.google.com/o/oauth2/v2/auth', tokenURL: 'https://oauth2.googleapis.com/token', scope: [ 'https://www.googleapis.com/auth/indexing', ], } const query = getQuery(event) const { code, state } = query const redirectUrl = getRequestURL(event).href if (!code) { // get the referrer const referrer = getHeader(event, 'referer') const data = { googleIndexingAuth: { indexingOAuthIdNext: tokenId, referrer, state: hash(new Date()) }, } await setUserSession(event, data) config.scope = config.scope || ['email', 'profile'] return sendRedirect( event, withQuery(config.authorizationURL, { response_type: 'code', client_id: config.clientId, redirect_uri: redirectUrl, scope: config.scope.join(' '), state: data.googleIndexingAuth.state, login_hint: sub, access_type: 'offline', prompt: 'consent', }), ) } const authPayload = session.googleIndexingAuth! || {} // cross-site request forgery protection if (authPayload.state !== state) { return sendError(event, createError({ statusCode: 401, message: 'Invalid state', })) } const body = { grant_type: 'authorization_code', redirect_uri: parsePath(redirectUrl).pathname, client_id: config.clientId, client_secret: config.clientSecret, code, } const tokens = await ofetch(config.tokenURL, { method: 'POST', body, }).catch((error) => { return { error } }) if (tokens.error) { return sendError(event, createError({ statusCode: 401, message: `Google login failed: ${tokens.error?.data?.error_description || 'Unknown error'}`, data: tokens, })) } // user has claimed spot in pool await pool.claim(tokenId, user.userId) // save the accessToken to the user (server only) // delete tokens await updateUserToken(user.userId, 'indexing', tokens) await updateUser(user.userId, { indexingOAuthIdNext: tokenId }) await setUserSession(event, { user: { indexingOAuthIdNext: tokenId } }) await incrementMetric('googleIndexingAuth') return sendRedirect(event, authPayload.referrer || '/dashboard') }) ================================================ FILE: server/routes/auth/google.get.ts ================================================ import { defu } from 'defu' import { getUser, getUserToken, incrementMetric, updateUserToken } from '~/server/utils/storage' import { googleAuthEventHandler } from '~/server/utils/auth/googleAuthEventHandler' import { getHashSecure } from '~/server/composables/auth' import type { UserSession } from '~/types' import { getUserQuota } from '~/server/utils/quota' import { sendWelcomeEmail } from '~/server/email/welcome' export default googleAuthEventHandler({ config: { redirectUrl: '/auth/google', scope: [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/webmasters.readonly', ], authorizationParams: { prompt: 'consent', access_type: 'offline', }, }, async onSuccess(event, { user: _user, tokens }) { const user = _user as { sub: string, picture: string, email: string } // sub is an openid claim that is unique to the user, we don't want to expose this to the client const userId = `user-${getHashSecure(user.sub)}` if (!(await getUser(userId))) { // TODO handle sign up login (emails, etc) await incrementMetric('signups') await sendWelcomeEmail(event, user.email) } const quota = await getUserQuota(userId) await updateUser(userId, { email: user.email, }) const userPublicPersistentData = await getUser(userId) await setUserSession(event, { // public data only! user: { email: user.email, quota, userId, picture: user.picture, analyticsPeriod: '28d', ...userPublicPersistentData, // contains analyticsPeriod if changed } satisfies UserSession['user'], }) const { tokens: currentTokens } = await getUserToken(userId, 'login') || {} await updateUserToken(userId, 'login', { updatedAt: Date.now(), sub: user.sub, tokens: defu(tokens, currentTokens), // avoid refresh token getting deleted }) return sendRedirect(event, '/dashboard') }, // Optional, will return a json error and 401 status code by default onError(event, error) { console.error('Google OAuth error:', error) return sendRedirect(event, '/') }, }) ================================================ FILE: server/tsconfig.json ================================================ { "extends": "../.nuxt/tsconfig.server.json" } ================================================ FILE: server/utils/api/googleSearchConsole.ts ================================================ import { parseURL } from 'ufo' import type { Credentials } from 'google-auth-library' import { OAuth2Client } from 'googleapis-common' import type { searchconsole_v1 } from '@googleapis/searchconsole' import { searchconsole } from '@googleapis/searchconsole' import type { GoogleSearchConsoleSite, SiteAnalytics, User } from '~/types' import { normalizeSiteUrl, percentDifference } from '~/server/utils/formatting' // @ts-expect-error untyped import { tokens } from '#app/token-pool.mjs' function formatDate(date: Date = new Date()) { return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}` } export function createGoogleOAuthClient(credentials: Credentials, token?: { client_id: string, client_secret: string }) { token = token || tokens[0] return new OAuth2Client({ // tells client to use the refresh_token... forceRefreshOnFailure: true, credentials, clientId: token.client_id, clientSecret: token.client_secret, }) } export async function fetchGoogleSearchConsoleSites(credentials: Credentials): Promise { const api = searchconsole({ version: 'v1', auth: createGoogleOAuthClient(credentials), }) return api.sites.list().then(res => res.data.siteEntry! as GoogleSearchConsoleSite[]) } async function recursiveQuery(api: searchconsole_v1.Searchconsole, query: searchconsole_v1.Params$Resource$Searchanalytics$Query, maxRows: number, page: number = 1, rows: searchconsole_v1.Schema$ApiDataRow[] = []) { const rowLimit = query.requestBody?.rowLimit || maxRows const res = await api.searchanalytics.query({ ...query, requestBody: { ...query.requestBody, startRow: (page - 1) * rowLimit, }, }) // add res rows rows.push(...res.data.rows!) if (res.data.rows!.length === rowLimit && res.data.rows!.length < maxRows && page <= 4) await recursiveQuery(api, query, maxRows, page + 1, rows) return { data: { rows } } } export async function fetchGoogleSearchConsoleAnalytics(credentials: Credentials, periodRange: User['analyticsPeriod'], siteUrl: string, maxRows = 1000): Promise { const api = searchconsole({ version: 'v1', auth: createGoogleOAuthClient(credentials), }) const periodDays = periodRange.includes('d') ? Number.parseInt(periodRange.replace('d', '')) : (Number.parseInt(periodRange.replace('mo', '')) * 30) const startPeriod = new Date() startPeriod.setDate(new Date().getDate() - periodDays) const startPrevPeriod = new Date() startPrevPeriod.setDate(new Date().getDate() - periodDays * 2) const endPrevPeriod = new Date() endPrevPeriod.setDate(new Date().getDate() - periodDays - 1) const requestBody = { dimensions: ['page'], type: 'web', aggregationType: 'byPage', } const rowLimit = maxRows > 25000 ? 25000 : maxRows const [keywordsPeriod, keywordsPrevPeriod, period, prevPeriod, graph] = (await Promise.all([ // do a query based on keywords instead of dates // period await recursiveQuery(api, { siteUrl, requestBody: { ...requestBody, // 1 month startDate: formatDate(startPeriod), endDate: formatDate(), // keywords dimensions: ['query'], rowLimit, }, }, maxRows), // prev period api.searchanalytics.query({ siteUrl, requestBody: { ...requestBody, // 1 month startDate: formatDate(startPrevPeriod), endDate: formatDate(endPrevPeriod), // keywords dimensions: ['query'], rowLimit, }, }), await recursiveQuery(api, { siteUrl, requestBody: { ...requestBody, // 1 month startDate: formatDate(startPeriod), endDate: formatDate(), rowLimit, }, }, maxRows), api.searchanalytics.query({ siteUrl, requestBody: { ...requestBody, startDate: formatDate(startPrevPeriod), endDate: formatDate(endPrevPeriod), rowLimit, }, }), // do another query but do it based on clicks / impressions for the day api.searchanalytics.query({ siteUrl, requestBody: { ...requestBody, startDate: formatDate(startPrevPeriod), endDate: formatDate(), dimensions: ['date'], rowLimit, }, }), ])) .map(res => res.data.rows || []) const analytics = { // compute analytics from calcualting each url stats togethor period: { totalClicks: period!.reduce((acc, row) => acc + row.clicks!, 0), totalImpressions: period!.reduce((acc, row) => acc + row.impressions!, 0), }, prevPeriod: { totalClicks: prevPeriod!.reduce((acc, row) => acc + row.clicks!, 0), totalImpressions: prevPeriod!.reduce((acc, row) => acc + row.impressions!, 0), }, } const normalizedSiteUrl = normalizeSiteUrl(siteUrl) const indexedUrls = period! .map(r => r.keys![0]) // doman property using www. // strip out subdomains, hash and query .filter(r => !r.includes('#') && !r.includes('?') // fix www. && r.startsWith(normalizedSiteUrl), ) const sitemaps = await api.sitemaps.list({ siteUrl, }) .then(res => res.data.sitemap || []) return { analytics, sitemaps, indexedUrls, period: period.map((row) => { const prevPeriodRow = prevPeriod.find(r => r.keys![0] === row.keys![0]) return { url: parseURL(row.keys![0]).pathname, clicks: row.clicks!, prevClicks: prevPeriodRow ? prevPeriodRow.clicks! : 0, clicksPercent: percentDifference(row.clicks!, prevPeriodRow?.clicks || 0), impressions: row.impressions!, impressionsPercent: percentDifference(row.impressions!, prevPeriodRow?.impressions || 0), prevImpressions: prevPeriodRow ? prevPeriodRow.impressions! : 0, } satisfies SiteAnalytics['period'][0] }), keywords: keywordsPeriod.map((row) => { const prevPeriodRow = keywordsPrevPeriod.find(r => r.keys![0] === row.keys![0]) return { keyword: row.keys![0], // position and ctr position: row.position!, positionPercent: percentDifference(row.position!, prevPeriodRow?.position || 0), prevPosition: prevPeriodRow ? prevPeriodRow.position! : 0, ctr: row.ctr!, ctrPercent: percentDifference(row.ctr!, prevPeriodRow?.ctr || 0), prevCtr: prevPeriodRow ? prevPeriodRow.ctr! : 0, clicks: row.clicks!, impressions: row.impressions, } satisfies SiteAnalytics['keywords'][0] }), graph: graph.map((row) => { // fix key return { clicks: row.clicks!, impressions: row.impressions!, time: row.keys![0], keys: undefined, } satisfies SiteAnalytics['graph'][0] }), } } ================================================ FILE: server/utils/auth/googleAuthEventHandler.ts ================================================ import { createError, eventHandler, getQuery, getRequestURL, sendRedirect, } from 'h3' import { parsePath, withQuery } from 'ufo' import { ofetch } from 'ofetch' import { defu } from 'defu' import type { OAuthGoogleConfig } from 'nuxt-auth-utils/dist/runtime/server/lib/oauth/google' import { useRuntimeConfig } from '#imports' import type { OAuthConfig } from '#auth-utils' // clone of oauth.googleEventHandler from nuxt-auth-utils until // https://github.com/Atinux/nuxt-auth-utils/issues/48 is fixed export function googleAuthEventHandler({ config, onSuccess, onError, }: OAuthConfig) { return eventHandler(async (event) => { config = defu(config, useRuntimeConfig(event).oauth?.google, { authorizationURL: 'https://accounts.google.com/o/oauth2/v2/auth', tokenURL: 'https://oauth2.googleapis.com/token', }) const { code } = getQuery(event) if (!config.clientId) { const error = createError({ statusCode: 500, message: 'Missing NUXT_OAUTH_GOOGLE_CLIENT_ID env variables.', }) if (!onError) throw error return onError(event, error) } const redirectUrl = getRequestURL(event).href if (!code) { config.scope = config.scope || ['email', 'profile'] return sendRedirect( event, withQuery(config.authorizationURL, { response_type: 'code', client_id: config.clientId, redirect_uri: redirectUrl, scope: config.scope.join(' '), ...(config.authorizationParams || {}), }), ) } const body = { grant_type: 'authorization_code', redirect_uri: parsePath(redirectUrl).pathname, client_id: config.clientId, client_secret: config.clientSecret, code, } const tokens = await ofetch(config.tokenURL, { method: 'POST', body, }).catch((error) => { return { error } }) if (tokens.error) { const error = createError({ statusCode: 401, message: `Google login failed: ${tokens.error?.data?.error_description || 'Unknown error'}`, data: tokens, }) if (!onError) throw error return onError(event, error) } const user = await ofetch( 'https://www.googleapis.com/oauth2/v3/userinfo', { headers: { Authorization: `Bearer ${tokens.access_token}`, }, }, ) return onSuccess(event, { tokens, user, }) }) } ================================================ FILE: server/utils/crawler/crawl.ts ================================================ import { $fetch } from 'ofetch' import { withBase } from 'ufo' import Sitemapper from 'sitemapper' import type { ParsedRobotsTxt } from './robotsTxt' import { parseRobotsTxt } from './robotsTxt' export interface CrawlOptions { robots: ParsedRobotsTxt siteUrl: string } export async function crawlSite(options: CrawlOptions) { // hard path, we need to manually crawl the site, some rules: // 1. we respect canonical URLs, don't include pages that have a different canonical URL // 2. we respect robots meta tag and x-robots-tag headers // 3. we respect robots.txt // first we need to make a queue worker where we can batch pages in chunks of 10 // we'll use a set to keep track of visited pages const visited = new Set() const queue = new Set() // we'll add home page to the queue first and add any URLs it discovered to be processed by the queue queue.add('/') // now work the queue, 200 hard cap while (queue.size || visited.size > 200) { // need to work queue in 5 so take 5 from the queue (or however many are left) const chunk = Array.from(queue).slice(0, 5) // remove the chunk from the queue chunk.forEach((url) => { visited.add(url) queue.delete(url) }) // process the chunk const chunkResults = (await Promise.all(chunk.map( url => processPage({ siteUrl: options.siteUrl, url, // we want to scan at least some pages, so we'll allow the first 5 to be scanned // isValidPath: path => (visited.size < 5) || !options.excludePaths.includes(path), robots: options.robots, }), ))) as { indexable: boolean, reason: string, links?: string[] }[] // add the results to the queue chunkResults .filter(({ indexable }) => indexable) .forEach((results) => { results.links!.forEach(url => !visited.has(url) && queue.add(url)) }) } return [...visited] } export async function processPage(options: { robots: ParsedRobotsTxt, url: string, siteUrl: string, isValidPath?: (path: string) => boolean }) { const url = options.url === '/' ? options.siteUrl : withBase(options.url, options.siteUrl) options.siteUrl = options.siteUrl.replace('sc-domain:', 'https://') if (!url.startsWith(options.siteUrl)) return { indexable: false, reason: 'wrong domain' } // avoid making a request if the URL is not allowed by robots.txt if (!options.robots.test(url)) return { indexable: false, reason: 'robots.txt' } const res = await $fetch.raw(url) .catch(() => { return { indexable: false, reason: 'error response' } }) if (res.indexable === false) return res // check meta tag const tag = res.headers['x-robots-tag'] || res.headers['x-robots-tag'] || '' if (tag.includes('noindex')) return { indexable: false, reason: 'x-robots-tag header' } // see if we were redirected if (res.headers.location && res.headers.location !== url) return { indexable: false, reason: 'redirect' } const html = res._data // check for robots meta tag blocking page, need to use regex here // tag may have noindex, but also "noindex, nofollow" or "nofollow, noindex" if (/]+name="robots"[^>]+content="[^"]*noindex[^"]*"[^>]*>/.test(html)) return { indexable: false, reason: 'robots meta tag' } // check canonical tag const canonical = html.match(/]+rel="canonical"[^>]+href="([^"]+)"/) if (canonical && canonical[1] !== url) return { indexable: false, reason: 'canonical URL different' } // parse the page and extract all a tag hrefs that start with a / OR start with the site url const links = res._data.match(/]+href="([^"]+)"/g) .map(a => a.match(/href="([^"]+)"/)[1]) .filter(href => href.startsWith('/') || href.startsWith(options.siteUrl)) .map(href => href.startsWith('/') ? href : new URL(href).pathname) // exclude files .filter(href => !href.split('/').pop().includes('.')) // exclude hash or query strings .filter(href => !href.includes('#') && !href.includes('?')) .filter(href => options.isValidPath ? options.isValidPath(href) : true) return { indexable: true, links } } export async function fetchRobots(options: { cacheKey: string, siteUrl: string }) { const fetchRobotsCached = cachedFunction(async () => { return await $fetch('/robots.txt', { baseURL: options.siteUrl, }).catch(() => `User-agent: *\nDisallow:`) // allow everything by default }, { group: 'app', maxAge: 60 * 60, // 1 hour name: options.cacheKey, getKey: () => 'robots', }) return parseRobotsTxt(await fetchRobotsCached()) } /** * Fetches routes from a sitemap file. */ export async function fetchSitemapUrls(options: { cacheKey: string, siteUrl: string, sitemapPaths: string[], robots?: ParsedRobotsTxt }) { const sitemaps = options.sitemapPaths || options.robots?.sitemaps || [] if (!sitemaps.length) sitemaps.push('/sitemap.xml') // make sure we're working from the host name const sitemap = new Sitemapper({ timeout: 15000, // 15 seconds }) return cachedFunction(async () => { return (await Promise.all([...(new Set(sitemaps))] .map(async (url) => { const abs = withBase(url, options.siteUrl) const isTxt = url.endsWith('.txt') if (isTxt) { return await $fetch(abs) .then(text => text.trim().split('\n')) .catch(() => []) } return await sitemap.fetch(abs).then(r => r.sites).catch(() => []) }))) .filter(Boolean) .flat() }, { name: options.cacheKey, group: 'app', getKey: () => 'sitemap', maxAge: 60 * 60, // 1 hour })() } ================================================ FILE: server/utils/crawler/robotsTxt.ts ================================================ export interface ParsedRobotsTxt { groups: RobotsGroupResolved[] sitemaps: string[] test: (path: string) => boolean } export type Arrayable = T | T[] export type RobotsGroupInput = GoogleInput | YandexInput export interface GoogleInput { comment?: Arrayable disallow?: Arrayable allow?: Arrayable userAgent?: Arrayable } export interface YandexInput extends GoogleInput { cleanParam?: Arrayable } export interface RobotsGroupResolved { comment: string[] disallow: string[] allow: string[] userAgent: string[] host?: string // yandex only cleanParam?: string[] } /** * We're going to read the robots.txt and extract any disallow or sitemaps rules from it. * * We're going to use the Google specification, the keys should be checked: * * - user-agent: identifies which crawler the rules apply to. * - allow: a URL path that may be crawled. * - disallow: a URL path that may not be crawled. * - sitemap: the complete URL of a sitemap. * - host: the host name of the site, this is optional non-standard directive. * * @see https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt */ export function parseRobotsTxt(s: string): ParsedRobotsTxt { // then we'll extract the disallow and sitemap rules const groups: RobotsGroupResolved[] = [] const sitemaps: string[] = [] let createNewGroup = false let currentGroup: RobotsGroupResolved = { comment: [], // comments are too hard to parse in a logical order, we'll just omit them disallow: [], allow: [], userAgent: [], } // read the contents for (const line of s.split('\n')) { const sepIndex = line.indexOf(':') // may not exist for comments if (sepIndex === -1) continue // get the rule, pop before the first : const rule = line.substring(0, sepIndex).trim() const val = line.substring(sepIndex + 1).trim() switch (rule) { case 'User-agent': if (createNewGroup) { groups.push({ ...currentGroup, }) currentGroup = { comment: [], disallow: [], allow: [], userAgent: [], } createNewGroup = false } currentGroup.userAgent.push(val) break case 'Allow': currentGroup.allow.push(val) createNewGroup = true break case 'Disallow': currentGroup.disallow.push(val) createNewGroup = true break case 'Sitemap': sitemaps.push(val) break case 'Host': currentGroup.host = val break case 'Clean-param': if (currentGroup.userAgent.includes('Yandex')) { currentGroup.cleanParam = currentGroup.cleanParam || [] currentGroup.cleanParam.push(val) } break } } // push final stack groups.push({ ...currentGroup, }) return { groups, sitemaps, test(path: string) { return indexableFromGroup(groups, path) }, } } export function asArray(v: any) { return typeof v === 'undefined' ? [] : (Array.isArray(v) ? v : [v]) } export function indexableFromGroup(groups: RobotsGroupInput[], path: string) { let indexable = true const wildCardGroups = groups.filter((group: any) => asArray(group.userAgent).includes('*')) for (const group of wildCardGroups) { if (asArray(group.disallow).includes((rule: string) => rule === '/')) return false const hasDisallowRule = asArray(group.disallow) // filter out empty rule .filter(rule => Boolean(rule)) .some((rule: string) => path.startsWith(rule)) const hasAllowRule = asArray(group.allow).some((rule: string) => path.startsWith(rule)) if (hasDisallowRule && !hasAllowRule) { indexable = false break } } return indexable } export function generateRobotsTxt({ groups, sitemaps }: { groups: RobotsGroupResolved[], sitemaps: string[] }): string { // iterate over the groups const lines: string[] = [] for (const group of groups) { // add the comments for (const comment of group.comment || []) lines.push(`# ${comment}`) // add the user agent for (const userAgent of group.userAgent || ['*']) lines.push(`User-agent: ${userAgent}`) // add the allow rules for (const allow of group.allow || []) lines.push(`Allow: ${allow}`) // add the disallow rules for (const disallow of group.disallow || []) lines.push(`Disallow: ${disallow}`) // yandex only (see https://yandex.com/support/webmaster/robot-workings/clean-param.html) for (const cleanParam of group.cleanParam || []) lines.push(`Clean-param: ${cleanParam}`) lines.push('') // seperator } // add sitemaps for (const sitemap of sitemaps) lines.push(`Sitemap: ${sitemap}`) return lines.join('\n') } ================================================ FILE: server/utils/crypto.ts ================================================ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' // Function to encrypt a token export function encryptToken(token: string, secretKey: string): string { if (secretKey.length !== 32) throw new Error('Secret key must be 32 characters long for aes-256-gcm.') const iv = randomBytes(16) // Initialization vector const cipher = createCipheriv('aes-256-gcm', secretKey, iv) let encrypted = cipher.update(token, 'utf8', 'hex') encrypted += cipher.final('hex') const authTag = cipher.getAuthTag().toString('hex') // Return the IV, encrypted token, and authTag in a single string return `${iv.toString('hex')}:${encrypted}:${authTag}` } // Function to decrypt a token export function decryptToken(encryptedToken: string, secretKey: string): string { if (secretKey.length !== 32) throw new Error('Secret key must be 32 characters long for aes-256-gcm.') const parts = encryptedToken.split(':') const iv = Buffer.from(parts[0], 'hex') const encrypted = parts[1] const authTag = Buffer.from(parts[2], 'hex') const decipher = createDecipheriv('aes-256-gcm', secretKey, iv) decipher.setAuthTag(authTag) let decrypted = decipher.update(encrypted, 'hex', 'utf8') decrypted += decipher.final('utf8') return decrypted } ================================================ FILE: server/utils/date.ts ================================================ // pacific timezone is 7 hours behind UTC, it has 3 letter abbreviation PST export function datePST() { const d = new Date() d.setHours(d.getHours() - 7) // format it as yyyy:mm:dd return d.toISOString().split('T')[0].replace(/-/g, ':') } ================================================ FILE: server/utils/formatting.ts ================================================ import { withHttps } from 'ufo' export function normalizeSiteUrl(siteUrl: string) { return siteUrl.startsWith('sc-domain:') ? withHttps(`${siteUrl.split(':')[1]}/`) : siteUrl } export function percentDifference(a?: number, b?: number) { if (!b || !a) return 0 return ((a - b) / ((a + b) / 2)) * 100 } ================================================ FILE: server/utils/oauthPool.ts ================================================ import type { Storage } from 'unstorage' import { prefixStorage } from 'unstorage' import type { OAuthPoolPayload, OAuthPoolToken } from '~/types' import { appStorage } from '~/server/utils/storage' // @ts-expect-error runtime import { tokens as _tokens, privateTokens } from '#app/token-pool.mjs' export const oAuthPoolStorage = prefixStorage(appStorage as Storage, 'auth:pool') export function createOAuthPool() { const tokens = _tokens as OAuthPoolToken[] const { maxUsersPerOAuth } = useRuntimeConfig().indexing return { get(id: string) { const token = tokens.find(t => t.id === id) if (token) return token const privateToken = privateTokens.find(t => t.id === id) if (privateToken) return privateToken }, async free() { const available = (await Promise.all( tokens.map(async (k) => { const payload = await oAuthPoolStorage.getItem(k.id) || { id: k.id, users: [] } satisfies OAuthPoolPayload return payload!.users.length < maxUsersPerOAuth ? k : null }), )).filter(Boolean) as OAuthPoolToken[] // get random available token if (available.length) return available[Math.floor(Math.random() * available.length)] }, async claim(id: string, userId: string) { const token = tokens.find(t => t.id === id) if (token) { const payload = await oAuthPoolStorage.getItem(token.id) || { id: token.id, users: [] } satisfies OAuthPoolPayload payload.users = [...new Set([...payload.users, userId])] await oAuthPoolStorage.setItem(token.id, payload) } }, async release(id: string, userId: string) { const token = tokens.find(t => t.id === id) if (token) { const payload = await oAuthPoolStorage.getItem(token.id) || { id: token.id, users: [] } satisfies OAuthPoolPayload payload.users = payload.users.filter(u => u !== userId) await oAuthPoolStorage.setItem(token.id, payload) } }, async usage() { // iterate tokens, fetch payload, count users const usage = await Promise.all(tokens.map(async (t) => { const payload = await oAuthPoolStorage.getItem(t.id) || { id: t.id, users: [] } satisfies OAuthPoolPayload return payload.users.length })) // want to return how many have been used and how many are free return { used: usage.reduce((a, b) => a + b, 0), free: usage.length * maxUsersPerOAuth - usage.reduce((a, b) => a + b, 0), } }, } } ================================================ FILE: server/utils/quota.ts ================================================ import { datePST } from '~/server/utils/date' import type { UserQuota } from '~/types' export async function getUserQuotaUsage(userId: string, key: keyof UserQuota) { const quota = await getUserQuota(userId) return quota[key] || 0 } export async function incrementUserQuota(userId: string, key: keyof UserQuota) { const quota = await getUserQuota(userId) quota[key] = (quota[key] || 0) + 1 await userAppStorage(userId, 'quota').setItem(`${datePST()}.json`, quota) return quota[key] } export async function getUserQuota(userId: string) { return (await userAppStorage(userId, 'quota').getItem(`${datePST()}.json`)) || { indexingApi: 0 } satisfies UserQuota } ================================================ FILE: server/utils/session.ts ================================================ import type { H3Event, SessionConfig } from 'h3' import type { defuFn } from 'defu' import { defu } from 'defu' import { useRuntimeConfig } from '#imports' import type { UserSession } from '~/types' export async function mergeUserSessionData(event: H3Event, data: Partial, merger: typeof defuFn) { const session = await _useSession(event) const fn = merger || defu await session.update(fn(data, session.data)) return session.data } let sessionConfig: SessionConfig function _useSession(event: H3Event) { sessionConfig = sessionConfig || defu({ password: import.meta.env.NUXT_SESSION_PASSWORD }, useRuntimeConfig(event).session as SessionConfig) return useSession(event, sessionConfig) } ================================================ FILE: server/utils/sharedCache.ts ================================================ import type { GoogleSearchConsoleSite, NitroAuthData } from '~/types' import { fetchGoogleSearchConsoleSites } from '~/server/utils/api/googleSearchConsole' export const fetchSitesCached = cachedFunction( async ({ tokens }: NitroAuthData) => { return await fetchGoogleSearchConsoleSites(tokens) }, { maxAge: 60 * 10, group: 'app', name: 'user', validate(item) { return Array.isArray(item) && Boolean(item.length) }, shouldInvalidateCache(_, force?: boolean) { return !!force }, transform(entry) { if (entry.value) return entry.value.sort((a, b) => normalizeSiteUrlForKey(a.siteUrl).localeCompare(normalizeSiteUrlForKey(b.siteUrl))) }, getKey: (authData: NitroAuthData) => `${authData.user.userId}:sites`, }, ) ================================================ FILE: server/utils/storage.ts ================================================ import type { Storage, StorageValue } from 'unstorage' import { prefixStorage } from 'unstorage' import { createDefu, defu } from 'defu' import { parse, stringify } from 'devalue' import type { User, UserOAuthToken, UserSite } from '~/types' import { decryptToken, encryptToken } from '~/server/utils/crypto' import { useRuntimeConfig } from '#imports' export interface UserAppStorage {} export function normalizeSiteUrlForKey(siteUrl: string) { // strip https, strip sc-domain, strip non-alphanumeric return siteUrl .replace('https://', '') .replace('sc-domain:', '') .replace(/[^a-z0-9]/g, '') } export const appStorage = prefixStorage(useStorage(), 'app') export function userAppStorage(userId: string, namespace?: string) { if (!userId) throw new Error('userId is required') return prefixStorage(appStorage as Storage, `user:${userId}${namespace ? `:${namespace}` : ''}`) } function userSiteAppStorage(userId: string, siteUrl: string, namespace?: string) { return prefixStorage(appStorage as Storage, `user:${userId}:sites:${normalizeSiteUrlForKey(siteUrl)}:${namespace ? `:${namespace}` : ''}`) } export const userMerger = createDefu((data, key, value) => { // we want to override arrays when an empty one is provided if (Array.isArray(data[key]) && Array.isArray(value)) { data[key] = value return true } }) export async function updateUser(userId: string, value: Partial) { const updated = userMerger(value, await getUser(userId)) await userAppStorage(userId).setItem('me.json', updated) return updated } export function getUser(userId: string) { return userAppStorage(userId).getItem('me.json') } export async function getUserSite(userId: string, siteUrl: string) { return (await userSiteAppStorage(userId, siteUrl) .getItem('payload.json')) || { urls: [] } satisfies UserSite } const userSiteMerger = createDefu((data, key, value) => { if (key === 'urls' && Array.isArray(value)) { // dedupe the array based on the url const urlsToAdd = [...value].filter(Boolean) data.urls = data.urls.filter(Boolean).map((u) => { const existing = urlsToAdd.findIndex(v => v?.url === u?.url) if (existing >= 0) { const val = urlsToAdd[existing] delete urlsToAdd[existing] return defu(val, u) } return u }).filter(Boolean) // just append new urls data.urls.push(...urlsToAdd) return true } }) export async function updateUserSite(userId: string, siteUrl: string, payload: Partial) { const siteData = await getUserSite(userId, siteUrl) return userSiteAppStorage(userId, siteUrl).setItem('payload.json', userSiteMerger(payload, siteData)) } export async function clearUserStorage(userId: string) { const keys = await userAppStorage(userId).getKeys() for (const key of keys) await userAppStorage(userId).removeItem(key) } export async function updateUserToken(userId: string, key: 'indexing' | 'login', value: Partial) { // need to encrypt const encrypted = encryptToken(stringify(value), useRuntimeConfig().key) return userAppStorage(userId, 'tokens').setItem(`${key}.json`, encrypted) } export async function getUserToken(userId: string, key: 'indexing' | 'login') { const token = await userAppStorage(userId, 'tokens').getItem(`${key}.json`) if (token) return parse(decryptToken(token, useRuntimeConfig().key)) return token } export function deleteUserToken(userId: string, key: 'indexing' | 'login') { return userAppStorage(userId, 'tokens').removeItem(`${key}.json`) } export async function incrementMetric(key: string) { const analytics = prefixStorage(appStorage, 'analytics') const newVal = (await analytics.getItem(key) || 0) + 1 await analytics.setItem(key, newVal) return newVal } export async function getMetric(key: string) { return (await prefixStorage(appStorage, 'analytics').getItem(key)) || 0 } ================================================ FILE: tailwind.config.ts ================================================ import type { Config } from 'tailwindcss' import defaultTheme from 'tailwindcss/defaultTheme' export default >{ theme: { extend: { fontFamily: { sans: ['DM Sans', 'DM Sans fallback', ...defaultTheme.fontFamily.sans], }, }, }, } ================================================ FILE: tsconfig.json ================================================ { // https://nuxt.com/docs/guide/concepts/typescript "extends": "./.nuxt/tsconfig.json" } ================================================ FILE: types/auth.ts ================================================ import type { Credentials } from 'google-auth-library' import type { RequiredNonNullable } from '~/types/util' export interface NitroAuthData { tokens: TokenPayload['tokens'] user: User } export type UserOAuthToken = RequiredNonNullable export interface TokenPayload { updatedAt: number sub: string tokens: RequiredNonNullable } export interface OAuthPoolPayload { id: string users: string[] } export interface OAuthPoolToken { id: string client_id: string client_secret: string label: string } export interface UserQuota { indexingApi: number } export interface User { email: string quota: UserQuota userId: string access?: 'pro' picture: string // legacy indexingOAuthId?: string indexingOAuthIdNext?: string lastIndexingOAuthIdNext?: string analyticsPeriod: string hiddenSites?: string[] } export interface UserSession { sub: string user: User // used when redirecting to Web Indexing API OAuth googleIndexingAuth?: { indexingOAuthId: string referrer: string state: string } } ================================================ FILE: types/data.ts ================================================ import type { searchconsole_v1 } from '@googleapis/searchconsole/v1' import type { indexing_v3 } from '@googleapis/indexing/v3' import type { RequiredNonNullable } from '~/types/util' export interface IndexedUrl extends RequiredNonNullable { } export interface SitePage { url: string lastInspected?: number // inspect url gsc response inspectionResult?: searchconsole_v1.Schema$UrlInspectionResult // submit url for indexing response urlNotificationMetadata?: indexing_v3.Schema$UrlNotificationMetadata } export interface UserSite { urls: SitePage[] crawl?: { updatedAt: number urls: string[] } } export interface GoogleSearchConsoleSite { siteUrl: string permissionLevel: 'siteOwner' | 'siteRestrictedUser' | 'siteFullUser' } export interface SiteAnalytics { analytics: { period: { totalClicks: number totalImpressions: number } prevPeriod: { totalClicks: number totalImpressions: number } } sitemaps: searchconsole_v1.Schema$WmxSitemap[] indexedUrls: string[] period: { url: string clicks: number clicksPercent: number prevClicks: number impressions: number impressionsPercent: number prevImpressions: number }[] keywords: { keyword: string position: number prevPosition: number positionPercent: number ctr: number ctrPercent: number prevCtr: number clicks: number }[] graph: { keys?: undefined time: string clicks: number impressions: number }[] } export interface SiteExpanded extends SiteAnalytics, GoogleSearchConsoleSite { nonIndexedPercent: number nonIndexedUrls: SitePage[] } ================================================ FILE: types/index.ts ================================================ export * from './auth' export * from './data' ================================================ FILE: types/nitro.d.ts ================================================ import type { NitroAuthData } from '~/types/auth' declare module 'h3' { export interface H3EventContext { authenticatedData: NitroAuthData } } export {} ================================================ FILE: types/util.ts ================================================ export type NonNullable = Exclude export type RequiredNonNullable = Required> ================================================ FILE: vitest.config.ts ================================================ import { defineVitestConfig } from '@nuxt/test-utils/config' export default defineVitestConfig({ // any custom Vitest config you require test: { watchExclude: ['.db/**', '.nuxt/**', '.saas'], }, })