@silverbackbase/canopee 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.js ADDED
@@ -0,0 +1,40 @@
1
+ import { createHash } from "node:crypto";
2
+ const VALIDATE_URL = process.env.SILVERBACKBASE_URL
3
+ ? `${process.env.SILVERBACKBASE_URL}/api/tokens/validate`
4
+ : null;
5
+ const SHARED_SECRET = process.env.TRAIL_VALIDATE_SECRET ?? "";
6
+ const cache = new Map();
7
+ async function validateToken(token) {
8
+ if (VALIDATE_URL) {
9
+ const hash = createHash("sha256").update(token).digest("hex");
10
+ const cached = cache.get(hash);
11
+ if (cached && cached.expires > Date.now())
12
+ return cached.valid;
13
+ try {
14
+ const res = await fetch(VALIDATE_URL, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json", "x-trail-secret": SHARED_SECRET },
17
+ body: JSON.stringify({ hash }),
18
+ });
19
+ const data = await res.json();
20
+ cache.set(hash, { valid: data.valid, expires: Date.now() + 60_000 });
21
+ return data.valid;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ // Self-host / dev: no auth
28
+ return true;
29
+ }
30
+ export const requireAuth = async (c, next) => {
31
+ const auth = c.req.header("authorization");
32
+ if (!auth?.startsWith("Bearer ")) {
33
+ return c.json({ error: "Unauthorized — provide Authorization: Bearer <token>" }, 401);
34
+ }
35
+ const token = auth.slice(7).trim();
36
+ const valid = await validateToken(token);
37
+ if (!valid)
38
+ return c.json({ error: "Invalid or revoked token" }, 401);
39
+ await next();
40
+ };
@@ -0,0 +1,95 @@
1
+ import OpenAI from 'openai';
2
+ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
3
+ async function getEmbeddings(texts) {
4
+ const BATCH = 100;
5
+ const results = [];
6
+ for (let i = 0; i < texts.length; i += BATCH) {
7
+ const batch = texts.slice(i, i + BATCH);
8
+ const res = await openai.embeddings.create({ model: 'text-embedding-3-small', input: batch });
9
+ results.push(...res.data.map(d => d.embedding));
10
+ }
11
+ return results;
12
+ }
13
+ function cosine(a, b) {
14
+ let dot = 0, na = 0, nb = 0;
15
+ for (let i = 0; i < a.length; i++) {
16
+ dot += a[i] * b[i];
17
+ na += a[i] * a[i];
18
+ nb += b[i] * b[i];
19
+ }
20
+ return dot / (Math.sqrt(na) * Math.sqrt(nb) || 1);
21
+ }
22
+ function kmeans(embeddings, k, iterations = 20) {
23
+ const n = embeddings.length;
24
+ const dim = embeddings[0].length;
25
+ // Init centroids with random sample
26
+ const centroidIdx = Array.from({ length: k }, (_, i) => Math.floor((i * n) / k));
27
+ let centroids = centroidIdx.map(i => [...embeddings[i]]);
28
+ let assignments = new Array(n).fill(0);
29
+ for (let iter = 0; iter < iterations; iter++) {
30
+ // Assign
31
+ for (let i = 0; i < n; i++) {
32
+ let best = 0, bestSim = -Infinity;
33
+ for (let c = 0; c < k; c++) {
34
+ const sim = cosine(embeddings[i], centroids[c]);
35
+ if (sim > bestSim) {
36
+ bestSim = sim;
37
+ best = c;
38
+ }
39
+ }
40
+ assignments[i] = best;
41
+ }
42
+ // Update centroids
43
+ const sums = Array.from({ length: k }, () => new Array(dim).fill(0));
44
+ const counts = new Array(k).fill(0);
45
+ for (let i = 0; i < n; i++) {
46
+ const c = assignments[i];
47
+ for (let d = 0; d < dim; d++)
48
+ sums[c][d] += embeddings[i][d];
49
+ counts[c]++;
50
+ }
51
+ for (let c = 0; c < k; c++) {
52
+ if (counts[c] > 0)
53
+ centroids[c] = sums[c].map(v => v / counts[c]);
54
+ }
55
+ }
56
+ return assignments;
57
+ }
58
+ function nameTerritory(keywords) {
59
+ // Pick the shortest/most representative keyword as territory name
60
+ return keywords.slice(0, 20).sort((a, b) => a.length - b.length)[0] ?? keywords[0];
61
+ }
62
+ export async function clusterKeywords(keywords) {
63
+ if (keywords.length === 0)
64
+ return [];
65
+ const texts = keywords.map(k => k.keyword);
66
+ const embeddings = await getEmbeddings(texts);
67
+ // k = sqrt(n/2) capped between 5 and 30
68
+ const k = Math.min(30, Math.max(5, Math.round(Math.sqrt(keywords.length / 2))));
69
+ const assignments = kmeans(embeddings, k);
70
+ const groups = new Map();
71
+ for (let i = 0; i < keywords.length; i++) {
72
+ const c = assignments[i];
73
+ if (!groups.has(c))
74
+ groups.set(c, { keywords: [], embeddings: [] });
75
+ groups.get(c).keywords.push(keywords[i]);
76
+ groups.get(c).embeddings.push(embeddings[i]);
77
+ }
78
+ const territories = [];
79
+ for (const [c, group] of groups) {
80
+ if (group.keywords.length === 0)
81
+ continue;
82
+ const id = `t_${c}_${Date.now()}`;
83
+ const name = nameTerritory(group.keywords.map(k => k.keyword));
84
+ territories.push({
85
+ id,
86
+ name,
87
+ keywords: group.keywords.map((kw, i) => ({
88
+ ...kw,
89
+ territoryId: id,
90
+ embedding: group.embeddings[i],
91
+ })),
92
+ });
93
+ }
94
+ return territories;
95
+ }
@@ -0,0 +1,39 @@
1
+ const BASE_URL = 'https://api.dataforseo.com/v3';
2
+ function authHeader() {
3
+ const login = process.env.DATAFORSEO_LOGIN;
4
+ const password = process.env.DATAFORSEO_PASSWORD;
5
+ if (!login || !password)
6
+ throw new Error('DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD not set');
7
+ return 'Basic ' + Buffer.from(`${login}:${password}`).toString('base64');
8
+ }
9
+ async function post(path, body) {
10
+ const res = await fetch(`${BASE_URL}${path}`, {
11
+ method: 'POST',
12
+ headers: { 'Authorization': authHeader(), 'Content-Type': 'application/json' },
13
+ body: JSON.stringify(body),
14
+ });
15
+ if (!res.ok)
16
+ throw new Error(`DataForSEO ${path} → ${res.status}`);
17
+ return res.json();
18
+ }
19
+ export async function getRankedKeywords(domain, limit = 500) {
20
+ const data = await post('/dataforseo_labs/google/ranked_keywords/live', [{
21
+ target: domain,
22
+ language_code: 'fr',
23
+ location_code: 2250,
24
+ limit,
25
+ order_by: ['keyword_data.keyword_info.search_volume,desc'],
26
+ }]);
27
+ const items = data?.tasks?.[0]?.result?.[0]?.items ?? [];
28
+ return items
29
+ .map((item) => ({
30
+ keyword: item.keyword_data?.keyword ?? '',
31
+ volume: item.keyword_data?.keyword_info?.search_volume ?? 0,
32
+ difficulty: item.keyword_data?.keyword_properties?.keyword_difficulty ?? 0,
33
+ position: item.ranked_serp_element?.serp_item?.rank_absolute ?? 0,
34
+ domain,
35
+ categories: item.keyword_data?.keyword_info?.categories ?? [],
36
+ intent: item.keyword_data?.search_intent_info?.main_intent ?? 'informational',
37
+ }))
38
+ .filter((k) => k.keyword && k.volume > 0);
39
+ }
package/dist/db.js ADDED
@@ -0,0 +1,44 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ const DB_PATH = process.env.DB_PATH ?? '/data/canopee.db';
3
+ let _db = null;
4
+ export function getDb() {
5
+ if (!_db) {
6
+ _db = new DatabaseSync(DB_PATH);
7
+ init(_db);
8
+ }
9
+ return _db;
10
+ }
11
+ function init(db) {
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS maps (
14
+ id TEXT PRIMARY KEY,
15
+ domain TEXT NOT NULL,
16
+ competitors TEXT NOT NULL,
17
+ created_at INTEGER NOT NULL,
18
+ refreshed_at INTEGER
19
+ );
20
+
21
+ CREATE TABLE IF NOT EXISTS territories (
22
+ id TEXT PRIMARY KEY,
23
+ map_id TEXT NOT NULL,
24
+ name TEXT NOT NULL,
25
+ category_id INTEGER NOT NULL DEFAULT 0,
26
+ keywords_count INTEGER NOT NULL DEFAULT 0,
27
+ FOREIGN KEY (map_id) REFERENCES maps(id)
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS keywords (
31
+ id TEXT PRIMARY KEY,
32
+ territory_id TEXT NOT NULL,
33
+ map_id TEXT NOT NULL,
34
+ keyword TEXT NOT NULL,
35
+ volume INTEGER,
36
+ difficulty INTEGER,
37
+ domain TEXT NOT NULL,
38
+ position INTEGER,
39
+ intent TEXT,
40
+ FOREIGN KEY (territory_id) REFERENCES territories(id),
41
+ FOREIGN KEY (map_id) REFERENCES maps(id)
42
+ );
43
+ `);
44
+ }
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { Hono } from 'hono';
3
+ import { cors } from 'hono/cors';
4
+ import { createMcpHandler } from './mcp.js';
5
+ import { requireAuth } from './auth.js';
6
+ const port = parseInt(process.env.PORT ?? '3000');
7
+ const app = new Hono();
8
+ app.use('*', cors({ origin: '*', allowMethods: ['GET', 'POST', 'OPTIONS'] }));
9
+ app.all('/mcp', requireAuth, createMcpHandler());
10
+ app.get('/health', (c) => c.json({ status: 'ok', service: 'canopee' }));
11
+ serve({ fetch: app.fetch, port }, () => {
12
+ const base = process.env.CANOPEE_URL ?? `http://localhost:${port}`;
13
+ console.log(`Canopee running on port ${port}`);
14
+ console.log(` MCP → ${base}/mcp`);
15
+ });
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { buildServer } from "./mcp.js";
4
+ const server = buildServer();
5
+ const transport = new StdioServerTransport();
6
+ await server.connect(transport);
package/dist/mcp.js ADDED
@@ -0,0 +1,69 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
3
+ import { z } from 'zod';
4
+ import { createMap } from './tools/create_map.js';
5
+ import { getTerritories } from './tools/get_territories.js';
6
+ import { getGaps } from './tools/get_gaps.js';
7
+ import { getKeywords } from './tools/get_keywords.js';
8
+ import { refreshMap } from './tools/refresh.js';
9
+ export function buildServer() {
10
+ const server = new McpServer({ name: 'canopee', version: '0.1.0' });
11
+ server.registerTool('canopee_create_map', {
12
+ description: 'Build a semantic territory map for a domain and its competitors. Fetches ranked keywords via DataForSEO, clusters them into semantic territories, and persists the map. Returns a map_id for subsequent calls.',
13
+ inputSchema: {
14
+ domain: z.string().describe('Your domain (e.g. monsite.fr)'),
15
+ competitors: z.array(z.string()).describe('List of competitor domains'),
16
+ },
17
+ }, async ({ domain, competitors }) => {
18
+ const result = await createMap(domain, competitors);
19
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
20
+ });
21
+ server.registerTool('canopee_get_territories', {
22
+ description: 'Return all semantic territories of a map with ownership scores — who dominates each territory and by how much.',
23
+ inputSchema: {
24
+ map_id: z.string().describe('Map ID returned by canopee_create_map'),
25
+ },
26
+ }, async ({ map_id }) => {
27
+ const result = getTerritories(map_id);
28
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
29
+ });
30
+ server.registerTool('canopee_get_gaps', {
31
+ description: 'Return top content opportunities — unclaimed or weakly contested territories ranked by gap score and volume/difficulty ratio.',
32
+ inputSchema: {
33
+ map_id: z.string().describe('Map ID returned by canopee_create_map'),
34
+ limit: z.number().int().min(1).max(50).default(10).describe('Max number of gaps to return'),
35
+ },
36
+ }, async ({ map_id, limit }) => {
37
+ const result = getGaps(map_id, limit);
38
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
39
+ });
40
+ server.registerTool('canopee_get_keywords', {
41
+ description: 'Return keywords for a specific territory sorted by opportunity score (volume / difficulty). Use after canopee_get_gaps to drill into a territory.',
42
+ inputSchema: {
43
+ map_id: z.string().describe('Map ID'),
44
+ territory_id: z.string().describe('Territory ID from canopee_get_territories or canopee_get_gaps'),
45
+ limit: z.number().int().min(1).max(100).default(20).describe('Max keywords to return'),
46
+ },
47
+ }, async ({ map_id, territory_id, limit }) => {
48
+ const result = getKeywords(map_id, territory_id, limit);
49
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
50
+ });
51
+ server.registerTool('canopee_refresh', {
52
+ description: 'Refresh an existing map with fresh DataForSEO data. Re-clusters territories and returns drift (territory count delta vs previous map).',
53
+ inputSchema: {
54
+ map_id: z.string().describe('Map ID to refresh'),
55
+ },
56
+ }, async ({ map_id }) => {
57
+ const result = await refreshMap(map_id);
58
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
59
+ });
60
+ return server;
61
+ }
62
+ export function createMcpHandler() {
63
+ return async (c) => {
64
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
65
+ const server = buildServer();
66
+ await server.connect(transport);
67
+ return transport.handleRequest(c.req.raw);
68
+ };
69
+ }
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { getDb } from '../db.js';
3
+ import { getRankedKeywords } from '../dataforseo.js';
4
+ function groupByCategory(keywords) {
5
+ const groups = new Map();
6
+ for (const kw of keywords) {
7
+ const cat = kw.categories[0] ?? 0;
8
+ if (!groups.has(cat))
9
+ groups.set(cat, []);
10
+ groups.get(cat).push(kw);
11
+ }
12
+ return groups;
13
+ }
14
+ function territoryName(keywords) {
15
+ // Most representative = highest volume keyword in the group
16
+ return keywords.sort((a, b) => b.volume - a.volume)[0]?.keyword ?? 'uncategorized';
17
+ }
18
+ export async function createMap(domain, competitors) {
19
+ const allDomains = [domain, ...competitors];
20
+ const results = await Promise.all(allDomains.map(d => getRankedKeywords(d)));
21
+ const allKeywords = results.flat();
22
+ if (allKeywords.length === 0)
23
+ throw new Error('No keywords found for these domains');
24
+ const groups = groupByCategory(allKeywords);
25
+ const db = getDb();
26
+ const mapId = randomUUID();
27
+ const now = Date.now();
28
+ db.prepare(`INSERT INTO maps (id, domain, competitors, created_at) VALUES (?, ?, ?, ?)`)
29
+ .run(mapId, domain, JSON.stringify(competitors), now);
30
+ for (const [categoryId, keywords] of groups) {
31
+ const territoryId = randomUUID();
32
+ const name = territoryName(keywords);
33
+ db.prepare(`INSERT INTO territories (id, map_id, name, category_id, keywords_count) VALUES (?, ?, ?, ?, ?)`)
34
+ .run(territoryId, mapId, name, categoryId, keywords.length);
35
+ for (const kw of keywords) {
36
+ db.prepare(`
37
+ INSERT INTO keywords (id, territory_id, map_id, keyword, volume, difficulty, domain, position, intent)
38
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
39
+ `).run(randomUUID(), territoryId, mapId, kw.keyword, kw.volume, kw.difficulty, kw.domain, kw.position, kw.intent);
40
+ }
41
+ }
42
+ return {
43
+ map_id: mapId,
44
+ domain,
45
+ competitors,
46
+ territories_count: groups.size,
47
+ keywords_count: allKeywords.length,
48
+ created_at: new Date(now).toISOString(),
49
+ };
50
+ }
@@ -0,0 +1,42 @@
1
+ import { getDb } from '../db.js';
2
+ export function getGaps(mapId, limit = 10) {
3
+ const db = getDb();
4
+ const map = db.prepare(`SELECT * FROM maps WHERE id = ?`).get(mapId);
5
+ if (!map)
6
+ throw new Error(`Map ${mapId} not found`);
7
+ const myDomain = map.domain;
8
+ const territories = db.prepare(`SELECT * FROM territories WHERE map_id = ?`).all(mapId);
9
+ const gaps = [];
10
+ for (const t of territories) {
11
+ const keywords = db.prepare(`
12
+ SELECT domain, COUNT(*) as count, SUM(volume) as total_volume
13
+ FROM keywords WHERE territory_id = ?
14
+ GROUP BY domain
15
+ `).all(t.id);
16
+ const totalCount = keywords.reduce((s, r) => s + r.count, 0);
17
+ const myRow = keywords.find((r) => r.domain === myDomain);
18
+ const myShare = myRow ? myRow.count / totalCount : 0;
19
+ // Best keyword in this territory (volume/difficulty ratio)
20
+ const bestKw = db.prepare(`
21
+ SELECT keyword, volume, difficulty
22
+ FROM keywords WHERE territory_id = ? AND volume > 0 AND difficulty > 0
23
+ ORDER BY (volume * 1.0 / difficulty) DESC LIMIT 1
24
+ `).get(t.id);
25
+ gaps.push({
26
+ territory_id: t.id,
27
+ name: t.name,
28
+ keywords_count: t.keywords_count,
29
+ my_share_pct: Math.round(myShare * 100),
30
+ gap_score: Math.round((1 - myShare) * 100),
31
+ best_keyword: bestKw ? {
32
+ keyword: bestKw.keyword,
33
+ volume: bestKw.volume,
34
+ difficulty: bestKw.difficulty,
35
+ opportunity_score: Math.round(bestKw.volume / (bestKw.difficulty || 1)),
36
+ } : null,
37
+ });
38
+ }
39
+ return gaps
40
+ .sort((a, b) => b.gap_score - a.gap_score)
41
+ .slice(0, limit);
42
+ }
@@ -0,0 +1,30 @@
1
+ import { getDb } from '../db.js';
2
+ export function getKeywords(mapId, territoryId, limit = 20) {
3
+ const db = getDb();
4
+ const territory = db.prepare(`SELECT * FROM territories WHERE id = ? AND map_id = ?`).get(territoryId, mapId);
5
+ if (!territory)
6
+ throw new Error(`Territory ${territoryId} not found in map ${mapId}`);
7
+ const map = db.prepare(`SELECT domain FROM maps WHERE id = ?`).get(mapId);
8
+ const keywords = db.prepare(`
9
+ SELECT keyword, volume, difficulty, domain, position,
10
+ ROUND(volume * 1.0 / MAX(difficulty, 1)) as opportunity_score
11
+ FROM keywords
12
+ WHERE territory_id = ? AND volume > 0
13
+ ORDER BY opportunity_score DESC
14
+ LIMIT ?
15
+ `).all(territoryId, limit);
16
+ return {
17
+ territory_id: territoryId,
18
+ territory_name: territory.name,
19
+ my_domain: map.domain,
20
+ keywords: keywords.map(k => ({
21
+ keyword: k.keyword,
22
+ volume: k.volume,
23
+ difficulty: k.difficulty,
24
+ opportunity_score: k.opportunity_score,
25
+ current_owner: k.domain,
26
+ position: k.position,
27
+ owned_by_me: k.domain === map.domain,
28
+ })),
29
+ };
30
+ }
@@ -0,0 +1,33 @@
1
+ import { getDb } from '../db.js';
2
+ export function getTerritories(mapId) {
3
+ const db = getDb();
4
+ const map = db.prepare(`SELECT * FROM maps WHERE id = ?`).get(mapId);
5
+ if (!map)
6
+ throw new Error(`Map ${mapId} not found`);
7
+ const myDomain = map.domain;
8
+ const territories = db.prepare(`SELECT * FROM territories WHERE map_id = ?`).all(mapId);
9
+ return territories.map(t => {
10
+ const keywords = db.prepare(`
11
+ SELECT domain, COUNT(*) as count, SUM(volume) as total_volume
12
+ FROM keywords WHERE territory_id = ?
13
+ GROUP BY domain ORDER BY count DESC
14
+ `).all(t.id);
15
+ const totalCount = keywords.reduce((s, r) => s + r.count, 0);
16
+ const myRow = keywords.find((r) => r.domain === myDomain);
17
+ const myShare = myRow ? Math.round((myRow.count / totalCount) * 100) : 0;
18
+ const topOwner = keywords[0]?.domain ?? myDomain;
19
+ return {
20
+ territory_id: t.id,
21
+ name: t.name,
22
+ keywords_count: t.keywords_count,
23
+ my_share_pct: myShare,
24
+ top_owner: topOwner,
25
+ ownership: keywords.map((r) => ({
26
+ domain: r.domain,
27
+ keywords: r.count,
28
+ volume: r.total_volume,
29
+ share_pct: Math.round((r.count / totalCount) * 100),
30
+ })),
31
+ };
32
+ }).sort((a, b) => b.keywords_count - a.keywords_count);
33
+ }
@@ -0,0 +1,54 @@
1
+ import { getDb } from '../db.js';
2
+ import { getRankedKeywords } from '../dataforseo.js';
3
+ import { randomUUID } from 'node:crypto';
4
+ function groupByCategory(keywords) {
5
+ const groups = new Map();
6
+ for (const kw of keywords) {
7
+ const cat = kw.categories[0] ?? 0;
8
+ if (!groups.has(cat))
9
+ groups.set(cat, []);
10
+ groups.get(cat).push(kw);
11
+ }
12
+ return groups;
13
+ }
14
+ function territoryName(keywords) {
15
+ return keywords.sort((a, b) => b.volume - a.volume)[0]?.keyword ?? 'uncategorized';
16
+ }
17
+ export async function refreshMap(mapId) {
18
+ const db = getDb();
19
+ const map = db.prepare(`SELECT * FROM maps WHERE id = ?`).get(mapId);
20
+ if (!map)
21
+ throw new Error(`Map ${mapId} not found`);
22
+ const domain = map.domain;
23
+ const competitors = JSON.parse(map.competitors);
24
+ const allDomains = [domain, ...competitors];
25
+ const results = await Promise.all(allDomains.map(d => getRankedKeywords(d)));
26
+ const allKeywords = results.flat();
27
+ if (allKeywords.length === 0)
28
+ throw new Error('No keywords found');
29
+ const prevCount = db.prepare(`SELECT COUNT(*) as c FROM territories WHERE map_id = ?`).get(mapId).c;
30
+ db.prepare(`DELETE FROM keywords WHERE map_id = ?`).run(mapId);
31
+ db.prepare(`DELETE FROM territories WHERE map_id = ?`).run(mapId);
32
+ const groups = groupByCategory(allKeywords);
33
+ const now = Date.now();
34
+ for (const [categoryId, keywords] of groups) {
35
+ const territoryId = randomUUID();
36
+ const name = territoryName(keywords);
37
+ db.prepare(`INSERT INTO territories (id, map_id, name, category_id, keywords_count) VALUES (?, ?, ?, ?, ?)`)
38
+ .run(territoryId, mapId, name, categoryId, keywords.length);
39
+ for (const kw of keywords) {
40
+ db.prepare(`
41
+ INSERT INTO keywords (id, territory_id, map_id, keyword, volume, difficulty, domain, position, intent)
42
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
43
+ `).run(randomUUID(), territoryId, mapId, kw.keyword, kw.volume, kw.difficulty, kw.domain, kw.position, kw.intent);
44
+ }
45
+ }
46
+ db.prepare(`UPDATE maps SET refreshed_at = ? WHERE id = ?`).run(now, mapId);
47
+ return {
48
+ map_id: mapId,
49
+ territories_count: groups.size,
50
+ territories_drift: groups.size - prevCount,
51
+ keywords_count: allKeywords.length,
52
+ refreshed_at: new Date(now).toISOString(),
53
+ };
54
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@silverbackbase/canopee",
3
+ "version": "0.1.0",
4
+ "description": "Semantic content territory mapping — MCP primitive for marketing agents",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/silverbackb/canopee.git"
13
+ },
14
+ "bin": {
15
+ "canopee": "./dist/index.js",
16
+ "canopee-mcp": "./dist/mcp-stdio.js"
17
+ },
18
+ "keywords": ["seo", "content", "territory", "mcp", "dataforseo", "semantic"],
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "dev": "tsx src/index.ts",
22
+ "start": "node dist/index.js",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit"
24
+ },
25
+ "dependencies": {
26
+ "@hono/node-server": "^1.12.0",
27
+ "@modelcontextprotocol/sdk": "^1.29.0",
28
+ "hono": "^4.3.0",
29
+ "zod": "^3.23.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.0.0",
33
+ "tsx": "^4.0.0",
34
+ "typescript": "^5.4.0"
35
+ }
36
+ }