SEO/AEO, Farb schema, breadcrumbs
This commit is contained in:
51
bizmatch-server/src/sitemap/sitemap.controller.ts
Normal file
51
bizmatch-server/src/sitemap/sitemap.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Controller, Get, Header, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Controller()
|
||||
export class SitemapController {
|
||||
constructor(private readonly sitemapService: SitemapService) {}
|
||||
|
||||
/**
|
||||
* Main sitemap index - lists all sitemap files
|
||||
* Route: /sitemap.xml
|
||||
*/
|
||||
@Get('sitemap.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getSitemapIndex(): Promise<string> {
|
||||
return await this.sitemapService.generateSitemapIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Static pages sitemap
|
||||
* Route: /sitemap/static.xml
|
||||
*/
|
||||
@Get('sitemap/static.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getStaticSitemap(): Promise<string> {
|
||||
return await this.sitemapService.generateStaticSitemap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Business listings sitemap (paginated)
|
||||
* Route: /sitemap/business-1.xml, /sitemap/business-2.xml, etc.
|
||||
*/
|
||||
@Get('sitemap/business-:page.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getBusinessSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||
return await this.sitemapService.generateBusinessSitemap(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commercial property sitemap (paginated)
|
||||
* Route: /sitemap/commercial-1.xml, /sitemap/commercial-2.xml, etc.
|
||||
*/
|
||||
@Get('sitemap/commercial-:page.xml')
|
||||
@Header('Content-Type', 'application/xml')
|
||||
@Header('Cache-Control', 'public, max-age=3600')
|
||||
async getCommercialSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||
return await this.sitemapService.generateCommercialSitemap(page);
|
||||
}
|
||||
}
|
||||
12
bizmatch-server/src/sitemap/sitemap.module.ts
Normal file
12
bizmatch-server/src/sitemap/sitemap.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
import { DrizzleModule } from '../drizzle/drizzle.module';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule],
|
||||
controllers: [SitemapController],
|
||||
providers: [SitemapService],
|
||||
exports: [SitemapService],
|
||||
})
|
||||
export class SitemapModule {}
|
||||
292
bizmatch-server/src/sitemap/sitemap.service.ts
Normal file
292
bizmatch-server/src/sitemap/sitemap.service.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { PG_CONNECTION } from '../drizzle/schema';
|
||||
|
||||
interface SitemapUrl {
|
||||
loc: string;
|
||||
lastmod?: string;
|
||||
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
interface SitemapIndexEntry {
|
||||
loc: string;
|
||||
lastmod?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
private readonly baseUrl = 'https://biz-match.com';
|
||||
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
|
||||
|
||||
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) {}
|
||||
|
||||
/**
|
||||
* Generate sitemap index (main sitemap.xml)
|
||||
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
|
||||
*/
|
||||
async generateSitemapIndex(): Promise<string> {
|
||||
const sitemaps: SitemapIndexEntry[] = [];
|
||||
|
||||
// Add static pages sitemap
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/sitemap/static.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
|
||||
// Count business listings
|
||||
const businessCount = await this.getBusinessListingsCount();
|
||||
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP);
|
||||
for (let page = 1; page <= businessPages; page++) {
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/sitemap/business-${page}.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
// Count commercial property listings
|
||||
const commercialCount = await this.getCommercialPropertiesCount();
|
||||
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP);
|
||||
for (let page = 1; page <= commercialPages; page++) {
|
||||
sitemaps.push({
|
||||
loc: `${this.baseUrl}/sitemap/commercial-${page}.xml`,
|
||||
lastmod: this.formatDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
return this.buildXmlSitemapIndex(sitemaps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate static pages sitemap
|
||||
*/
|
||||
async generateStaticSitemap(): Promise<string> {
|
||||
const urls = this.getStaticPageUrls();
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate business listings sitemap (paginated)
|
||||
*/
|
||||
async generateBusinessSitemap(page: number): Promise<string> {
|
||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate commercial property sitemap (paginated)
|
||||
*/
|
||||
async generateCommercialSitemap(page: number): Promise<string> {
|
||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
|
||||
return this.buildXmlSitemap(urls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XML sitemap index
|
||||
*/
|
||||
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
|
||||
const sitemapElements = sitemaps
|
||||
.map(sitemap => {
|
||||
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
|
||||
if (sitemap.lastmod) {
|
||||
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
|
||||
}
|
||||
element += '\n </sitemap>';
|
||||
return element;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${sitemapElements}
|
||||
</sitemapindex>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XML sitemap string
|
||||
*/
|
||||
private buildXmlSitemap(urls: SitemapUrl[]): string {
|
||||
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urlElements}
|
||||
</urlset>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build single URL element
|
||||
*/
|
||||
private buildUrlElement(url: SitemapUrl): string {
|
||||
let element = `<url>\n <loc>${url.loc}</loc>`;
|
||||
|
||||
if (url.lastmod) {
|
||||
element += `\n <lastmod>${url.lastmod}</lastmod>`;
|
||||
}
|
||||
|
||||
if (url.changefreq) {
|
||||
element += `\n <changefreq>${url.changefreq}</changefreq>`;
|
||||
}
|
||||
|
||||
if (url.priority !== undefined) {
|
||||
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
|
||||
}
|
||||
|
||||
element += '\n </url>';
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get static page URLs
|
||||
*/
|
||||
private getStaticPageUrls(): SitemapUrl[] {
|
||||
return [
|
||||
{
|
||||
loc: `${this.baseUrl}/`,
|
||||
changefreq: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/home`,
|
||||
changefreq: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/businessListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/commercialPropertyListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/brokerListings`,
|
||||
changefreq: 'daily',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/terms-of-use`,
|
||||
changefreq: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
loc: `${this.baseUrl}/privacy-statement`,
|
||||
changefreq: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count business listings (non-draft)
|
||||
*/
|
||||
private async getBusinessListingsCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.businesses_json)
|
||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error counting business listings:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commercial properties (non-draft)
|
||||
*/
|
||||
private async getCommercialPropertiesCount(): Promise<number> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.commercials_json)
|
||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error counting commercial properties:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get business listing URLs from database (paginated, slug-based)
|
||||
*/
|
||||
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||
try {
|
||||
const listings = await this.db
|
||||
.select({
|
||||
id: schema.businesses_json.id,
|
||||
slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
|
||||
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
|
||||
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
|
||||
})
|
||||
.from(schema.businesses_json)
|
||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return listings.map(listing => {
|
||||
const urlSlug = listing.slug || listing.id;
|
||||
return {
|
||||
loc: `${this.baseUrl}/business/${urlSlug}`,
|
||||
lastmod: this.formatDate(listing.updated || listing.created),
|
||||
changefreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching business listings for sitemap:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commercial property URLs from database (paginated, slug-based)
|
||||
*/
|
||||
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||
try {
|
||||
const properties = await this.db
|
||||
.select({
|
||||
id: schema.commercials_json.id,
|
||||
slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
|
||||
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
|
||||
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
|
||||
})
|
||||
.from(schema.commercials_json)
|
||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return properties.map(property => {
|
||||
const urlSlug = property.slug || property.id;
|
||||
return {
|
||||
loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
|
||||
lastmod: this.formatDate(property.updated || property.created),
|
||||
changefreq: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching commercial properties for sitemap:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to ISO 8601 format (YYYY-MM-DD)
|
||||
*/
|
||||
private formatDate(date: Date | string): string {
|
||||
if (!date) return new Date().toISOString().split('T')[0];
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user