From a532776eee593edc1ff5173bcafb42ca6af11e4b Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 14 Apr 2026 01:59:54 +0100 Subject: [PATCH] feat: Bing Webmaster Tools integration --- .../src/analytics/analytics.controller.ts | 15 +- backend/apps/cloud/src/common/utils.ts | 1 + .../apps/cloud/src/project/bwt.controller.ts | 118 +++ backend/apps/cloud/src/project/bwt.service.ts | 798 ++++++++++++++++++ .../src/project/entity/project.entity.ts | 19 + backend/apps/cloud/src/project/gsc.service.ts | 159 ++++ .../apps/cloud/src/project/project.module.ts | 8 +- backend/migrations/mysql/2026_04_14_bwt.sql | 7 + web/app/api/api.server.ts | 19 + web/app/hooks/useAuthProxy.ts | 17 + web/app/lib/models/Project.ts | 1 + .../Project/Settings/ProjectSettings.tsx | 240 ++++++ web/app/pages/Project/tabs/SEO/SEOView.tsx | 23 +- web/app/routes/api.auth.ts | 24 + web/app/routes/bwt-connected.tsx | 149 ++++ web/app/routes/projects.settings.$id.tsx | 124 +++ web/app/utils/routes.ts | 1 + web/public/locales/en.json | 15 + 18 files changed, 1726 insertions(+), 12 deletions(-) create mode 100644 backend/apps/cloud/src/project/bwt.controller.ts create mode 100644 backend/apps/cloud/src/project/bwt.service.ts create mode 100644 backend/migrations/mysql/2026_04_14_bwt.sql create mode 100644 web/app/routes/bwt-connected.tsx diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 36152d32e..92bed25f9 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -101,6 +101,7 @@ import { LiveVisitorsDto } from './dto/live-visitors.dto' import { GetHeartbeatStatsDto } from './dto/get-heartbeat-stats' import { GetKeywordsDto } from './dto/get-keywords.dto' import { GSCService } from '../project/gsc.service' +import { BWTService } from '../project/bwt.service' import { GetProfileIdDto, GetSessionIdDto } from './dto/get-id.dto' dayjs.extend(utc) @@ -209,6 +210,7 @@ export class AnalyticsController { private readonly analyticsService: AnalyticsService, private readonly logger: AppLoggerService, private readonly gscService: GSCService, + private readonly bwtService: BWTService, ) {} @ApiBearerAuth() @@ -1045,13 +1047,12 @@ export class AnalyticsController { diff, ) - return this.gscService.getDashboard( - pid, - groupFrom, - groupTo, - finalTimeBucket, - filters, - ) + const [gscData, bwtData] = await Promise.all([ + this.gscService.getDashboard(pid, groupFrom, groupTo, finalTimeBucket, filters), + this.bwtService.getDashboard(pid, groupFrom, groupTo, finalTimeBucket, filters).catch(() => null), + ]) + + return GSCService.mergeDashboards(gscData, bwtData) } @Get('gsc-details') diff --git a/backend/apps/cloud/src/common/utils.ts b/backend/apps/cloud/src/common/utils.ts index afe8e896f..73ba39320 100644 --- a/backend/apps/cloud/src/common/utils.ts +++ b/backend/apps/cloud/src/common/utils.ts @@ -37,6 +37,7 @@ export const deriveKey = ( | 'access-token' | 'gsc-token' | 'ga4-token' + | 'bwt-token' | 'revenue', length = 32, ) => { diff --git a/backend/apps/cloud/src/project/bwt.controller.ts b/backend/apps/cloud/src/project/bwt.controller.ts new file mode 100644 index 000000000..481274442 --- /dev/null +++ b/backend/apps/cloud/src/project/bwt.controller.ts @@ -0,0 +1,118 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseGuards, + BadRequestException, + HttpCode, + Ip, + Headers, +} from '@nestjs/common' +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' + +import { AuthenticationGuard } from '../auth/guards/authentication.guard' +import { Auth } from '../auth/decorators' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { BWTService } from './bwt.service' +import { ProjectService } from './project.service' +import { trackCustom } from '../common/analytics' +import { getIPFromHeaders } from '../common/utils' + +@ApiTags('Project - Bing Webmaster Tools') +@UseGuards(AuthenticationGuard) +@Controller({ path: 'project/bwt', version: '1' }) +export class BWTController { + constructor( + private readonly bwtService: BWTService, + private readonly projectService: ProjectService, + ) {} + + @Post('process-token') + @Auth() + async processBWTToken( + @Body() body: { code: string; state: string }, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() requestIp: string, + ) { + const ip = getIPFromHeaders(headers) || requestIp || '' + const { code, state } = body + + if (!code || !state) { + throw new BadRequestException('Invalid BWT token parameters') + } + + const { pid } = await this.bwtService.handleOAuthCallback(uid, code, state) + + await trackCustom(ip, headers['user-agent'], { + ev: 'BWT_CONNECTED', + }) + + return { pid } + } + + @ApiBearerAuth() + @Post(':pid/connect') + @Auth() + async connect(@Param('pid') pid: string, @CurrentUserId() uid: string) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + return this.bwtService.generateConnectURL(uid, pid) + } + + @ApiBearerAuth() + @Get(':pid/status') + @Auth() + async status(@Param('pid') pid: string, @CurrentUserId() uid: string) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + return this.bwtService.getStatus(pid) + } + + @ApiBearerAuth() + @Get(':pid/properties') + @Auth() + async properties(@Param('pid') pid: string, @CurrentUserId() uid: string) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + return this.bwtService.listSites(pid) + } + + @ApiBearerAuth() + @Post(':pid/property') + @Auth() + async setProperty( + @Param('pid') pid: string, + @CurrentUserId() uid: string, + @Body() body: { propertyUri: string }, + ) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + await this.bwtService.setProperty(pid, body?.propertyUri) + return {} + } + + @ApiBearerAuth() + @Delete(':pid/disconnect') + @Auth() + @HttpCode(204) + async disconnect( + @Param('pid') pid: string, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() requestIp: string, + ) { + const ip = getIPFromHeaders(headers) || requestIp || '' + + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + await this.bwtService.disconnect(pid) + + await trackCustom(ip, headers['user-agent'], { + ev: 'BWT_DISCONNECTED', + }) + } +} diff --git a/backend/apps/cloud/src/project/bwt.service.ts b/backend/apps/cloud/src/project/bwt.service.ts new file mode 100644 index 000000000..43d9dc572 --- /dev/null +++ b/backend/apps/cloud/src/project/bwt.service.ts @@ -0,0 +1,798 @@ +import { parse as parseDomain } from 'tldts' +import _isEmpty from 'lodash/isEmpty' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import { + Injectable, + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import CryptoJS from 'crypto-js' + +import { isDevelopment, PRODUCTION_ORIGIN, redis } from '../common/constants' +import { ProjectService } from './project.service' +import { deriveKey } from '../common/utils' +import { parseBrandKeywords } from './gsc.service' + +dayjs.extend(utc) + +type StoredTokens = { + access_token?: string + refresh_token?: string + scope?: string + expiry_date?: number + site_url?: string | null +} + +const REDIS_STATE_PREFIX = 'bwt:state:' + +const BWT_REDIRECT_URL = isDevelopment + ? 'http://localhost:3000/bwt-connected' + : `${PRODUCTION_ORIGIN}/bwt-connected` + +const BWT_AUTHORIZE_URL = 'https://www.bing.com/webmasters/OAuth/authorize' +const BWT_TOKEN_URL = 'https://www.bing.com/webmasters/oauth/token' +const BWT_API_BASE = 'https://ssl.bing.com/webmaster/api.svc/json' + +const ENCRYPTION_KEY = deriveKey('bwt-token') + +type BwtQueryStatsRow = { + Query: string + Clicks: number + Impressions: number + AvgClickPosition: number + AvgImpressionPosition: number + Date: string +} + +type BwtRankTrafficRow = { + Clicks: number + Impressions: number + Date: string +} + +function parseBingDate(raw: string): dayjs.Dayjs { + const match = /\/Date\((\d+)/.exec(raw) + if (match) { + return dayjs.utc(Number(match[1])) + } + return dayjs.utc(raw) +} + +@Injectable() +export class BWTService { + constructor( + private readonly configService: ConfigService, + private readonly projectService: ProjectService, + ) {} + + private getClientCredentials() { + const clientId = this.configService.get('BING_BWT_CLIENT_ID') + const clientSecret = this.configService.get( + 'BING_BWT_CLIENT_SECRET', + ) + + if (!clientId || !clientSecret) { + throw new InternalServerErrorException( + 'Bing Webmaster Tools client is not configured', + ) + } + + return { clientId, clientSecret } + } + + async generateConnectURL(uid: string, pid: string): Promise<{ url: string }> { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + + const { clientId } = this.getClientCredentials() + + const state = `${pid}:${uid}:${Date.now()}` + await redis.set( + REDIS_STATE_PREFIX + state, + JSON.stringify({ uid, pid }), + 'EX', + 600, + ) + + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + redirect_uri: BWT_REDIRECT_URL, + scope: 'webmaster.read', + state, + }) + + return { url: `${BWT_AUTHORIZE_URL}?${params.toString()}` } + } + + async handleOAuthCallback(uid: string, code: string, state: string) { + const cached = await redis.get(REDIS_STATE_PREFIX + state) + if (!cached) { + throw new BadRequestException('Invalid or expired OAuth state') + } + + const { pid } = JSON.parse(cached) + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + + const { clientId, clientSecret } = this.getClientCredentials() + + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: BWT_REDIRECT_URL, + }) + + const res = await fetch(BWT_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!res.ok) { + throw new BadRequestException('Failed to exchange Bing OAuth code') + } + + const tokens = await res.json() + + const existingTokens = await this.getStoredTokens(pid) + + const toStore: StoredTokens = { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expiry_date: tokens.expires_in + ? Date.now() + tokens.expires_in * 1000 + : undefined, + scope: tokens.scope, + site_url: existingTokens.site_url ?? null, + } + + await this.setStoredTokens(pid, toStore) + + // Bing doesn't expose user email via a standard endpoint, + // so we store a placeholder indicating the account is connected. + await this.projectService.update( + { id: pid }, + { bwtAccountEmail: 'connected' } as any, + ) + + await redis.del(REDIS_STATE_PREFIX + state) + + return { pid } + } + + private async getStoredTokens(pid: string): Promise { + const project = await this.projectService.findOne({ + where: { id: pid }, + select: [ + 'bwtAccessTokenEnc', + 'bwtRefreshTokenEnc', + 'bwtTokenExpiry', + 'bwtScope', + 'bwtSiteUrl', + ], + }) + + if (!project) return {} + + const decrypt = (val?: string | null) => { + if (!val) return undefined + try { + const bytes = CryptoJS.Rabbit.decrypt(val, ENCRYPTION_KEY) + return bytes.toString(CryptoJS.enc.Utf8) || undefined + } catch { + return undefined + } + } + + const expiry = project.bwtTokenExpiry + ? Number(project.bwtTokenExpiry) + : undefined + + return { + access_token: decrypt(project.bwtAccessTokenEnc), + refresh_token: decrypt(project.bwtRefreshTokenEnc), + expiry_date: expiry, + scope: project.bwtScope || undefined, + site_url: project.bwtSiteUrl || null, + } + } + + private async setStoredTokens(pid: string, tokens: StoredTokens) { + const encrypt = (val?: string) => + val ? CryptoJS.Rabbit.encrypt(val, ENCRYPTION_KEY).toString() : null + + await this.projectService.update( + { id: pid }, + { + bwtAccessTokenEnc: encrypt(tokens.access_token), + bwtRefreshTokenEnc: encrypt(tokens.refresh_token), + bwtTokenExpiry: tokens.expiry_date as any, + bwtScope: tokens.scope || null, + bwtSiteUrl: tokens.site_url ?? null, + } as any, + ) + } + + async disconnect(pid: string) { + await this.projectService.update( + { id: pid }, + { + bwtAccessTokenEnc: null, + bwtRefreshTokenEnc: null, + bwtTokenExpiry: null, + bwtScope: null, + bwtSiteUrl: null, + bwtAccountEmail: null, + } as any, + ) + } + + async isConnected(pid: string) { + const tokens = await this.getStoredTokens(pid) + return !_isEmpty(tokens?.refresh_token || tokens?.access_token) + } + + async getStatus( + pid: string, + ): Promise<{ connected: boolean; email: string | null }> { + const connected = await this.isConnected(pid) + if (!connected) return { connected, email: null } + const project = await this.projectService.findOne({ + where: { id: pid }, + select: ['bwtAccountEmail'], + }) + return { connected, email: project?.bwtAccountEmail || null } + } + + private async getAccessToken(pid: string): Promise { + const tokens = await this.getStoredTokens(pid) + + if (_isEmpty(tokens)) { + throw new BadRequestException( + 'Bing Webmaster Tools is not connected for this project', + ) + } + + if (tokens.expiry_date && tokens.expiry_date <= Date.now()) { + if (!tokens.refresh_token) { + throw new BadRequestException('Bing token expired and no refresh token') + } + + const { clientId, clientSecret } = this.getClientCredentials() + + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: tokens.refresh_token, + grant_type: 'refresh_token', + }) + + const res = await fetch(BWT_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + + if (!res.ok) { + throw new BadRequestException('Failed to refresh Bing access token') + } + + const data = await res.json() + + const updated: StoredTokens = { + ...tokens, + access_token: data.access_token, + expiry_date: data.expires_in + ? Date.now() + data.expires_in * 1000 + : Date.now() + 55 * 60 * 1000, + refresh_token: data.refresh_token || tokens.refresh_token, + } + + await this.setStoredTokens(pid, updated) + return updated.access_token! + } + + return tokens.access_token! + } + + private async callBingApi( + pid: string, + method: string, + params: Record = {}, + ): Promise { + const accessToken = await this.getAccessToken(pid) + + const query = new URLSearchParams(params) + const url = `${BWT_API_BASE}/${method}?${query.toString()}` + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!res.ok) { + throw new InternalServerErrorException( + `Bing API call ${method} failed: ${res.status}`, + ) + } + + return res.json() + } + + async listSites( + pid: string, + ): Promise<{ siteUrl: string; permissionLevel?: string }[]> { + try { + const data = await this.callBingApi<{ d: any[] }>(pid, 'GetUserSites') + const sites = data?.d || [] + return sites.map((s: any) => ({ + siteUrl: s.Url || s.url || s.SiteUrl || '', + permissionLevel: undefined, + })) + } catch { + throw new InternalServerErrorException( + 'Failed to fetch Bing Webmaster sites', + ) + } + } + + async setProperty(pid: string, siteUrl: string) { + const tokens = await this.getStoredTokens(pid) + if (_isEmpty(tokens)) { + throw new BadRequestException( + 'Bing Webmaster Tools is not connected for this project', + ) + } + await this.projectService.update( + { id: pid }, + { bwtSiteUrl: siteUrl ?? null } as any, + ) + } + + private filterByDateRange( + rows: T[], + from: string, + to: string, + ): T[] { + const fromDate = dayjs.utc(from).startOf('day') + const toDate = dayjs.utc(to).endOf('day') + + return rows.filter((row) => { + const d = parseBingDate(row.Date) + return d.isAfter(fromDate.subtract(1, 'millisecond')) && d.isBefore(toDate.add(1, 'millisecond')) + }) + } + + private getSiteUrl(pid: string): Promise { + return this.getStoredTokens(pid).then((tokens) => { + if (_isEmpty(tokens.site_url)) { + throw new BadRequestException( + 'Bing Webmaster site is not linked for this project', + ) + } + return tokens.site_url as string + }) + } + + async getKeywords( + pid: string, + from: string, + to: string, + limit = 250, + offset = 0, + ): Promise< + { + name: string + count: number + impressions: number + position: number + ctr: number + }[] + > { + const siteUrl = await this.getSiteUrl(pid) + + try { + const data = await this.callBingApi<{ d: BwtQueryStatsRow[] }>( + pid, + 'GetQueryStats', + { siteUrl }, + ) + + const filtered = this.filterByDateRange(data?.d || [], from, to) + + const aggregated = new Map< + string, + { clicks: number; impressions: number; positionSum: number; count: number } + >() + + for (const row of filtered) { + const key = (row.Query || '').toLowerCase() + const existing = aggregated.get(key) + if (existing) { + existing.clicks += row.Clicks || 0 + existing.impressions += row.Impressions || 0 + existing.positionSum += + (row.AvgImpressionPosition || 0) * (row.Impressions || 0) + existing.count++ + } else { + aggregated.set(key, { + clicks: row.Clicks || 0, + impressions: row.Impressions || 0, + positionSum: + (row.AvgImpressionPosition || 0) * (row.Impressions || 0), + count: 1, + }) + } + } + + return Array.from(aggregated.entries()) + .map(([name, stats]) => ({ + name: name || '(not set)', + count: Math.round(stats.clicks), + impressions: Math.round(stats.impressions), + position: Number( + (stats.impressions > 0 + ? stats.positionSum / stats.impressions + : 0 + ).toFixed(2), + ), + ctr: Number( + (stats.impressions > 0 + ? (stats.clicks / stats.impressions) * 100 + : 0 + ).toFixed(2), + ), + })) + .sort((a, b) => b.count - a.count) + .slice(offset, offset + limit) + } catch (err) { + if (err instanceof BadRequestException) throw err + throw new InternalServerErrorException( + 'Failed to fetch keywords from Bing Webmaster Tools', + ) + } + } + + async getSummary( + pid: string, + from: string, + to: string, + ): Promise<{ + clicks: number + impressions: number + ctr: number + position: number + }> { + const siteUrl = await this.getSiteUrl(pid) + + try { + const data = await this.callBingApi<{ d: BwtRankTrafficRow[] }>( + pid, + 'GetRankAndTrafficStats', + { siteUrl }, + ) + + const filtered = this.filterByDateRange(data?.d || [], from, to) + + let totalClicks = 0 + let totalImpressions = 0 + + for (const row of filtered) { + totalClicks += row.Clicks || 0 + totalImpressions += row.Impressions || 0 + } + + // GetRankAndTrafficStats doesn't include position, so we use query stats + const queryData = await this.callBingApi<{ d: BwtQueryStatsRow[] }>( + pid, + 'GetQueryStats', + { siteUrl }, + ) + + const filteredQueries = this.filterByDateRange( + queryData?.d || [], + from, + to, + ) + + let positionWeightedSum = 0 + let positionTotalImpressions = 0 + + for (const row of filteredQueries) { + const imp = row.Impressions || 0 + positionWeightedSum += (row.AvgImpressionPosition || 0) * imp + positionTotalImpressions += imp + } + + const avgPosition = + positionTotalImpressions > 0 + ? positionWeightedSum / positionTotalImpressions + : 0 + + return { + clicks: Math.round(totalClicks), + impressions: Math.round(totalImpressions), + ctr: Number( + (totalImpressions > 0 + ? (totalClicks / totalImpressions) * 100 + : 0 + ).toFixed(2), + ), + position: Number(avgPosition.toFixed(1)), + } + } catch (err) { + if (err instanceof BadRequestException) throw err + throw new InternalServerErrorException( + 'Failed to fetch summary from Bing Webmaster Tools', + ) + } + } + + async getDateSeries( + pid: string, + from: string, + to: string, + ): Promise< + { + date: string + clicks: number + impressions: number + ctr: number + position: number + }[] + > { + const siteUrl = await this.getSiteUrl(pid) + + try { + const data = await this.callBingApi<{ d: BwtRankTrafficRow[] }>( + pid, + 'GetRankAndTrafficStats', + { siteUrl }, + ) + + const filtered = this.filterByDateRange(data?.d || [], from, to) + + return filtered.map((row) => { + const d = parseBingDate(row.Date) + const imp = row.Impressions || 0 + const clicks = row.Clicks || 0 + return { + date: d.format('YYYY-MM-DD'), + clicks: Math.round(clicks), + impressions: Math.round(imp), + ctr: Number((imp > 0 ? (clicks / imp) * 100 : 0).toFixed(2)), + position: 0, + } + }) + } catch (err) { + if (err instanceof BadRequestException) throw err + throw new InternalServerErrorException( + 'Failed to fetch date series from Bing Webmaster Tools', + ) + } + } + + async getTopPages( + pid: string, + from: string, + to: string, + limit = 50, + offset = 0, + ): Promise< + { + page: string + clicks: number + impressions: number + ctr: number + position: number + }[] + > { + const siteUrl = await this.getSiteUrl(pid) + + try { + const data = await this.callBingApi<{ d: BwtQueryStatsRow[] }>( + pid, + 'GetPageStats', + { siteUrl }, + ) + + const filtered = this.filterByDateRange(data?.d || [], from, to) + + const aggregated = new Map< + string, + { clicks: number; impressions: number; positionSum: number } + >() + + for (const row of filtered) { + const key = row.Query || '' + const existing = aggregated.get(key) + if (existing) { + existing.clicks += row.Clicks || 0 + existing.impressions += row.Impressions || 0 + existing.positionSum += + (row.AvgImpressionPosition || 0) * (row.Impressions || 0) + } else { + aggregated.set(key, { + clicks: row.Clicks || 0, + impressions: row.Impressions || 0, + positionSum: + (row.AvgImpressionPosition || 0) * (row.Impressions || 0), + }) + } + } + + return Array.from(aggregated.entries()) + .map(([page, stats]) => ({ + page: page || '(not set)', + clicks: Math.round(stats.clicks), + impressions: Math.round(stats.impressions), + ctr: Number( + (stats.impressions > 0 + ? (stats.clicks / stats.impressions) * 100 + : 0 + ).toFixed(2), + ), + position: Number( + (stats.impressions > 0 + ? stats.positionSum / stats.impressions + : 0 + ).toFixed(1), + ), + })) + .sort((a, b) => b.clicks - a.clicks) + .slice(offset, offset + limit) + } catch (err) { + if (err instanceof BadRequestException) throw err + throw new InternalServerErrorException( + 'Failed to fetch top pages from Bing Webmaster Tools', + ) + } + } + + async getBrandedTraffic( + pid: string, + from: string, + to: string, + ): Promise<{ branded: number; nonBranded: number }> { + const brandKeywords = await this.getProjectBrandKeywords(pid) + if (_isEmpty(brandKeywords)) { + return { branded: 0, nonBranded: 0 } + } + + const siteUrl = await this.getSiteUrl(pid) + + try { + const data = await this.callBingApi<{ d: BwtQueryStatsRow[] }>( + pid, + 'GetQueryStats', + { siteUrl }, + ) + + const filtered = this.filterByDateRange(data?.d || [], from, to) + + let branded = 0 + let nonBranded = 0 + + for (const row of filtered) { + const query = (row.Query || '').toLowerCase() + const clicks = row.Clicks || 0 + + if (brandKeywords.some((keyword) => query.includes(keyword))) { + branded += clicks + } else { + nonBranded += clicks + } + } + + return { + branded: Math.round(branded), + nonBranded: Math.round(nonBranded), + } + } catch (err) { + if (err instanceof BadRequestException) throw err + throw new InternalServerErrorException( + 'Failed to fetch branded traffic from Bing Webmaster Tools', + ) + } + } + + private async getProjectBrandKeywords(pid: string): Promise { + const project = await this.projectService.findOne({ + where: { id: pid }, + select: ['name', 'websiteUrl', 'brandKeywords'], + }) + + if (!project) return [] + + const parsed = parseBrandKeywords(project.brandKeywords) + if (parsed && parsed.length > 0) { + return Array.from( + new Set(parsed.map((k) => k.toLowerCase().trim()).filter(Boolean)), + ) + } + + const keywords = new Set() + + if (project.websiteUrl) { + try { + const { domainWithoutSuffix } = parseDomain(project.websiteUrl) + if (domainWithoutSuffix) { + keywords.add(domainWithoutSuffix.toLowerCase()) + } + } catch { + // + } + } + + if (project.name) { + const name = project.name.toLowerCase().trim() + if (name.length >= 3) { + keywords.add(name) + } + } + + return Array.from(keywords) + } + + async getDashboard( + pid: string, + from: string, + to: string, + timeBucket?: string, + filtersStr?: string, + ) { + const connected = await this.isConnected(pid) + if (!connected) { + return { notConnected: true } + } + + const tokens = await this.getStoredTokens(pid) + if (_isEmpty(tokens.site_url)) { + return { notConnected: true, noProperty: true } + } + + const fromDate = dayjs(from) + const toDate = dayjs(to) + const days = toDate.diff(fromDate, 'day') + 1 + const prevTo = fromDate.subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss') + const prevFrom = fromDate + .subtract(days, 'day') + .format('YYYY-MM-DD HH:mm:ss') + + try { + const [ + summary, + previousSummary, + dateSeries, + topPages, + topQueries, + brandedTraffic, + ] = await Promise.all([ + this.getSummary(pid, from, to), + this.getSummary(pid, prevFrom, prevTo).catch(() => null), + this.getDateSeries(pid, from, to), + this.getTopPages(pid, from, to, 50, 0), + this.getKeywords(pid, from, to, 50, 0), + this.getBrandedTraffic(pid, from, to), + ]) + + return { + notConnected: false, + summary, + previousSummary, + dateSeries, + topPages, + topQueries, + topCountries: [], + topDevices: [], + brandedTraffic, + } + } catch { + return { notConnected: true } + } + } +} diff --git a/backend/apps/cloud/src/project/entity/project.entity.ts b/backend/apps/cloud/src/project/entity/project.entity.ts index 6a84b3912..f8de4e453 100644 --- a/backend/apps/cloud/src/project/entity/project.entity.ts +++ b/backend/apps/cloud/src/project/entity/project.entity.ts @@ -157,6 +157,25 @@ export class Project { @Column('varchar', { nullable: true, default: null, length: 256 }) gscAccountEmail: string | null + // Bing Webmaster Tools integration + @Column('text', { nullable: true, default: null }) + bwtAccessTokenEnc: string | null + + @Column('text', { nullable: true, default: null }) + bwtRefreshTokenEnc: string | null + + @Column('bigint', { nullable: true, default: null }) + bwtTokenExpiry: string | null + + @Column('varchar', { nullable: true, default: null, length: 512 }) + bwtScope: string | null + + @Column('varchar', { nullable: true, default: null, length: 512 }) + bwtSiteUrl: string | null + + @Column('varchar', { nullable: true, default: null, length: 256 }) + bwtAccountEmail: string | null + // Revenue / Payment provider integration @Column('text', { nullable: true, default: null }) paddleApiKeyEnc: string | null diff --git a/backend/apps/cloud/src/project/gsc.service.ts b/backend/apps/cloud/src/project/gsc.service.ts index 192412842..99a0141e1 100644 --- a/backend/apps/cloud/src/project/gsc.service.ts +++ b/backend/apps/cloud/src/project/gsc.service.ts @@ -909,6 +909,7 @@ export class GSCService { return { notConnected: false, + sources: { google: true, bing: false }, summary, previousSummary, dateSeries, @@ -919,4 +920,162 @@ export class GSCService { brandedTraffic, } } + + static mergeDashboards(gscData: any, bwtData: any): any { + const gscConnected = gscData && !gscData.notConnected + const bwtConnected = bwtData && !bwtData.notConnected + + if (!gscConnected && !bwtConnected) { + return gscData || bwtData || { notConnected: true } + } + + if (!bwtConnected) return gscData + if (!gscConnected) { + return { ...bwtData, sources: { google: false, bing: true } } + } + + const mergeSummary = (a: any, b: any) => { + if (!a && !b) return null + if (!a) return b + if (!b) return a + + const clicks = (a.clicks || 0) + (b.clicks || 0) + const impressions = (a.impressions || 0) + (b.impressions || 0) + const totalImp = (a.impressions || 0) + (b.impressions || 0) + + return { + clicks, + impressions, + ctr: Number( + (impressions > 0 ? (clicks / impressions) * 100 : 0).toFixed(2), + ), + position: Number( + (totalImp > 0 + ? ((a.position || 0) * (a.impressions || 0) + + (b.position || 0) * (b.impressions || 0)) / + totalImp + : 0 + ).toFixed(1), + ), + } + } + + const mergeByKey = >( + aItems: T[], + bItems: T[], + keyField: string, + ): T[] => { + const map = new Map() + + for (const item of aItems) { + map.set(item[keyField], { ...item }) + } + + for (const item of bItems) { + const key = item[keyField] + const existing = map.get(key) + + if (existing) { + const totalImp = + (existing.impressions || 0) + (item.impressions || 0) + existing.clicks = (existing.clicks || 0) + (item.clicks || 0) + existing.count = (existing.count || 0) + (item.count || 0) + existing.impressions = totalImp + existing.ctr = Number( + (totalImp > 0 + ? (existing.clicks / totalImp) * 100 + : 0 + ).toFixed(2), + ) + existing.position = Number( + (totalImp > 0 + ? ((existing.position || 0) * + ((existing.impressions || 0) - (item.impressions || 0)) + + (item.position || 0) * (item.impressions || 0)) / + totalImp + : 0 + ).toFixed(1), + ) + } else { + map.set(key, { ...item }) + } + } + + return Array.from(map.values()) + } + + const mergeDateSeries = (a: any[], b: any[]): any[] => { + const map = new Map() + + for (const item of a) { + map.set(item.date, { ...item }) + } + + for (const item of b) { + const existing = map.get(item.date) + + if (existing) { + const totalClicks = (existing.clicks || 0) + (item.clicks || 0) + const totalImp = + (existing.impressions || 0) + (item.impressions || 0) + existing.clicks = totalClicks + existing.impressions = totalImp + existing.ctr = Number( + (totalImp > 0 ? (totalClicks / totalImp) * 100 : 0).toFixed(2), + ) + if (item.position) { + existing.position = Number( + (totalImp > 0 + ? ((existing.position || 0) * + ((existing.impressions || 0) - (item.impressions || 0)) + + (item.position || 0) * (item.impressions || 0)) / + totalImp + : existing.position + ).toFixed(1), + ) + } + } else { + map.set(item.date, { ...item }) + } + } + + return Array.from(map.values()).sort((x, y) => + x.date.localeCompare(y.date), + ) + } + + return { + notConnected: false, + sources: { google: true, bing: true }, + summary: mergeSummary(gscData.summary, bwtData.summary), + previousSummary: mergeSummary( + gscData.previousSummary, + bwtData.previousSummary, + ), + dateSeries: mergeDateSeries( + gscData.dateSeries || [], + bwtData.dateSeries || [], + ), + topPages: mergeByKey( + gscData.topPages || [], + bwtData.topPages || [], + 'page', + ), + topQueries: mergeByKey( + gscData.topQueries || [], + bwtData.topQueries || [], + 'name', + ), + topCountries: gscData.topCountries || [], + topDevices: gscData.topDevices || [], + brandedTraffic: { + branded: + (gscData.brandedTraffic?.branded || 0) + + (bwtData.brandedTraffic?.branded || 0), + nonBranded: + (gscData.brandedTraffic?.nonBranded || 0) + + (bwtData.brandedTraffic?.nonBranded || 0), + }, + } + } } diff --git a/backend/apps/cloud/src/project/project.module.ts b/backend/apps/cloud/src/project/project.module.ts index b4fb53d18..cf7888fca 100644 --- a/backend/apps/cloud/src/project/project.module.ts +++ b/backend/apps/cloud/src/project/project.module.ts @@ -5,6 +5,8 @@ import { ProjectService } from './project.service' import { ProjectController } from './project.controller' import { GSCController } from './gsc.controller' import { GSCService } from './gsc.service' +import { BWTController } from './bwt.controller' +import { BWTService } from './bwt.service' import { UserModule } from '../user/user.module' import { ActionTokensModule } from '../action-tokens/action-tokens.module' import { MailerModule } from '../mailer/mailer.module' @@ -42,8 +44,8 @@ import { PendingInvitationModule } from '../pending-invitation/pending-invitatio MailerModule, PendingInvitationModule, ], - providers: [ProjectService, ProjectsViewsRepository, GSCService], - exports: [ProjectService, ProjectsViewsRepository, GSCService], - controllers: [ProjectController, GSCController], + providers: [ProjectService, ProjectsViewsRepository, GSCService, BWTService], + exports: [ProjectService, ProjectsViewsRepository, GSCService, BWTService], + controllers: [ProjectController, GSCController, BWTController], }) export class ProjectModule {} diff --git a/backend/migrations/mysql/2026_04_14_bwt.sql b/backend/migrations/mysql/2026_04_14_bwt.sql new file mode 100644 index 000000000..95261a010 --- /dev/null +++ b/backend/migrations/mysql/2026_04_14_bwt.sql @@ -0,0 +1,7 @@ +ALTER TABLE `project` + ADD COLUMN `bwtAccessTokenEnc` text DEFAULT NULL, + ADD COLUMN `bwtRefreshTokenEnc` text DEFAULT NULL, + ADD COLUMN `bwtTokenExpiry` bigint DEFAULT NULL, + ADD COLUMN `bwtScope` varchar(512) DEFAULT NULL, + ADD COLUMN `bwtSiteUrl` varchar(512) DEFAULT NULL, + ADD COLUMN `bwtAccountEmail` varchar(256) DEFAULT NULL; diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 320e31c3a..7837bdd30 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -2386,6 +2386,21 @@ export async function processGSCTokenServer( }) } +// ============================================================================ +// MARK: Bing Webmaster Tools API +// ============================================================================ + +export async function processBWTTokenServer( + request: Request, + code: string, + state: string, +): Promise> { + return serverFetch<{ pid: string }>(request, 'v1/project/bwt/process-token', { + method: 'POST', + body: { code, state }, + }) +} + // ============================================================================ // MARK: Google Analytics 4 Import API // ============================================================================ @@ -2487,6 +2502,10 @@ interface GSCTopDeviceEntry { export interface GSCDashboardResponse { notConnected?: boolean noProperty?: boolean + sources?: { + google: boolean + bing: boolean + } summary?: { clicks: number impressions: number diff --git a/web/app/hooks/useAuthProxy.ts b/web/app/hooks/useAuthProxy.ts index ebefc494a..ab7a70ab7 100644 --- a/web/app/hooks/useAuthProxy.ts +++ b/web/app/hooks/useAuthProxy.ts @@ -135,6 +135,22 @@ export function useAuthProxy() { [], ) + const processBWTToken = useCallback( + async (code: string, state: string): Promise<{ pid: string }> => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'processBWTToken', code, state }), + }) + const result = (await response.json()) as ProxyResponse<{ pid: string }> + if (result.error || !result.data) { + throw new Error(result.error || 'Failed to process BWT token') + } + return result.data + }, + [], + ) + const processGA4ImportToken = useCallback( async (code: string, state: string): Promise<{ pid: string }> => { const response = await fetch('/api/auth', { @@ -207,6 +223,7 @@ export function useAuthProxy() { linkBySSOHash, processSSOToken, processGSCToken, + processBWTToken, processGA4ImportToken, authMe, linkSSOWithPassword, diff --git a/web/app/lib/models/Project.ts b/web/app/lib/models/Project.ts index 43dd33a87..db23ad4c4 100644 --- a/web/app/lib/models/Project.ts +++ b/web/app/lib/models/Project.ts @@ -233,6 +233,7 @@ export interface Project { botsProtectionLevel: 'off' | 'basic' role?: Role gscPropertyUri?: string | null + bwtSiteUrl?: string | null isPinned?: boolean revenueCurrency?: string websiteUrl?: string | null diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index 385cae096..226855659 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -494,6 +494,15 @@ const ProjectSettings = () => { >([]) const [gscEmail, setGscEmail] = useState(null) + // Bing Webmaster Tools integration state + const bwtFetcher = useFetcher() + const [bwtConnected, setBwtConnected] = useState(null) + const [bwtProperties, setBwtProperties] = useState< + { siteUrl: string; permissionLevel?: string }[] + >([]) + const [bwtEmail, setBwtEmail] = useState(null) + const [bwtSiteUrl, setBwtSiteUrl] = useState(null) + // CAPTCHA state const [captchaSecretKey, setCaptchaSecretKey] = useState( () => initialProject.captchaSecretKey || null, @@ -552,6 +561,10 @@ const ProjectSettings = () => { const lastHandledGscData = useRef(null) const gscInitialized = useRef(false) + const [bwtPropertiesPending, setBwtPropertiesPending] = useState(false) + const lastHandledBwtData = useRef(null) + const bwtInitialized = useRef(false) + // Handle GSC fetcher responses useEffect(() => { if (gscFetcher.state !== 'idle' || !gscFetcher.data) return @@ -639,6 +652,88 @@ const ProjectSettings = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Handle BWT fetcher responses + useEffect(() => { + if (bwtFetcher.state !== 'idle' || !bwtFetcher.data) return + if (lastHandledBwtData.current === bwtFetcher.data) return + lastHandledBwtData.current = bwtFetcher.data + + const { + intent, + success, + bwtStatus, + bwtProperties: properties, + bwtAuthUrl, + error: bwtError, + } = bwtFetcher.data as any + + if (success) { + if (intent === 'bwt-status' && bwtStatus) { + setBwtConnected(bwtStatus.connected) + setBwtEmail(bwtStatus.email || null) + if (bwtStatus.connected) { + setBwtPropertiesPending(true) + } else { + setBwtProperties([]) + } + } else if (intent === 'bwt-properties' && properties) { + setBwtProperties(properties) + setBwtPropertiesPending(false) + } else if (intent === 'bwt-connect' && bwtAuthUrl) { + const safeUrl = (() => { + try { + const parsed = new URL(bwtAuthUrl) + if (parsed.protocol !== 'https:') return null + if (parsed.username || parsed.password) return null + if (parsed.hostname !== 'www.bing.com') return null + return parsed.toString() + } catch { + return null + } + })() + + if (!safeUrl) { + toast.error(t('apiNotifications.somethingWentWrong')) + return + } + + window.location.href = safeUrl + } else if (intent === 'bwt-disconnect') { + setBwtConnected(false) + setBwtProperties([]) + setBwtEmail(null) + setBwtSiteUrl(null) + toast.success(t('project.settings.bwt.disconnected')) + } else if (intent === 'bwt-set-property') { + toast.success(t('project.settings.bwt.propertyConnected')) + } + } else if (bwtError) { + toast.error( + typeof bwtError === 'string' + ? bwtError + : t('apiNotifications.somethingWentWrong'), + ) + setBwtPropertiesPending(false) + } + }, [bwtFetcher.state, bwtFetcher.data, t]) + + // Fetch BWT properties after status confirms connected + useEffect(() => { + if (bwtPropertiesPending && bwtFetcher.state === 'idle') { + setBwtPropertiesPending(false) + bwtFetcher.submit({ intent: 'bwt-properties' }, { method: 'post' }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bwtPropertiesPending, bwtFetcher.state]) + + // Initial BWT status fetch + useEffect(() => { + if (isSelfhosted || bwtInitialized.current) return + bwtInitialized.current = true + bwtFetcher.submit({ intent: 'bwt-status' }, { method: 'post' }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // Handle fetcher responses useEffect(() => { if (fetcher.data?.success) { @@ -1239,6 +1334,151 @@ const ProjectSettings = () => { )} + +
+

+ + + + {t('project.settings.bwt.title')} +

+ {bwtConnected === null ? ( + + ) : !bwtConnected ? ( +
+

+ {t('project.settings.bwt.connect')} +

+ +
+ ) : ( +
+ + + + +