This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
npm start / npm run dev are long-running and the user runs them manually.http://localhost:3000/doc (HTML) or /doc/openapi (OpenAPI 3.0 JSON) instead of duplicating route docs in code/notes.npm install # also runs `npm install` inside web/
npm run build # tsc → dist/, then `cd web && npm run build` → dist/web/
npm run dev # ts-node, no build
npm run watch # tsc -w
npm run dev:web # vite dev server for the UI only
There is no test runner, lint, or formatter wired up in package.json — don't invent one.
npm run build runs npm run generate:version first (scripts/generate-version.js), which writes src/version.ts from git rev-parse. The file is gitignored and regenerated each build; it powers the User-Agent header (ShopCallCrawler/<version>+<hash>).
Systemd install/reset/restore lives in scripts/install.sh, scripts/reset.sh, scripts/restore.sh — install copies the built dist/ to /opt/webshop-scraper, reads .env from the project dir, and creates a webshop-scraper systemd unit.
A TypeScript Express service that scrapes any website (webshops or generic), classifies content into 13 fixed categories, stores in SQLite, optionally embeds into Qdrant for semantic search, and exposes the result via REST + MCP.
src/index.ts wires everything in one process:
HTTP request → JobQueue (in-memory, EventEmitter, max N concurrent)
│
▼ emits 'process-job'
WebScraper.scrape(baseUrl, siteId)
├─ SitemapParser: detect platform + resolve sitemap (custom → robots.txt → fallback)
├─ LinkDiscovery: BFS same-origin crawl, capped by site.max_crawl_depth / max_pages
├─ filter via UrlExclusions (glob patterns) and disabled-content URLs
├─ merge in CustomUrls (manually added)
├─ ContentExtractor: fetch + turndown → markdown
├─ SiteDatabase.saveContent: SHA-256 hash, skip-if-unchanged
└─ QdrantEmbeddingWorkflow: chunk → embed via OpenRouter → upsert
ScrapeScheduler (node-cron, every minute): re-queues jobs from `scheduled_jobs`
There is no message broker — JobQueue is a plain EventEmitter over an in-memory Map. index.ts listens for process-job and runs the scrape. Jobs do not survive a process restart; scheduled_jobs (DB) is what survives.
Two site types drive scraping behavior:
webshop — ShopRenter, WooCommerce, Shopify. Sitemap-driven.website — anything else. Falls back to BFS link discovery when no sitemap is found.Four site platforms: shoprenter, woocommerce, shopify, generic. Detected by detectPlatform() in src/scraper/SitemapParser.ts (hostname patterns + homepage HTML sniffing for WooCommerce).
shipping, contacts, terms, faq, services, about, team, blog, pricing, testimonials, gallery, landing, other. Defined in src/types/index.ts (ContentType, ALL_CONTENT_TYPES) and used identically across DB columns, REST responses, Qdrant collection names ({custom_id}-{category}), and MCP tool names ({category}_search). Adding a 14th category means updating types, classifier, MCP tool registry (src/mcp/McpServer.ts), and Qdrant collection management.
Don't add routes by calling app.get(...) directly. The pattern:
src/api/components/*Endpoint.ts, extends BaseEndpointComponent from src/api/core/types.ts.getEndpoints() returns EndpointMethod[] with handler and JSON Schema documentation (parameters, requestBody, responses, requiresAuth).src/api/routes.ts instantiates components, registers them with EndpointRegistry, and the registry builds the Express router and the /doc + /doc/openapi outputs from the same metadata.requiresAuth: true makes authMiddleware (Bearer token vs API_KEY) prepend automatically. /health, /doc*, /ui/*, /mcp* are public.When adding an endpoint: edit the relevant *Endpoint.ts, add to the array, fill out the schema. Don't add a separate doc — the OpenAPI spec generates from the schema you wrote.
shops ↔ sites compatibility (important)The project was originally "webshop scraper" (shops). It was renamed to "sites" but both surfaces are live:
migrateShopsToSites() in src/database/Database.ts renames tables/columns on first boot. The DB file is still data/shops.db (preserved for back-compat); fresh installs would create sites.db but existing deployments keep shops.db. Don't rename it.routes.ts clones every /api/sites/* (and /api/qdrant/sites/*, /api/mcp/sites/*) route into /api/shops/* with :siteId → :id. The clones run through legacyShopAlias middleware (src/api/middleware/legacyShopAlias.ts) which monkey-patches res.json() to recursively rename keys: site_id → shop_id, sites → shops, site_type → webshop_type, etc. New code should use the site-based names; legacy callers keep working unchanged.site_id and shop_id as the parameter name.If you add a new site_* key in a response and legacy clients need it, add it to KEY_MAP in legacyShopAlias.ts.
Sites have two identifiers:
id — internal UUID, generated.custom_id — optional client-provided UUID. Becomes immutable once Qdrant is enabled for that site (would orphan collections otherwise). Qdrant collection names are derived from custom_id, not id.SiteDatabase.getSiteByAnyId(id) accepts either. REST path params named :id or :siteId accept either.
Optional, gated on QDRANT_ENABLED=true server-wide and qdrant_enabled=true per-site. Pipeline:
saveContent writes a row, QdrantEmbeddingWorkflow.processContentBatch() runs.ChunkingService splits content into chunks.EmbeddingService calls OpenRouter (uses the openai client pointed at OpenRouter) for vectors.QdrantService upserts into collection {custom_id}-{category}. Point ID is deterministic: SHA256(url + content_hash + chunk_index) — same URL + same content ⇒ same point ID, so re-embedding is a no-op; content changes yield a fresh point and orphan the old one.qdrant_deletion_queue and QdrantCleanupService actually deletes them after QDRANT_DELETION_DELAY_HOURS (default 24).QdrantMigrationService) from the old per-URL collection layout ({custom_id}-{category}_{url_hash}) to the consolidated layout.Failures are logged into the qdrant_errors table, surfaced via GET /api/sites/:siteId/qdrant/errors.
Mounted at /mcp (Streamable HTTP, stateless) only when both MCP_ENABLED=true and QDRANT_ENABLED=true. src/mcp/McpServer.ts registers 14 tools (1 list_contents + 13 *_search, one per category). Tool classes live in src/mcp/tools/*.ts, all extending BaseTool. Adding a tool: create the class, register it in initializeTools(), and (if it's a new category) update everything in the "13 content categories" section above. Every tool call is logged to mcp_logs via McpLogger; REST /api/sites/:id/search also logs to mcp_logs with tool_name: "rest_search" so analytics show traffic from both transports together.
If the site is unknown or qdrant_enabled is false, tools return "Search not available for this site" rather than erroring — preserve this behavior; agents downstream rely on it.
SQLite via better-sqlite3. Schema applied at startup from src/database/Database.ts — no separate migration step, the constructor runs migrateShopsToSites(), migrateSiteTypeToPlatform(), then initializeTables(). File path: data/shops.db if it exists, else data/sites.db.
saveContent deduplicates across content types: if the same content_hash already exists for the site in another category, the new row is skipped (returns { skipped: true }) and no embedding/webhook is fired. This is intentional — pages that classify into multiple categories should only be embedded once.
Per-site events fired by WebhookManager: scrape_started, scrape_completed, scrape_failed, content_changed. Deliveries are recorded in webhook_deliveries (success/failed + response code).
file_path:line_number so the user can jump.logger from src/utils/logger.ts. HTTP access logs are emitted from server.ts with icon-prefixed status (✓ 2xx, ↪️ 3xx, ⚠️ 4xx, ❌ 5xx); set LOG_HTTP_VERBOSE=true to include UA + content length.src/version.ts is gitignored and machine-generated — don't edit it, don't import build metadata from anywhere else.web/) is a separate Vite + Tailwind project built into dist/web and served at /ui from the Node server. It has its own package.json and tsconfig.json.