Jelajahi Sumber

feat: add web UI panels, MCP tools for 13 categories, site type badges

Phase 4 of universal scraper enhancement:
- Custom URLs panel with include/exclude management on site detail page
- Crawl settings panel (sitemap URL, max depth, max pages) on site detail
- Content metadata grid showing all 13 categories with page counts
- Site type badges (Webshop/Website) on sites list page
- 9 new MCP search tools for new categories (services, about, team, blog,
  pricing, testimonials, gallery, landing, other) — 14 tools total
fszontagh 3 bulan lalu
induk
melakukan
f343539394

+ 81 - 0
src/mcp/McpServer.ts

@@ -12,6 +12,15 @@ import { ContactsSearchTool } from './tools/ContactsSearchTool';
 import { TermsSearchTool } from './tools/TermsSearchTool';
 import { TermsSearchTool } from './tools/TermsSearchTool';
 import { FaqSearchTool } from './tools/FaqSearchTool';
 import { FaqSearchTool } from './tools/FaqSearchTool';
 import { ListContentsTool } from './tools/ListContentsTool';
 import { ListContentsTool } from './tools/ListContentsTool';
+import { ServicesSearchTool } from './tools/ServicesSearchTool';
+import { AboutSearchTool } from './tools/AboutSearchTool';
+import { TeamSearchTool } from './tools/TeamSearchTool';
+import { BlogSearchTool } from './tools/BlogSearchTool';
+import { PricingSearchTool } from './tools/PricingSearchTool';
+import { TestimonialsSearchTool } from './tools/TestimonialsSearchTool';
+import { GallerySearchTool } from './tools/GallerySearchTool';
+import { LandingSearchTool } from './tools/LandingSearchTool';
+import { OtherSearchTool } from './tools/OtherSearchTool';
 import type { AppConfig } from '../config';
 import type { AppConfig } from '../config';
 
 
 export class McpServer {
 export class McpServer {
@@ -97,12 +106,84 @@ export class McpServer {
 
 
     const listContentsTool = new ListContentsTool(this.database);
     const listContentsTool = new ListContentsTool(this.database);
 
 
+    const servicesTool = new ServicesSearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
+    const aboutTool = new AboutSearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
+    const teamTool = new TeamSearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
+    const blogTool = new BlogSearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
+    const pricingTool = new PricingSearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
+    const testimonialsTool = new TestimonialsSearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
+    const galleryTool = new GallerySearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
+    const landingTool = new LandingSearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
+    const otherTool = new OtherSearchTool(
+      this.database,
+      this.qdrantService,
+      this.embeddingService,
+      this.mcpLogger
+    );
+
     // Register tools
     // Register tools
     this.tools.set('list_contents', listContentsTool);
     this.tools.set('list_contents', listContentsTool);
     this.tools.set('shipping_search', shippingTool);
     this.tools.set('shipping_search', shippingTool);
     this.tools.set('contacts_search', contactsTool);
     this.tools.set('contacts_search', contactsTool);
     this.tools.set('terms_search', termsTool);
     this.tools.set('terms_search', termsTool);
     this.tools.set('faq_search', faqTool);
     this.tools.set('faq_search', faqTool);
+    this.tools.set('services_search', servicesTool);
+    this.tools.set('about_search', aboutTool);
+    this.tools.set('team_search', teamTool);
+    this.tools.set('blog_search', blogTool);
+    this.tools.set('pricing_search', pricingTool);
+    this.tools.set('testimonials_search', testimonialsTool);
+    this.tools.set('gallery_search', galleryTool);
+    this.tools.set('landing_search', landingTool);
+    this.tools.set('other_search', otherTool);
 
 
     logger.info(`Registered ${this.tools.size} MCP tools`);
     logger.info(`Registered ${this.tools.size} MCP tools`);
   }
   }

+ 43 - 0
src/mcp/tools/AboutSearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class AboutSearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'about');
+  }
+
+  getDescription(): string {
+    return 'Search for company information, history, and mission';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for about information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 43 - 0
src/mcp/tools/BlogSearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class BlogSearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'blog');
+  }
+
+  getDescription(): string {
+    return 'Search blog posts, articles, and news content';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for blog information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 43 - 0
src/mcp/tools/GallerySearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class GallerySearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'gallery');
+  }
+
+  getDescription(): string {
+    return 'Search gallery, photos, case studies, and portfolio items';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for gallery information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 43 - 0
src/mcp/tools/LandingSearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class LandingSearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'landing');
+  }
+
+  getDescription(): string {
+    return 'Search promotional offers, campaigns, and special deals';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for landing information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 43 - 0
src/mcp/tools/OtherSearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class OtherSearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'other');
+  }
+
+  getDescription(): string {
+    return 'Search uncategorized content and miscellaneous pages';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for other information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 43 - 0
src/mcp/tools/PricingSearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class PricingSearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'pricing');
+  }
+
+  getDescription(): string {
+    return 'Search pricing, fees, packages, and plans';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for pricing information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 43 - 0
src/mcp/tools/ServicesSearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class ServicesSearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'services');
+  }
+
+  getDescription(): string {
+    return 'Search for services, treatments, and offerings information';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for services information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 43 - 0
src/mcp/tools/TeamSearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class TeamSearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'team');
+  }
+
+  getDescription(): string {
+    return 'Search for team members, staff profiles, and personnel';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for team information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 43 - 0
src/mcp/tools/TestimonialsSearchTool.ts

@@ -0,0 +1,43 @@
+import { BaseTool } from './BaseTool';
+import { SiteDatabase } from '../../database/Database';
+import { QdrantService } from '../../services/QdrantService';
+import { EmbeddingService } from '../../services/EmbeddingService';
+import { McpLogger } from '../McpLogger';
+
+export class TestimonialsSearchTool extends BaseTool {
+  constructor(
+    database: SiteDatabase,
+    qdrantService: QdrantService,
+    embeddingService: EmbeddingService,
+    mcpLogger: McpLogger
+  ) {
+    super(database, qdrantService, embeddingService, mcpLogger, 'testimonials');
+  }
+
+  getDescription(): string {
+    return 'Search testimonials, reviews, and customer feedback';
+  }
+
+  getInputSchema(): object {
+    return {
+      type: 'object',
+      properties: {
+        site_id: {
+          type: 'string',
+          description: 'The unique identifier of the website to search in',
+        },
+        query: {
+          type: 'string',
+          description: 'Search query for testimonials information',
+        },
+        limit: {
+          type: 'number',
+          description: 'Maximum number of results to return (1-20, default: 10)',
+          minimum: 1,
+          maximum: 20,
+        },
+      },
+      required: ['site_id', 'query'],
+    };
+  }
+}

+ 432 - 10
web/src/components/pages/SiteDetailPage.ts

@@ -1,16 +1,23 @@
 import { ApiService } from '../../services/ApiService';
 import { ApiService } from '../../services/ApiService';
 import { ToastService } from '../../services/ToastService';
 import { ToastService } from '../../services/ToastService';
 import { Router } from '../../app/Router';
 import { Router } from '../../app/Router';
-import { SiteDetail, QdrantEmbedding, QdrantError } from '../../types';
+import { SiteDetail, QdrantEmbedding, QdrantError, CustomUrl, UrlExclusion } from '../../types';
 import { getIcon } from '../../utils/icons';
 import { getIcon } from '../../utils/icons';
 import { formatRelativeTime, getDomainFromUrl, getStatusBadgeClass, getContentTypeLabel } from '../../utils/helpers';
 import { formatRelativeTime, getDomainFromUrl, getStatusBadgeClass, getContentTypeLabel } from '../../utils/helpers';
 
 
+const ALL_CONTENT_TYPES = [
+  'shipping', 'contacts', 'terms', 'faq', 'services', 'about', 'team',
+  'blog', 'pricing', 'testimonials', 'gallery', 'landing', 'other'
+] as const;
+
 export class SiteDetailPage {
 export class SiteDetailPage {
   private element: HTMLElement | null = null;
   private element: HTMLElement | null = null;
   private siteDetail: SiteDetail | null = null;
   private siteDetail: SiteDetail | null = null;
   private eventsbound: boolean = false;
   private eventsbound: boolean = false;
   private qdrantEmbeddings: QdrantEmbedding[] = [];
   private qdrantEmbeddings: QdrantEmbedding[] = [];
   private qdrantErrors: QdrantError[] = [];
   private qdrantErrors: QdrantError[] = [];
+  private customUrls: CustomUrl[] = [];
+  private urlExclusions: UrlExclusion[] = [];
 
 
   constructor(
   constructor(
     private siteId: string,
     private siteId: string,
@@ -45,10 +52,12 @@ export class SiteDetailPage {
       if (siteResponse.success && siteResponse.data) {
       if (siteResponse.success && siteResponse.data) {
         this.siteDetail = siteResponse.data;
         this.siteDetail = siteResponse.data;
 
 
-        // Load Qdrant data in parallel (optional - these can fail without breaking the page)
-        const [embeddingsResponse, errorsResponse] = await Promise.allSettled([
+        // Load Qdrant data, custom URLs, and exclusions in parallel (optional - these can fail without breaking the page)
+        const [embeddingsResponse, errorsResponse, customUrlsResponse, exclusionsResponse] = await Promise.allSettled([
           this.apiService.getSiteQdrantEmbeddings(this.siteId),
           this.apiService.getSiteQdrantEmbeddings(this.siteId),
-          this.apiService.getSiteQdrantErrors(this.siteId)
+          this.apiService.getSiteQdrantErrors(this.siteId),
+          this.apiService.getCustomUrls(this.siteId),
+          this.apiService.getUrlExclusions(this.siteId)
         ]);
         ]);
 
 
         // Handle embeddings response (won't break if it fails)
         // Handle embeddings response (won't break if it fails)
@@ -69,6 +78,22 @@ export class SiteDetailPage {
           console.warn('[SiteDetailPage] Errors fetch failed:', errorsResponse.reason);
           console.warn('[SiteDetailPage] Errors fetch failed:', errorsResponse.reason);
         }
         }
 
 
+        // Handle custom URLs response
+        if (customUrlsResponse.status === 'fulfilled' && customUrlsResponse.value.success && customUrlsResponse.value.data) {
+          const urlsData = (customUrlsResponse.value.data as any).data?.custom_urls || (customUrlsResponse.value.data as any).custom_urls || [];
+          this.customUrls = urlsData;
+        } else if (customUrlsResponse.status === 'rejected') {
+          console.warn('[SiteDetailPage] Custom URLs fetch failed:', customUrlsResponse.reason);
+        }
+
+        // Handle URL exclusions response
+        if (exclusionsResponse.status === 'fulfilled' && exclusionsResponse.value.success && exclusionsResponse.value.data) {
+          const exclData = (exclusionsResponse.value.data as any).data?.url_exclusions || (exclusionsResponse.value.data as any).url_exclusions || [];
+          this.urlExclusions = exclData;
+        } else if (exclusionsResponse.status === 'rejected') {
+          console.warn('[SiteDetailPage] URL exclusions fetch failed:', exclusionsResponse.reason);
+        }
+
         this.renderSiteDetail();
         this.renderSiteDetail();
       } else {
       } else {
         console.error('[SiteDetailPage] Site API returned error:', siteResponse.error);
         console.error('[SiteDetailPage] Site API returned error:', siteResponse.error);
@@ -251,6 +276,72 @@ export class SiteDetailPage {
         </div>
         </div>
       </div>
       </div>
 
 
+      <!-- Crawl Settings -->
+      <div class="card">
+        <div class="card-header">
+          <h3 class="text-lg font-medium text-gray-900 dark:text-white">Crawl Settings</h3>
+          <p class="text-sm text-gray-500 dark:text-gray-400">Configure crawling behavior for this site</p>
+        </div>
+        <div class="card-body">
+          <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
+            <div>
+              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                Sitemap URL
+              </label>
+              <div class="flex items-center space-x-2">
+                <input
+                  type="text"
+                  id="sitemap-url-input"
+                  value="${site.custom_sitemap_url || site.sitemap_url || ''}"
+                  placeholder="https://example.com/sitemap.xml"
+                  class="flex-1 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
+                />
+                <button
+                  type="button"
+                  id="redetect-sitemap"
+                  class="btn btn-secondary"
+                  title="Re-detect sitemap"
+                >
+                  Re-detect
+                </button>
+              </div>
+            </div>
+            <div>
+              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                Max Crawl Depth
+              </label>
+              <input
+                type="number"
+                id="max-crawl-depth-input"
+                value="${site.max_crawl_depth ?? 3}"
+                min="1"
+                max="5"
+                class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
+              />
+            </div>
+            <div>
+              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
+                Max Pages
+              </label>
+              <input
+                type="number"
+                id="max-pages-input"
+                value="${site.max_pages ?? 100}"
+                min="1"
+                max="1000"
+                class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
+              />
+            </div>
+          </div>
+          <div class="mt-4 flex justify-end">
+            <button type="button" id="save-crawl-settings" class="btn btn-primary">
+              <div class="w-4 h-4 mr-1">${getIcon('save')}</div>
+              Save Settings
+            </button>
+          </div>
+        </div>
+      </div>
+
       <!-- Content Overview -->
       <!-- Content Overview -->
       <div class="card">
       <div class="card">
         <div class="card-header">
         <div class="card-header">
@@ -258,13 +349,143 @@ export class SiteDetailPage {
           <p class="text-sm text-gray-500 dark:text-gray-400">Latest scraped content by category</p>
           <p class="text-sm text-gray-500 dark:text-gray-400">Latest scraped content by category</p>
         </div>
         </div>
         <div class="card-body">
         <div class="card-body">
-          <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
-            ${Object.entries(content_metadata).map(([key, items]) => `
-              <div class="text-center">
-                <div class="text-lg font-semibold text-gray-900 dark:text-white">${items.length}</div>
-                <div class="text-sm text-gray-500 dark:text-gray-400">${getContentTypeLabel(key)}</div>
+          <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
+            ${(['shipping', 'contacts', 'terms', 'faq', 'services', 'about', 'team', 'blog', 'pricing', 'testimonials', 'gallery', 'landing', 'other'] as const).map(key => {
+              const items = (content_metadata as unknown as Record<string, any[]>)[key] || [];
+              const count = items.length;
+              const muted = count === 0;
+              return `
+              <div class="rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center${muted ? ' opacity-40' : ''}">
+                <div class="text-lg font-semibold ${muted ? 'text-gray-400 dark:text-gray-600' : 'text-gray-900 dark:text-white'}">${count}</div>
+                <div class="text-sm ${muted ? 'text-gray-400 dark:text-gray-600' : 'text-gray-500 dark:text-gray-400'}">${getContentTypeLabel(key)}</div>
+              </div>`;
+            }).join('')}
+          </div>
+        </div>
+      </div>
+
+      <!-- Custom URLs -->
+      <div class="card">
+        <div class="card-header">
+          <h3 class="text-lg font-medium text-gray-900 dark:text-white">Custom URLs</h3>
+          <p class="text-sm text-gray-500 dark:text-gray-400">Manage include URLs and exclude patterns for crawling</p>
+        </div>
+        <div class="card-body space-y-6">
+          <!-- Include URLs -->
+          <div>
+            <div class="flex items-center justify-between mb-3">
+              <h4 class="text-sm font-medium text-gray-900 dark:text-white">Include URLs</h4>
+              <button type="button" id="toggle-add-url-form" class="btn btn-secondary btn-sm">
+                + Add URL
+              </button>
+            </div>
+            <div id="add-url-form" class="hidden mb-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
+              <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
+                <input
+                  type="text"
+                  id="new-custom-url"
+                  placeholder="https://example.com/page"
+                  class="px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
+                />
+                <select
+                  id="new-custom-url-type"
+                  class="px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
+                >
+                  ${ALL_CONTENT_TYPES.map(t => `<option value="${t}">${getContentTypeLabel(t)}</option>`).join('')}
+                </select>
+                <button type="button" id="add-custom-url-btn" class="btn btn-primary btn-sm">
+                  Add
+                </button>
               </div>
               </div>
-            `).join('')}
+            </div>
+            ${this.customUrls.length === 0 ? `
+              <p class="text-sm text-gray-500 dark:text-gray-400">No custom URLs configured.</p>
+            ` : `
+              <div class="overflow-x-auto">
+                <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
+                  <thead>
+                    <tr>
+                      <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">URL</th>
+                      <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Category</th>
+                      <th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Enabled</th>
+                      <th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
+                    </tr>
+                  </thead>
+                  <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
+                    ${this.customUrls.map(cu => `
+                      <tr>
+                        <td class="px-3 py-2 text-sm text-gray-900 dark:text-white break-all">${cu.url}</td>
+                        <td class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">${getContentTypeLabel(cu.content_type)}</td>
+                        <td class="px-3 py-2 text-center">
+                          <label class="inline-flex items-center cursor-pointer">
+                            <input type="checkbox" class="sr-only custom-url-toggle" data-id="${cu.id}" ${cu.enabled ? 'checked' : ''}>
+                            <div class="relative">
+                              <div class="block bg-gray-600 w-10 h-6 rounded-full"></div>
+                              <div class="dot absolute left-0.5 top-0.5 bg-white w-5 h-5 rounded-full transition ${cu.enabled ? 'transform translate-x-4 bg-blue-600' : ''}"></div>
+                            </div>
+                          </label>
+                        </td>
+                        <td class="px-3 py-2 text-center">
+                          <button type="button" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 delete-custom-url" data-id="${cu.id}">
+                            <div class="w-4 h-4">${getIcon('delete')}</div>
+                          </button>
+                        </td>
+                      </tr>
+                    `).join('')}
+                  </tbody>
+                </table>
+              </div>
+            `}
+          </div>
+
+          <!-- Exclude Patterns -->
+          <div>
+            <div class="flex items-center justify-between mb-3">
+              <h4 class="text-sm font-medium text-gray-900 dark:text-white">Exclude Patterns</h4>
+              <button type="button" id="toggle-add-exclusion-form" class="btn btn-secondary btn-sm">
+                + Add Pattern
+              </button>
+            </div>
+            <div id="add-exclusion-form" class="hidden mb-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
+              <div class="flex items-center space-x-3">
+                <input
+                  type="text"
+                  id="new-exclusion-pattern"
+                  placeholder="*/admin/*"
+                  class="flex-1 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
+                />
+                <button type="button" id="add-exclusion-btn" class="btn btn-primary btn-sm">
+                  Add
+                </button>
+              </div>
+              <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Use * as wildcard, e.g. */admin/*</p>
+            </div>
+            ${this.urlExclusions.length === 0 ? `
+              <p class="text-sm text-gray-500 dark:text-gray-400">No exclusion patterns configured.</p>
+            ` : `
+              <div class="overflow-x-auto">
+                <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
+                  <thead>
+                    <tr>
+                      <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Pattern</th>
+                      <th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
+                    </tr>
+                  </thead>
+                  <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
+                    ${this.urlExclusions.map(excl => `
+                      <tr>
+                        <td class="px-3 py-2 text-sm text-gray-900 dark:text-white font-mono">${excl.pattern}</td>
+                        <td class="px-3 py-2 text-center">
+                          <button type="button" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 delete-exclusion" data-id="${excl.id}">
+                            <div class="w-4 h-4">${getIcon('delete')}</div>
+                          </button>
+                        </td>
+                      </tr>
+                    `).join('')}
+                  </tbody>
+                </table>
+              </div>
+            `}
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
@@ -663,5 +884,206 @@ export class SiteDetailPage {
         this.toastService.error('Error', 'Failed to clear custom ID');
         this.toastService.error('Error', 'Failed to clear custom ID');
       }
       }
     });
     });
+
+    // --- Crawl Settings ---
+    this.element.querySelector('#save-crawl-settings')?.addEventListener('click', async () => {
+      if (!this.siteDetail) return;
+
+      const sitemapInput = this.element?.querySelector('#sitemap-url-input') as HTMLInputElement;
+      const depthInput = this.element?.querySelector('#max-crawl-depth-input') as HTMLInputElement;
+      const pagesInput = this.element?.querySelector('#max-pages-input') as HTMLInputElement;
+
+      const settings: {
+        custom_sitemap_url?: string | null;
+        max_crawl_depth?: number;
+        max_pages?: number;
+      } = {};
+
+      const sitemapVal = sitemapInput?.value.trim() || null;
+      settings.custom_sitemap_url = sitemapVal;
+
+      const depth = parseInt(depthInput?.value || '3', 10);
+      if (depth >= 1 && depth <= 5) {
+        settings.max_crawl_depth = depth;
+      }
+
+      const pages = parseInt(pagesInput?.value || '100', 10);
+      if (pages >= 1 && pages <= 1000) {
+        settings.max_pages = pages;
+      }
+
+      try {
+        const response = await this.apiService.updateSiteSettings(this.siteId, settings);
+        if (response.success) {
+          this.toastService.success('Settings Saved', 'Crawl settings have been updated');
+          await this.loadSiteDetail();
+        } else {
+          this.toastService.error('Save Failed', response.error || 'Failed to save crawl settings');
+        }
+      } catch (error) {
+        this.toastService.error('Error', 'Failed to save crawl settings');
+      }
+    });
+
+    // Re-detect sitemap
+    this.element.querySelector('#redetect-sitemap')?.addEventListener('click', async () => {
+      if (!this.siteDetail) return;
+
+      const btn = this.element?.querySelector('#redetect-sitemap') as HTMLButtonElement;
+      if (btn) { btn.disabled = true; btn.textContent = 'Detecting...'; }
+
+      try {
+        const response = await this.apiService.detectSite(this.siteDetail.site.url);
+        if (response.success && response.data) {
+          const detected = (response.data as any).data || response.data;
+          const sitemapInput = this.element?.querySelector('#sitemap-url-input') as HTMLInputElement;
+          if (sitemapInput && detected.sitemap_url) {
+            sitemapInput.value = detected.sitemap_url;
+          }
+          this.toastService.success('Detected', detected.sitemap_url ? `Sitemap found: ${detected.sitemap_url}` : 'No sitemap detected');
+        } else {
+          this.toastService.error('Detection Failed', response.error || 'Failed to detect sitemap');
+        }
+      } catch (error) {
+        this.toastService.error('Error', 'Failed to detect sitemap');
+      } finally {
+        if (btn) { btn.disabled = false; btn.textContent = 'Re-detect'; }
+      }
+    });
+
+    // --- Custom URLs ---
+    // Toggle add URL form
+    this.element.querySelector('#toggle-add-url-form')?.addEventListener('click', () => {
+      const form = this.element?.querySelector('#add-url-form');
+      form?.classList.toggle('hidden');
+    });
+
+    // Add custom URL
+    this.element.querySelector('#add-custom-url-btn')?.addEventListener('click', async () => {
+      const urlInput = this.element?.querySelector('#new-custom-url') as HTMLInputElement;
+      const typeSelect = this.element?.querySelector('#new-custom-url-type') as HTMLSelectElement;
+
+      const url = urlInput?.value.trim();
+      const contentType = typeSelect?.value;
+
+      if (!url) {
+        this.toastService.error('Validation Error', 'URL is required');
+        return;
+      }
+
+      try {
+        const response = await this.apiService.addCustomUrl(this.siteId, { url, content_type: contentType });
+        if (response.success) {
+          this.toastService.success('URL Added', 'Custom URL has been added');
+          this.eventsbound = false;
+          await this.loadSiteDetail();
+        } else {
+          this.toastService.error('Add Failed', response.error || 'Failed to add custom URL');
+        }
+      } catch (error) {
+        this.toastService.error('Error', 'Failed to add custom URL');
+      }
+    });
+
+    // Toggle custom URL enabled/disabled
+    this.element.querySelectorAll('.custom-url-toggle').forEach(toggle => {
+      toggle.addEventListener('change', async (e) => {
+        const checkbox = e.target as HTMLInputElement;
+        const customUrlId = checkbox.dataset.id;
+        if (!customUrlId) return;
+
+        try {
+          const response = await this.apiService.updateCustomUrl(this.siteId, customUrlId, checkbox.checked);
+          if (response.success) {
+            this.toastService.success('Updated', `Custom URL ${checkbox.checked ? 'enabled' : 'disabled'}`);
+          } else {
+            checkbox.checked = !checkbox.checked;
+            this.toastService.error('Update Failed', response.error || 'Failed to toggle custom URL');
+          }
+        } catch (error) {
+          checkbox.checked = !checkbox.checked;
+          this.toastService.error('Error', 'Failed to toggle custom URL');
+        }
+      });
+    });
+
+    // Delete custom URL
+    this.element.querySelectorAll('.delete-custom-url').forEach(btn => {
+      btn.addEventListener('click', async (e) => {
+        const button = (e.currentTarget as HTMLElement);
+        const customUrlId = button.dataset.id;
+        if (!customUrlId) return;
+
+        if (!confirm('Delete this custom URL?')) return;
+
+        try {
+          const response = await this.apiService.deleteCustomUrl(this.siteId, customUrlId);
+          if (response.success) {
+            this.toastService.success('Deleted', 'Custom URL has been removed');
+            this.eventsbound = false;
+            await this.loadSiteDetail();
+          } else {
+            this.toastService.error('Delete Failed', response.error || 'Failed to delete custom URL');
+          }
+        } catch (error) {
+          this.toastService.error('Error', 'Failed to delete custom URL');
+        }
+      });
+    });
+
+    // --- URL Exclusions ---
+    // Toggle add exclusion form
+    this.element.querySelector('#toggle-add-exclusion-form')?.addEventListener('click', () => {
+      const form = this.element?.querySelector('#add-exclusion-form');
+      form?.classList.toggle('hidden');
+    });
+
+    // Add exclusion pattern
+    this.element.querySelector('#add-exclusion-btn')?.addEventListener('click', async () => {
+      const patternInput = this.element?.querySelector('#new-exclusion-pattern') as HTMLInputElement;
+      const pattern = patternInput?.value.trim();
+
+      if (!pattern) {
+        this.toastService.error('Validation Error', 'Pattern is required');
+        return;
+      }
+
+      try {
+        const response = await this.apiService.addUrlExclusion(this.siteId, pattern);
+        if (response.success) {
+          this.toastService.success('Pattern Added', 'Exclusion pattern has been added');
+          this.eventsbound = false;
+          await this.loadSiteDetail();
+        } else {
+          this.toastService.error('Add Failed', response.error || 'Failed to add exclusion pattern');
+        }
+      } catch (error) {
+        this.toastService.error('Error', 'Failed to add exclusion pattern');
+      }
+    });
+
+    // Delete exclusion pattern
+    this.element.querySelectorAll('.delete-exclusion').forEach(btn => {
+      btn.addEventListener('click', async (e) => {
+        const button = (e.currentTarget as HTMLElement);
+        const exclusionId = button.dataset.id;
+        if (!exclusionId) return;
+
+        if (!confirm('Delete this exclusion pattern?')) return;
+
+        try {
+          const response = await this.apiService.deleteUrlExclusion(this.siteId, exclusionId);
+          if (response.success) {
+            this.toastService.success('Deleted', 'Exclusion pattern has been removed');
+            this.eventsbound = false;
+            await this.loadSiteDetail();
+          } else {
+            this.toastService.error('Delete Failed', response.error || 'Failed to delete exclusion pattern');
+          }
+        } catch (error) {
+          this.toastService.error('Error', 'Failed to delete exclusion pattern');
+        }
+      });
+    });
   }
   }
 }
 }

+ 3 - 3
web/src/components/pages/SitesPage.ts

@@ -3,7 +3,7 @@ import { ToastService } from '../../services/ToastService';
 import { Router } from '../../app/Router';
 import { Router } from '../../app/Router';
 import { SiteWithAnalytics, Site } from '../../types';
 import { SiteWithAnalytics, Site } from '../../types';
 import { getIcon } from '../../utils/icons';
 import { getIcon } from '../../utils/icons';
-import { formatRelativeTime, getDomainFromUrl, getSiteTypeInfo } from '../../utils/helpers';
+import { formatRelativeTime, getDomainFromUrl, getSiteTypeInfo, getPlatformLabel } from '../../utils/helpers';
 
 
 export class SitesPage {
 export class SitesPage {
   private element: HTMLElement | null = null;
   private element: HTMLElement | null = null;
@@ -443,8 +443,8 @@ export class SitesPage {
                   <h3 class="text-sm font-medium text-gray-900 dark:text-white truncate">
                   <h3 class="text-sm font-medium text-gray-900 dark:text-white truncate">
                     ${domain}
                     ${domain}
                   </h3>
                   </h3>
-                  <p class="text-xs text-gray-500 dark:text-gray-400">
-                    ${typeInfo.label}
+                  <p class="text-xs">
+                    <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${site.site_type === 'webshop' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'}">${site.site_type === 'webshop' && site.site_platform && site.site_platform !== 'generic' ? `Webshop &middot; ${getPlatformLabel(site.site_platform)}` : typeInfo.label}</span>
                   </p>
                   </p>
                 </div>
                 </div>
               </div>
               </div>

+ 26 - 1
web/src/services/ApiService.ts

@@ -10,6 +10,7 @@ import {
   Webhook,
   Webhook,
   WebhookDelivery,
   WebhookDelivery,
   CustomUrl,
   CustomUrl,
+  UrlExclusion,
   ContentFilters,
   ContentFilters,
   SiteResultsResponse,
   SiteResultsResponse,
   ApiResponse,
   ApiResponse,
@@ -227,7 +228,7 @@ export class ApiService {
 
 
   async addCustomUrl(
   async addCustomUrl(
     siteId: string,
     siteId: string,
-    urlData: { url: string; content_type: 'shipping' | 'contacts' | 'terms' | 'faq' }
+    urlData: { url: string; content_type: string }
   ): Promise<ApiResponse<CustomUrl>> {
   ): Promise<ApiResponse<CustomUrl>> {
     return this.request(`/api/sites/${siteId}/custom-urls`, {
     return this.request(`/api/sites/${siteId}/custom-urls`, {
       method: 'POST',
       method: 'POST',
@@ -255,6 +256,30 @@ export class ApiService {
     });
     });
   }
   }
 
 
+  // URL Exclusions
+  async getUrlExclusions(siteId: string): Promise<ApiResponse<{ url_exclusions: UrlExclusion[] }>> {
+    return this.request(`/api/sites/${siteId}/url-exclusions`);
+  }
+
+  async addUrlExclusion(
+    siteId: string,
+    pattern: string
+  ): Promise<ApiResponse<UrlExclusion>> {
+    return this.request(`/api/sites/${siteId}/url-exclusions`, {
+      method: 'POST',
+      body: JSON.stringify({ pattern }),
+    });
+  }
+
+  async deleteUrlExclusion(
+    siteId: string,
+    exclusionId: string
+  ): Promise<ApiResponse<{ message: string }>> {
+    return this.request(`/api/sites/${siteId}/url-exclusions/${exclusionId}`, {
+      method: 'DELETE',
+    });
+  }
+
   // Qdrant Management
   // Qdrant Management
   async updateSiteQdrant(
   async updateSiteQdrant(
     siteId: string,
     siteId: string,

+ 8 - 1
web/src/types/index.ts

@@ -102,11 +102,18 @@ export interface CustomUrl {
   id: string;
   id: string;
   site_id: string;
   site_id: string;
   url: string;
   url: string;
-  content_type: 'shipping' | 'contacts' | 'terms' | 'faq';
+  content_type: 'shipping' | 'contacts' | 'terms' | 'faq' | 'services' | 'about' | 'team' | 'blog' | 'pricing' | 'testimonials' | 'gallery' | 'landing' | 'other';
   enabled: boolean;
   enabled: boolean;
   created_at: string;
   created_at: string;
 }
 }
 
 
+export interface UrlExclusion {
+  id: string;
+  site_id: string;
+  pattern: string;
+  created_at: string;
+}
+
 export interface Job {
 export interface Job {
   job_id: string;
   job_id: string;
   site_id?: string;
   site_id?: string;

+ 19 - 1
web/src/utils/helpers.ts

@@ -188,6 +188,24 @@ export function getContentTypeLabel(type: string): string {
       return 'Terms & Conditions';
       return 'Terms & Conditions';
     case 'faq':
     case 'faq':
       return 'FAQ';
       return 'FAQ';
+    case 'services':
+      return 'Services';
+    case 'about':
+      return 'About';
+    case 'team':
+      return 'Team';
+    case 'blog':
+      return 'Blog';
+    case 'pricing':
+      return 'Pricing';
+    case 'testimonials':
+      return 'Testimonials';
+    case 'gallery':
+      return 'Gallery';
+    case 'landing':
+      return 'Landing Pages';
+    case 'other':
+      return 'Other';
     default:
     default:
       return type;
       return type;
   }
   }
@@ -221,7 +239,7 @@ export function getSiteTypeInfo(type: string, platform?: string): { label: strin
   }
   }
 }
 }
 
 
-function getPlatformLabel(platform: string): string {
+export function getPlatformLabel(platform: string): string {
   switch (platform.toLowerCase()) {
   switch (platform.toLowerCase()) {
     case 'shopify': return 'Shopify';
     case 'shopify': return 'Shopify';
     case 'woocommerce': return 'WooCommerce';
     case 'woocommerce': return 'WooCommerce';