|
|
@@ -1,5 +1,6 @@
|
|
|
-import { ScraperResult, PageContent } from '../types';
|
|
|
+import { ScraperResult, PageContent, ContentType, ALL_CONTENT_TYPES } from '../types';
|
|
|
import { SitemapParser, resolveSitemapUrl } from './SitemapParser';
|
|
|
+import { LinkDiscovery } from './LinkDiscovery';
|
|
|
import { ContentExtractor } from './ContentExtractor';
|
|
|
import { logger } from '../utils/logger';
|
|
|
import { SiteDatabase } from '../database/Database';
|
|
|
@@ -56,7 +57,61 @@ export class WebScraper {
|
|
|
}
|
|
|
|
|
|
// Parse sitemap
|
|
|
- const urls = await SitemapParser.parseSitemap(sitemapUrl);
|
|
|
+ let urls: any[] = [];
|
|
|
+ let sitemapAvailable = true;
|
|
|
+ try {
|
|
|
+ urls = await SitemapParser.parseSitemap(sitemapUrl);
|
|
|
+ } catch (error) {
|
|
|
+ logger.warn(`Failed to parse sitemap at ${sitemapUrl}: ${error}`);
|
|
|
+ urls = [];
|
|
|
+ sitemapAvailable = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Run link discovery to supplement (or replace) sitemap URLs
|
|
|
+ const discovery = new LinkDiscovery();
|
|
|
+ let maxDepth = 3;
|
|
|
+ let maxPages = 200;
|
|
|
+ if (this.db && siteId) {
|
|
|
+ const siteRecord = this.db.getSiteByAnyId(siteId);
|
|
|
+ if (siteRecord) {
|
|
|
+ maxDepth = siteRecord.max_crawl_depth ?? 3;
|
|
|
+ maxPages = siteRecord.max_pages ?? 200;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (urls.length === 0) {
|
|
|
+ logger.info('No sitemap found, using link discovery as primary URL source');
|
|
|
+ }
|
|
|
+
|
|
|
+ const discoveredUrls = await discovery.discoverLinks(baseUrl, maxDepth, maxPages);
|
|
|
+
|
|
|
+ // Merge sitemap URLs with discovered URLs (deduplicate by URL string)
|
|
|
+ const existingUrlSet = new Set(urls.map((u: any) => u.loc));
|
|
|
+ for (const discovered of discoveredUrls) {
|
|
|
+ if (!existingUrlSet.has(discovered.url)) {
|
|
|
+ urls.push({
|
|
|
+ loc: discovered.url,
|
|
|
+ lastmod: undefined,
|
|
|
+ changefreq: undefined,
|
|
|
+ priority: undefined
|
|
|
+ });
|
|
|
+ existingUrlSet.add(discovered.url);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (discoveredUrls.length > 0) {
|
|
|
+ logger.info(`Link discovery found ${discoveredUrls.length} URLs, ${urls.length} total after merge with sitemap`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Remove excluded URLs before classification
|
|
|
+ if (this.db && siteId) {
|
|
|
+ const beforeCount = urls.length;
|
|
|
+ urls = urls.filter((u: any) => !this.db!.isUrlExcluded(siteId!, u.loc));
|
|
|
+ const excludedCount = beforeCount - urls.length;
|
|
|
+ if (excludedCount > 0) {
|
|
|
+ logger.info(`Excluded ${excludedCount} URLs via URL exclusion rules`);
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
// Get the first URL to extract changefreq and lastmod for scheduling
|
|
|
const firstUrl = urls.length > 0 ? urls[0] : null;
|
|
|
@@ -71,10 +126,9 @@ export class WebScraper {
|
|
|
|
|
|
// Remove disabled URLs from each category
|
|
|
if (disabledUrlSet.size > 0) {
|
|
|
- filteredUrls.shipping = filteredUrls.shipping.filter((u: any) => !disabledUrlSet.has(u.loc));
|
|
|
- filteredUrls.contacts = filteredUrls.contacts.filter((u: any) => !disabledUrlSet.has(u.loc));
|
|
|
- filteredUrls.terms = filteredUrls.terms.filter((u: any) => !disabledUrlSet.has(u.loc));
|
|
|
- filteredUrls.faq = filteredUrls.faq.filter((u: any) => !disabledUrlSet.has(u.loc));
|
|
|
+ for (const ct of ALL_CONTENT_TYPES) {
|
|
|
+ filteredUrls[ct] = filteredUrls[ct].filter((u: any) => !disabledUrlSet.has(u.loc));
|
|
|
+ }
|
|
|
logger.info(`Filtered out ${disabledUrlSet.size} disabled URLs from scraping`);
|
|
|
}
|
|
|
}
|
|
|
@@ -84,23 +138,18 @@ export class WebScraper {
|
|
|
const customUrls = this.db.getEnabledCustomUrls(siteId);
|
|
|
|
|
|
// Group custom URLs by content type
|
|
|
- const customUrlsByType: { [key: string]: any[] } = {
|
|
|
- shipping: [],
|
|
|
- contacts: [],
|
|
|
- terms: [],
|
|
|
- faq: []
|
|
|
- };
|
|
|
+ const customUrlsByType: Record<string, any[]> = {};
|
|
|
+ for (const ct of ALL_CONTENT_TYPES) {
|
|
|
+ customUrlsByType[ct] = [];
|
|
|
+ }
|
|
|
|
|
|
for (const customUrl of customUrls) {
|
|
|
- const contentTypeKey = customUrl.content_type === 'shipping' ? 'shipping' :
|
|
|
- customUrl.content_type === 'contacts' ? 'contacts' :
|
|
|
- customUrl.content_type === 'terms' ? 'terms' :
|
|
|
- 'faq';
|
|
|
+ const contentTypeKey = customUrl.content_type as ContentType;
|
|
|
|
|
|
// Check if this URL already exists in sitemap URLs
|
|
|
- const alreadyExists = filteredUrls[contentTypeKey].some((u: any) => u.loc === customUrl.url);
|
|
|
+ const alreadyExists = filteredUrls[contentTypeKey]?.some((u: any) => u.loc === customUrl.url);
|
|
|
|
|
|
- if (!alreadyExists) {
|
|
|
+ if (!alreadyExists && customUrlsByType[contentTypeKey]) {
|
|
|
// Add custom URL in the same format as sitemap URLs
|
|
|
customUrlsByType[contentTypeKey].push({
|
|
|
loc: customUrl.url,
|
|
|
@@ -115,168 +164,63 @@ export class WebScraper {
|
|
|
}
|
|
|
|
|
|
// Merge custom URLs with sitemap URLs
|
|
|
- filteredUrls.shipping = [...filteredUrls.shipping, ...customUrlsByType.shipping];
|
|
|
- filteredUrls.contacts = [...filteredUrls.contacts, ...customUrlsByType.contacts];
|
|
|
- filteredUrls.terms = [...filteredUrls.terms, ...customUrlsByType.terms];
|
|
|
- filteredUrls.faq = [...filteredUrls.faq, ...customUrlsByType.faq];
|
|
|
+ for (const ct of ALL_CONTENT_TYPES) {
|
|
|
+ filteredUrls[ct] = [...filteredUrls[ct], ...customUrlsByType[ct]];
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- logger.info('Filtered URLs', {
|
|
|
- shipping: filteredUrls.shipping.length,
|
|
|
- contacts: filteredUrls.contacts.length,
|
|
|
- terms: filteredUrls.terms.length,
|
|
|
- faq: filteredUrls.faq.length
|
|
|
- });
|
|
|
+ const urlCounts: Record<string, number> = {};
|
|
|
+ for (const ct of ALL_CONTENT_TYPES) {
|
|
|
+ urlCounts[ct] = filteredUrls[ct].length;
|
|
|
+ }
|
|
|
+ logger.info('Filtered URLs', urlCounts);
|
|
|
|
|
|
// Extract content from each page type - now supporting multiple pages per category
|
|
|
- const shippingContent = await this.extractAllPageContent(filteredUrls.shipping);
|
|
|
- const contactsContent = await this.extractAllPageContent(filteredUrls.contacts);
|
|
|
- const termsContent = await this.extractAllPageContent(filteredUrls.terms);
|
|
|
- const faqContent = await this.extractAllPageContent(filteredUrls.faq);
|
|
|
+ const contentByType: Record<ContentType, PageContent[]> = {} as any;
|
|
|
+ for (const ct of ALL_CONTENT_TYPES) {
|
|
|
+ contentByType[ct] = await this.extractAllPageContent(filteredUrls[ct]);
|
|
|
+ }
|
|
|
|
|
|
// Save content to database if available
|
|
|
if (this.db && siteId) {
|
|
|
// Collect all content for batch embedding processing
|
|
|
const embeddingBatch: any[] = [];
|
|
|
|
|
|
- // Save all shipping pages
|
|
|
- for (const content of shippingContent) {
|
|
|
- const result = this.db.saveContent(siteId, 'shipping', content.url, content.content, content.title);
|
|
|
-
|
|
|
- // Skip embedding and webhooks if content was skipped (duplicate hash in another category)
|
|
|
- if (result.skipped) continue;
|
|
|
-
|
|
|
- // Add to embedding batch if workflow is available
|
|
|
- if (this.embeddingWorkflow) {
|
|
|
- embeddingBatch.push({
|
|
|
- contentId: result.contentId,
|
|
|
- contentType: 'shipping',
|
|
|
- url: content.url,
|
|
|
- content: content.content,
|
|
|
- title: content.title,
|
|
|
- contentHash: this.calculateContentHash(content.content),
|
|
|
- contentChanged: result.changed
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // Trigger webhook if content changed
|
|
|
- if (result.changed && this.webhookManager) {
|
|
|
- const previous = this.db.getContentHistory(siteId, 'shipping', 2);
|
|
|
- const oldHash = previous.length > 1 ? previous[1].content_hash : '';
|
|
|
- const newHash = previous.length > 0 ? previous[0].content_hash : '';
|
|
|
-
|
|
|
- await this.webhookManager.notifyContentChanged(
|
|
|
- siteId,
|
|
|
- 'shipping',
|
|
|
- content.url,
|
|
|
- oldHash,
|
|
|
- newHash
|
|
|
- );
|
|
|
- }
|
|
|
- }
|
|
|
- // Save all contact pages
|
|
|
- for (const content of contactsContent) {
|
|
|
- const result = this.db.saveContent(siteId, 'contacts', content.url, content.content, content.title);
|
|
|
-
|
|
|
- // Skip embedding and webhooks if content was skipped (duplicate hash in another category)
|
|
|
- if (result.skipped) continue;
|
|
|
-
|
|
|
- // Add to embedding batch if workflow is available
|
|
|
- if (this.embeddingWorkflow) {
|
|
|
- embeddingBatch.push({
|
|
|
- contentId: result.contentId,
|
|
|
- contentType: 'contacts',
|
|
|
- url: content.url,
|
|
|
- content: content.content,
|
|
|
- title: content.title,
|
|
|
- contentHash: this.calculateContentHash(content.content),
|
|
|
- contentChanged: result.changed
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // Trigger webhook if content changed
|
|
|
- if (result.changed && this.webhookManager) {
|
|
|
- const previous = this.db.getContentHistory(siteId, 'contacts', 2);
|
|
|
- const oldHash = previous.length > 1 ? previous[1].content_hash : '';
|
|
|
- const newHash = previous.length > 0 ? previous[0].content_hash : '';
|
|
|
-
|
|
|
- await this.webhookManager.notifyContentChanged(
|
|
|
- siteId,
|
|
|
- 'contacts',
|
|
|
- content.url,
|
|
|
- oldHash,
|
|
|
- newHash
|
|
|
- );
|
|
|
- }
|
|
|
- }
|
|
|
- // Save all terms pages
|
|
|
- for (const content of termsContent) {
|
|
|
- const result = this.db.saveContent(siteId, 'terms', content.url, content.content, content.title);
|
|
|
-
|
|
|
- // Skip embedding and webhooks if content was skipped (duplicate hash in another category)
|
|
|
- if (result.skipped) continue;
|
|
|
-
|
|
|
- // Add to embedding batch if workflow is available
|
|
|
- if (this.embeddingWorkflow) {
|
|
|
- embeddingBatch.push({
|
|
|
- contentId: result.contentId,
|
|
|
- contentType: 'terms',
|
|
|
- url: content.url,
|
|
|
- content: content.content,
|
|
|
- title: content.title,
|
|
|
- contentHash: this.calculateContentHash(content.content),
|
|
|
- contentChanged: result.changed
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // Trigger webhook if content changed
|
|
|
- if (result.changed && this.webhookManager) {
|
|
|
- const previous = this.db.getContentHistory(siteId, 'terms', 2);
|
|
|
- const oldHash = previous.length > 1 ? previous[1].content_hash : '';
|
|
|
- const newHash = previous.length > 0 ? previous[0].content_hash : '';
|
|
|
-
|
|
|
- await this.webhookManager.notifyContentChanged(
|
|
|
- siteId,
|
|
|
- 'terms',
|
|
|
- content.url,
|
|
|
- oldHash,
|
|
|
- newHash
|
|
|
- );
|
|
|
- }
|
|
|
- }
|
|
|
- // Save all FAQ pages
|
|
|
- for (const content of faqContent) {
|
|
|
- const result = this.db.saveContent(siteId, 'faq', content.url, content.content, content.title);
|
|
|
-
|
|
|
- // Skip embedding and webhooks if content was skipped (duplicate hash in another category)
|
|
|
- if (result.skipped) continue;
|
|
|
-
|
|
|
- // Add to embedding batch if workflow is available
|
|
|
- if (this.embeddingWorkflow) {
|
|
|
- embeddingBatch.push({
|
|
|
- contentId: result.contentId,
|
|
|
- contentType: 'faq',
|
|
|
- url: content.url,
|
|
|
- content: content.content,
|
|
|
- title: content.title,
|
|
|
- contentHash: this.calculateContentHash(content.content),
|
|
|
- contentChanged: result.changed
|
|
|
- });
|
|
|
- }
|
|
|
+ // Save all pages for each content type
|
|
|
+ for (const ct of ALL_CONTENT_TYPES) {
|
|
|
+ for (const content of contentByType[ct]) {
|
|
|
+ const result = this.db.saveContent(siteId, ct, content.url, content.content, content.title);
|
|
|
+
|
|
|
+ // Skip embedding and webhooks if content was skipped (duplicate hash in another category)
|
|
|
+ if (result.skipped) continue;
|
|
|
+
|
|
|
+ // Add to embedding batch if workflow is available
|
|
|
+ if (this.embeddingWorkflow) {
|
|
|
+ embeddingBatch.push({
|
|
|
+ contentId: result.contentId,
|
|
|
+ contentType: ct,
|
|
|
+ url: content.url,
|
|
|
+ content: content.content,
|
|
|
+ title: content.title,
|
|
|
+ contentHash: this.calculateContentHash(content.content),
|
|
|
+ contentChanged: result.changed
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
- // Trigger webhook if content changed
|
|
|
- if (result.changed && this.webhookManager) {
|
|
|
- const previous = this.db.getContentHistory(siteId, 'faq', 2);
|
|
|
- const oldHash = previous.length > 1 ? previous[1].content_hash : '';
|
|
|
- const newHash = previous.length > 0 ? previous[0].content_hash : '';
|
|
|
-
|
|
|
- await this.webhookManager.notifyContentChanged(
|
|
|
- siteId,
|
|
|
- 'faq',
|
|
|
- content.url,
|
|
|
- oldHash,
|
|
|
- newHash
|
|
|
- );
|
|
|
+ // Trigger webhook if content changed
|
|
|
+ if (result.changed && this.webhookManager) {
|
|
|
+ const previous = this.db.getContentHistory(siteId, ct, 2);
|
|
|
+ const oldHash = previous.length > 1 ? previous[1].content_hash : '';
|
|
|
+ const newHash = previous.length > 0 ? previous[0].content_hash : '';
|
|
|
+
|
|
|
+ await this.webhookManager.notifyContentChanged(
|
|
|
+ siteId,
|
|
|
+ ct,
|
|
|
+ content.url,
|
|
|
+ oldHash,
|
|
|
+ newHash
|
|
|
+ );
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -303,7 +247,10 @@ export class WebScraper {
|
|
|
|
|
|
// Update analytics
|
|
|
const scrapeTimeMs = Date.now() - startTime;
|
|
|
- const pageCount = shippingContent.length + contactsContent.length + termsContent.length + faqContent.length;
|
|
|
+ let pageCount = 0;
|
|
|
+ for (const ct of ALL_CONTENT_TYPES) {
|
|
|
+ pageCount += contentByType[ct].length;
|
|
|
+ }
|
|
|
|
|
|
this.db.updateAnalytics(siteId, {
|
|
|
scrapeTimeMs,
|
|
|
@@ -321,10 +268,19 @@ export class WebScraper {
|
|
|
|
|
|
const result: ScraperResult = {
|
|
|
initial_sitemap: sitemapUrl,
|
|
|
- shipping_informations: shippingContent,
|
|
|
- contacts: contactsContent,
|
|
|
- terms_of_conditions: termsContent,
|
|
|
- faq: faqContent
|
|
|
+ shipping_informations: contentByType.shipping,
|
|
|
+ contacts: contentByType.contacts,
|
|
|
+ terms_of_conditions: contentByType.terms,
|
|
|
+ faq: contentByType.faq,
|
|
|
+ services: contentByType.services,
|
|
|
+ about: contentByType.about,
|
|
|
+ team: contentByType.team,
|
|
|
+ blog: contentByType.blog,
|
|
|
+ pricing: contentByType.pricing,
|
|
|
+ testimonials: contentByType.testimonials,
|
|
|
+ gallery: contentByType.gallery,
|
|
|
+ landing: contentByType.landing,
|
|
|
+ other: contentByType.other
|
|
|
};
|
|
|
|
|
|
logger.info('Scraping completed successfully');
|