diff --git a/package.json b/package.json index c241d9f..53b8789 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,13 @@ "dependencies": { "@google-analytics/admin": "^8.2.0", "@google-analytics/data": "^5.1.0", + "@sentry/node": "^9.0.0", "dotenv": "^16.5.0", "effect": "^3.15.4", + "googleapis": "^150.0.1", "inversify": "^7.5.1", "node-fetch": "^3.3.2", "reflect-metadata": "^0.2.2", - "@sentry/node": "^9.0.0", "yaml": "^2.5.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa820d9..ce1c27f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: effect: specifier: ^3.15.4 version: 3.16.8 + googleapis: + specifier: ^150.0.1 + version: 150.0.1 inversify: specifier: ^7.5.1 version: 7.5.2(reflect-metadata@0.2.2) @@ -730,6 +733,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@5.2.0: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} @@ -909,6 +916,14 @@ packages: resolution: {integrity: sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==} engines: {node: '>=14'} + googleapis-common@8.0.2-rc.0: + resolution: {integrity: sha512-JTcxRvmFa9Ec1uyfMEimEMeeKq1sHNZX3vn2qmoUMtnvixXXvcqTcbDZvEZXkEWpGlPlOf4joyep6/qs0BrLyg==} + engines: {node: '>=18.0.0'} + + googleapis@150.0.1: + resolution: {integrity: sha512-9Wa9vm3WtDpss0VFBHsbZWcoRccpOSWdpz7YIfb1LBXopZJEg/Zc8ymmaSgvDkP4FhN+pqPS9nZjO7REAJWSUg==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1024,6 +1039,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1091,6 +1110,10 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1134,6 +1157,22 @@ packages: shimmer@1.2.1: resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1208,6 +1247,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1985,6 +2027,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@5.2.0: dependencies: assertion-error: 2.0.1 @@ -2209,6 +2256,23 @@ snapshots: google-logging-utils@1.1.1: {} + googleapis-common@8.0.2-rc.0: + dependencies: + extend: 3.0.2 + gaxios: 7.0.0-rc.4 + google-auth-library: 10.0.0-rc.2 + qs: 6.14.0 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + googleapis@150.0.1: + dependencies: + google-auth-library: 10.0.0-rc.2 + googleapis-common: 8.0.2-rc.0 + transitivePeerDependencies: + - supports-color + gopd@1.2.0: {} gtoken@8.0.0-rc.1: @@ -2327,6 +2391,8 @@ snapshots: object-hash@3.0.0: {} + object-inspect@1.13.4: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -2392,6 +2458,10 @@ snapshots: pure-rand@6.1.0: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -2458,6 +2528,34 @@ snapshots: shimmer@1.2.1: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -2522,6 +2620,8 @@ snapshots: undici-types@6.21.0: {} + url-template@2.0.8: {} + util-deprecate@1.0.2: {} vite-node@3.2.4(@types/node@22.15.32)(yaml@2.8.0): diff --git a/src/application/query/SearchConsoleQuery.ts b/src/application/query/SearchConsoleQuery.ts new file mode 100644 index 0000000..7e49da2 --- /dev/null +++ b/src/application/query/SearchConsoleQuery.ts @@ -0,0 +1,9 @@ +import type { Effect } from "effect"; +import type { SearchConsoleWebsiteData } from "../../domain/SearchConsole.js"; +import type { WebsiteConfig } from "../../domain/WebsiteConfig.js"; + +export interface SearchConsoleQuery { + getSearchConsoleDataByWebsites( + websites: WebsiteConfig[], + ): Effect.Effect; +} diff --git a/src/config/Container.ts b/src/config/Container.ts index df6e0e0..115ec5e 100644 --- a/src/config/Container.ts +++ b/src/config/Container.ts @@ -2,6 +2,8 @@ import { Container } from "inversify"; import "reflect-metadata"; import type { PVQuery } from "../application/query/PVQuery.js"; import { Ga4PVQueryAdapter } from "../infrastructure/query/GA4PVQueryAdapter.js"; +import type { SearchConsoleQuery } from "../application/query/SearchConsoleQuery.js"; +import { SearchConsolePVQueryAdapter } from "../infrastructure/query/SearchConsolePVQueryAdapter.js"; import type { SlackCommand } from "../application/command/SlackCommand.js"; import { SlackCommandAdapter } from "../infrastructure/command/SlackCommandAdapter.js"; import { envConfig } from "./EnvConfig.js"; @@ -26,6 +28,11 @@ container container.bind(TYPES.PVQuery).to(Ga4PVQueryAdapter).inSingletonScope(); +container + .bind(TYPES.SearchConsoleQuery) + .to(SearchConsolePVQueryAdapter) + .inSingletonScope(); + container .bind(TYPES.SlackCommand) .to(SlackCommandAdapter) diff --git a/src/config/Types.ts b/src/config/Types.ts index aba54fe..3b24b87 100644 --- a/src/config/Types.ts +++ b/src/config/Types.ts @@ -5,6 +5,7 @@ export const TYPES = { SentryDsn: Symbol.for("SentryDsn"), }, PVQuery: Symbol.for("PQuery"), + SearchConsoleQuery: Symbol.for("SearchConsoleQuery"), SlackCommand: Symbol.for("SlackCommand"), WebMetricsCollector: Symbol.for("WebMetricsCollector"), ErrorReporter: Symbol.for("ErrorReporter"), diff --git a/src/domain/SearchConsole.ts b/src/domain/SearchConsole.ts new file mode 100644 index 0000000..cb2fa4b --- /dev/null +++ b/src/domain/SearchConsole.ts @@ -0,0 +1,8 @@ +export interface SearchConsoleWebsiteData { + websiteName: string; + siteUrl: string; + clicks: number; + impressions: number; + ctr: number; + position: number; +} diff --git a/src/infrastructure/query/SearchConsolePVQueryAdapter.ts b/src/infrastructure/query/SearchConsolePVQueryAdapter.ts new file mode 100644 index 0000000..9ced8db --- /dev/null +++ b/src/infrastructure/query/SearchConsolePVQueryAdapter.ts @@ -0,0 +1,59 @@ +import { Effect } from "effect"; +import type { SearchConsoleWebsiteData } from "../../domain/SearchConsole.js"; +import type { WebsiteConfig } from "../../domain/WebsiteConfig.js"; +import type { SearchConsoleQuery } from "../../application/query/SearchConsoleQuery.js"; +import { google } from "googleapis"; +import { inject, injectable } from "inversify"; +import { TYPES } from "../../config/Types.js"; + +@injectable() +export class SearchConsolePVQueryAdapter implements SearchConsoleQuery { + private readonly auth: any; + private readonly webmasters: any; + + constructor(@inject(TYPES.config.GoogleKeyFilePath) path: string) { + this.auth = new google.auth.GoogleAuth({ + keyFile: path, + scopes: ["https://www.googleapis.com/auth/webmasters.readonly"], + }); + this.webmasters = google.webmasters({ + version: "v3", + auth: this.auth, + }); + } + + getSearchConsoleDataByWebsites( + websites: WebsiteConfig[], + ): Effect.Effect { + const websitesWithSearchConsole = websites.filter( + (website) => website.metrics.searchConsole?.siteUrl, + ); + + return Effect.all( + websitesWithSearchConsole.map((website) => + Effect.promise(async () => { + const siteUrl = website.metrics.searchConsole!.siteUrl; + const result = await this.webmasters.searchanalytics.query({ + siteUrl: siteUrl, + requestBody: { + startDate: "yesterday", + endDate: "yesterday", + dimensions: [], + rowLimit: 1, + }, + }); + + const row = result.data.rows?.[0]; + return { + websiteName: website.name, + siteUrl: siteUrl, + clicks: row?.clicks || 0, + impressions: row?.impressions || 0, + ctr: row?.ctr || 0, + position: row?.position || 0, + }; + }), + ), + ); + } +}