@crawlith/core 0.1.0 → 0.1.2

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.
Files changed (238) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +70 -0
  3. package/dist/analysis/analysis_list.html +35 -0
  4. package/dist/analysis/analysis_page.html +123 -0
  5. package/dist/analysis/analyze.d.ts +40 -5
  6. package/dist/analysis/analyze.js +395 -347
  7. package/dist/analysis/clustering.d.ts +23 -0
  8. package/dist/analysis/clustering.js +206 -0
  9. package/dist/analysis/content.d.ts +1 -1
  10. package/dist/analysis/content.js +11 -5
  11. package/dist/analysis/duplicate.d.ts +34 -0
  12. package/dist/analysis/duplicate.js +305 -0
  13. package/dist/analysis/heading.d.ts +116 -0
  14. package/dist/analysis/heading.js +356 -0
  15. package/dist/analysis/images.d.ts +1 -1
  16. package/dist/analysis/images.js +6 -5
  17. package/dist/analysis/links.d.ts +1 -1
  18. package/dist/analysis/links.js +8 -8
  19. package/dist/{scoring/orphanSeverity.d.ts → analysis/orphan.d.ts} +12 -23
  20. package/dist/{scoring/orphanSeverity.js → analysis/orphan.js} +9 -3
  21. package/dist/analysis/scoring.js +11 -2
  22. package/dist/analysis/seo.d.ts +8 -4
  23. package/dist/analysis/seo.js +41 -30
  24. package/dist/analysis/soft404.d.ts +17 -0
  25. package/dist/analysis/soft404.js +62 -0
  26. package/dist/analysis/structuredData.d.ts +1 -1
  27. package/dist/analysis/structuredData.js +5 -4
  28. package/dist/analysis/templates.d.ts +2 -0
  29. package/dist/analysis/templates.js +7 -0
  30. package/dist/application/index.d.ts +2 -0
  31. package/dist/application/index.js +2 -0
  32. package/dist/application/usecase.d.ts +3 -0
  33. package/dist/application/usecase.js +1 -0
  34. package/dist/application/usecases.d.ts +114 -0
  35. package/dist/application/usecases.js +201 -0
  36. package/dist/audit/index.js +1 -1
  37. package/dist/audit/transport.d.ts +1 -1
  38. package/dist/audit/transport.js +5 -4
  39. package/dist/audit/types.d.ts +1 -0
  40. package/dist/constants.d.ts +17 -0
  41. package/dist/constants.js +23 -0
  42. package/dist/core/scope/scopeManager.js +3 -0
  43. package/dist/core/security/ipGuard.d.ts +11 -0
  44. package/dist/core/security/ipGuard.js +71 -3
  45. package/dist/crawler/crawl.d.ts +4 -22
  46. package/dist/crawler/crawl.js +4 -335
  47. package/dist/crawler/crawler.d.ts +87 -0
  48. package/dist/crawler/crawler.js +683 -0
  49. package/dist/crawler/extract.d.ts +4 -1
  50. package/dist/crawler/extract.js +7 -2
  51. package/dist/crawler/fetcher.d.ts +2 -1
  52. package/dist/crawler/fetcher.js +26 -11
  53. package/dist/crawler/metricsRunner.d.ts +23 -1
  54. package/dist/crawler/metricsRunner.js +202 -72
  55. package/dist/crawler/normalize.d.ts +41 -0
  56. package/dist/crawler/normalize.js +119 -3
  57. package/dist/crawler/parser.d.ts +1 -3
  58. package/dist/crawler/parser.js +2 -49
  59. package/dist/crawler/resolver.d.ts +11 -0
  60. package/dist/crawler/resolver.js +67 -0
  61. package/dist/crawler/sitemap.d.ts +6 -0
  62. package/dist/crawler/sitemap.js +27 -17
  63. package/dist/crawler/trap.d.ts +5 -1
  64. package/dist/crawler/trap.js +23 -2
  65. package/dist/db/CrawlithDB.d.ts +110 -0
  66. package/dist/db/CrawlithDB.js +500 -0
  67. package/dist/db/graphLoader.js +42 -30
  68. package/dist/db/index.d.ts +11 -0
  69. package/dist/db/index.js +41 -29
  70. package/dist/db/migrations.d.ts +2 -0
  71. package/dist/db/{schema.js → migrations.js} +90 -43
  72. package/dist/db/pluginRegistry.d.ts +9 -0
  73. package/dist/db/pluginRegistry.js +19 -0
  74. package/dist/db/repositories/EdgeRepository.d.ts +13 -0
  75. package/dist/db/repositories/EdgeRepository.js +20 -0
  76. package/dist/db/repositories/MetricsRepository.d.ts +16 -8
  77. package/dist/db/repositories/MetricsRepository.js +28 -7
  78. package/dist/db/repositories/PageRepository.d.ts +15 -2
  79. package/dist/db/repositories/PageRepository.js +169 -25
  80. package/dist/db/repositories/SiteRepository.d.ts +9 -0
  81. package/dist/db/repositories/SiteRepository.js +13 -0
  82. package/dist/db/repositories/SnapshotRepository.d.ts +14 -5
  83. package/dist/db/repositories/SnapshotRepository.js +64 -5
  84. package/dist/db/reset.d.ts +9 -0
  85. package/dist/db/reset.js +32 -0
  86. package/dist/db/statements.d.ts +12 -0
  87. package/dist/db/statements.js +40 -0
  88. package/dist/diff/compare.d.ts +0 -5
  89. package/dist/diff/compare.js +0 -12
  90. package/dist/diff/service.d.ts +16 -0
  91. package/dist/diff/service.js +41 -0
  92. package/dist/domain/index.d.ts +4 -0
  93. package/dist/domain/index.js +4 -0
  94. package/dist/events.d.ts +56 -0
  95. package/dist/events.js +1 -0
  96. package/dist/graph/graph.d.ts +36 -42
  97. package/dist/graph/graph.js +26 -17
  98. package/dist/graph/hits.d.ts +23 -0
  99. package/dist/graph/hits.js +111 -0
  100. package/dist/graph/metrics.d.ts +0 -4
  101. package/dist/graph/metrics.js +25 -9
  102. package/dist/graph/pagerank.d.ts +17 -4
  103. package/dist/graph/pagerank.js +126 -91
  104. package/dist/graph/simhash.d.ts +6 -0
  105. package/dist/graph/simhash.js +14 -0
  106. package/dist/index.d.ts +29 -8
  107. package/dist/index.js +29 -8
  108. package/dist/lock/hashKey.js +1 -1
  109. package/dist/lock/lockManager.d.ts +5 -1
  110. package/dist/lock/lockManager.js +38 -13
  111. package/dist/plugin-system/plugin-cli.d.ts +10 -0
  112. package/dist/plugin-system/plugin-cli.js +31 -0
  113. package/dist/plugin-system/plugin-config.d.ts +16 -0
  114. package/dist/plugin-system/plugin-config.js +36 -0
  115. package/dist/plugin-system/plugin-loader.d.ts +17 -0
  116. package/dist/plugin-system/plugin-loader.js +122 -0
  117. package/dist/plugin-system/plugin-registry.d.ts +25 -0
  118. package/dist/plugin-system/plugin-registry.js +167 -0
  119. package/dist/plugin-system/plugin-types.d.ts +205 -0
  120. package/dist/plugin-system/plugin-types.js +1 -0
  121. package/dist/ports/index.d.ts +9 -0
  122. package/dist/ports/index.js +1 -0
  123. package/{src/report/sitegraph_template.ts → dist/report/crawl.html} +330 -81
  124. package/dist/report/crawlExport.d.ts +3 -0
  125. package/dist/report/{sitegraphExport.js → crawlExport.js} +3 -3
  126. package/dist/report/crawl_template.d.ts +1 -0
  127. package/dist/report/crawl_template.js +7 -0
  128. package/dist/report/export.d.ts +3 -0
  129. package/dist/report/export.js +81 -0
  130. package/dist/report/html.js +15 -216
  131. package/dist/report/insight.d.ts +27 -0
  132. package/dist/report/insight.js +103 -0
  133. package/dist/scoring/health.d.ts +56 -0
  134. package/dist/scoring/health.js +213 -0
  135. package/dist/utils/chalk.d.ts +6 -0
  136. package/dist/utils/chalk.js +41 -0
  137. package/dist/utils/secureConfig.d.ts +23 -0
  138. package/dist/utils/secureConfig.js +128 -0
  139. package/package.json +12 -6
  140. package/CHANGELOG.md +0 -7
  141. package/dist/db/schema.d.ts +0 -2
  142. package/dist/graph/cluster.d.ts +0 -6
  143. package/dist/graph/cluster.js +0 -173
  144. package/dist/graph/duplicate.d.ts +0 -10
  145. package/dist/graph/duplicate.js +0 -251
  146. package/dist/report/sitegraphExport.d.ts +0 -3
  147. package/dist/report/sitegraph_template.d.ts +0 -1
  148. package/dist/report/sitegraph_template.js +0 -630
  149. package/dist/scoring/hits.d.ts +0 -9
  150. package/dist/scoring/hits.js +0 -111
  151. package/src/analysis/analyze.ts +0 -548
  152. package/src/analysis/content.ts +0 -62
  153. package/src/analysis/images.ts +0 -28
  154. package/src/analysis/links.ts +0 -41
  155. package/src/analysis/scoring.ts +0 -59
  156. package/src/analysis/seo.ts +0 -82
  157. package/src/analysis/structuredData.ts +0 -62
  158. package/src/audit/dns.ts +0 -49
  159. package/src/audit/headers.ts +0 -98
  160. package/src/audit/index.ts +0 -66
  161. package/src/audit/scoring.ts +0 -232
  162. package/src/audit/transport.ts +0 -258
  163. package/src/audit/types.ts +0 -102
  164. package/src/core/network/proxyAdapter.ts +0 -21
  165. package/src/core/network/rateLimiter.ts +0 -39
  166. package/src/core/network/redirectController.ts +0 -47
  167. package/src/core/network/responseLimiter.ts +0 -34
  168. package/src/core/network/retryPolicy.ts +0 -57
  169. package/src/core/scope/domainFilter.ts +0 -45
  170. package/src/core/scope/scopeManager.ts +0 -52
  171. package/src/core/scope/subdomainPolicy.ts +0 -39
  172. package/src/core/security/ipGuard.ts +0 -92
  173. package/src/crawler/crawl.ts +0 -382
  174. package/src/crawler/extract.ts +0 -34
  175. package/src/crawler/fetcher.ts +0 -233
  176. package/src/crawler/metricsRunner.ts +0 -124
  177. package/src/crawler/normalize.ts +0 -108
  178. package/src/crawler/parser.ts +0 -190
  179. package/src/crawler/sitemap.ts +0 -73
  180. package/src/crawler/trap.ts +0 -96
  181. package/src/db/graphLoader.ts +0 -105
  182. package/src/db/index.ts +0 -70
  183. package/src/db/repositories/EdgeRepository.ts +0 -29
  184. package/src/db/repositories/MetricsRepository.ts +0 -49
  185. package/src/db/repositories/PageRepository.ts +0 -128
  186. package/src/db/repositories/SiteRepository.ts +0 -32
  187. package/src/db/repositories/SnapshotRepository.ts +0 -74
  188. package/src/db/schema.ts +0 -177
  189. package/src/diff/compare.ts +0 -84
  190. package/src/graph/cluster.ts +0 -192
  191. package/src/graph/duplicate.ts +0 -286
  192. package/src/graph/graph.ts +0 -172
  193. package/src/graph/metrics.ts +0 -110
  194. package/src/graph/pagerank.ts +0 -125
  195. package/src/graph/simhash.ts +0 -61
  196. package/src/index.ts +0 -30
  197. package/src/lock/hashKey.ts +0 -51
  198. package/src/lock/lockManager.ts +0 -124
  199. package/src/lock/pidCheck.ts +0 -13
  200. package/src/report/html.ts +0 -227
  201. package/src/report/sitegraphExport.ts +0 -58
  202. package/src/scoring/hits.ts +0 -131
  203. package/src/scoring/orphanSeverity.ts +0 -176
  204. package/src/utils/version.ts +0 -18
  205. package/tests/__snapshots__/orphanSeverity.test.ts.snap +0 -49
  206. package/tests/analysis.unit.test.ts +0 -98
  207. package/tests/analyze.integration.test.ts +0 -98
  208. package/tests/audit/dns.test.ts +0 -31
  209. package/tests/audit/headers.test.ts +0 -45
  210. package/tests/audit/scoring.test.ts +0 -133
  211. package/tests/audit/security.test.ts +0 -12
  212. package/tests/audit/transport.test.ts +0 -112
  213. package/tests/clustering.test.ts +0 -118
  214. package/tests/crawler.test.ts +0 -358
  215. package/tests/db.test.ts +0 -159
  216. package/tests/diff.test.ts +0 -67
  217. package/tests/duplicate.test.ts +0 -110
  218. package/tests/fetcher.test.ts +0 -106
  219. package/tests/fetcher_safety.test.ts +0 -85
  220. package/tests/fixtures/analyze-crawl.json +0 -26
  221. package/tests/hits.test.ts +0 -134
  222. package/tests/html_report.test.ts +0 -58
  223. package/tests/lock/lockManager.test.ts +0 -138
  224. package/tests/metrics.test.ts +0 -196
  225. package/tests/normalize.test.ts +0 -101
  226. package/tests/orphanSeverity.test.ts +0 -160
  227. package/tests/pagerank.test.ts +0 -98
  228. package/tests/parser.test.ts +0 -117
  229. package/tests/proxy_safety.test.ts +0 -57
  230. package/tests/redirect_safety.test.ts +0 -73
  231. package/tests/safety.test.ts +0 -114
  232. package/tests/scope.test.ts +0 -66
  233. package/tests/scoring.test.ts +0 -59
  234. package/tests/sitemap.test.ts +0 -88
  235. package/tests/soft404.test.ts +0 -41
  236. package/tests/trap.test.ts +0 -39
  237. package/tests/visualization_data.test.ts +0 -46
  238. package/tsconfig.json +0 -11
@@ -0,0 +1,41 @@
1
+ import { styleText } from 'node:util';
2
+ const alias = {
3
+ grey: 'gray'
4
+ };
5
+ const chalk = createChalk([]);
6
+ function createChalk(styles) {
7
+ const formatter = ((text) => applyStyles(styles, text));
8
+ return new Proxy(formatter, {
9
+ apply(_target, _thisArg, args) {
10
+ return applyStyles(styles, args[0]);
11
+ },
12
+ get(_target, prop) {
13
+ if (typeof prop !== 'string') {
14
+ return undefined;
15
+ }
16
+ const style = alias[prop] ?? prop;
17
+ return createChalk([...styles, style]);
18
+ }
19
+ });
20
+ }
21
+ function applyStyles(styles, text) {
22
+ const value = String(text ?? '');
23
+ if (styles.length === 0 || !isColorEnabled()) {
24
+ return value;
25
+ }
26
+ return styleText(styles, value);
27
+ }
28
+ function isColorEnabled() {
29
+ if (process.env.NO_COLOR !== undefined || process.env.NODE_DISABLE_COLORS !== undefined) {
30
+ return false;
31
+ }
32
+ const forceColor = process.env.FORCE_COLOR;
33
+ if (forceColor === '0') {
34
+ return false;
35
+ }
36
+ if (forceColor !== undefined) {
37
+ return true;
38
+ }
39
+ return Boolean(process.stdout?.isTTY);
40
+ }
41
+ export default chalk;
@@ -0,0 +1,23 @@
1
+ export interface CrawlithConfig {
2
+ [section: string]: {
3
+ key?: string;
4
+ createdAt?: number;
5
+ [key: string]: unknown;
6
+ };
7
+ }
8
+ /**
9
+ * Resolve the canonical Crawlith config file path.
10
+ */
11
+ export declare function getCrawlithConfigPath(): string;
12
+ /**
13
+ * Return section config, or undefined if config file/section does not exist.
14
+ */
15
+ export declare function getConfigSection(section: string): CrawlithConfig[string] | undefined;
16
+ /**
17
+ * Encrypt and persist a section API key in ~/.crawlith/config.json.
18
+ */
19
+ export declare function setEncryptedConfigKey(section: string, apiKey: string): void;
20
+ /**
21
+ * Get and decrypt the API key for a config section.
22
+ */
23
+ export declare function getDecryptedConfigKey(section: string): string;
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ const CONFIG_DIR = path.join(os.homedir(), '.crawlith');
6
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
7
+ /**
8
+ * Resolve the canonical Crawlith config file path.
9
+ */
10
+ export function getCrawlithConfigPath() {
11
+ return CONFIG_PATH;
12
+ }
13
+ /**
14
+ * Return section config, or undefined if config file/section does not exist.
15
+ */
16
+ export function getConfigSection(section) {
17
+ const config = readConfigFile(false);
18
+ if (!config)
19
+ return undefined;
20
+ return config[section];
21
+ }
22
+ /**
23
+ * Encrypt and persist a section API key in ~/.crawlith/config.json.
24
+ */
25
+ export function setEncryptedConfigKey(section, apiKey) {
26
+ const config = readConfigFile(false) || {};
27
+ config[section] = {
28
+ ...(config[section] || {}),
29
+ key: encryptString(apiKey),
30
+ createdAt: Math.floor(Date.now() / 1000)
31
+ };
32
+ writeConfigFile(config);
33
+ }
34
+ /**
35
+ * Get and decrypt the API key for a config section.
36
+ */
37
+ export function getDecryptedConfigKey(section) {
38
+ if (!fs.existsSync(CONFIG_PATH)) {
39
+ throw new Error(`Missing ${section} config. Run: crawlith config ${section} set <api_key>`);
40
+ }
41
+ const config = readConfigFile(true);
42
+ if (!config) {
43
+ throw new Error(`Missing ${section} config. Run: crawlith config ${section} set <api_key>`);
44
+ }
45
+ const payload = config[section]?.key;
46
+ if (!payload || typeof payload !== 'string') {
47
+ throw new Error(`Missing ${section} key in config. Run: crawlith config ${section} set <api_key>`);
48
+ }
49
+ return decryptString(payload);
50
+ }
51
+ /**
52
+ * Read config file from disk.
53
+ */
54
+ function readConfigFile(required) {
55
+ if (!fs.existsSync(CONFIG_PATH)) {
56
+ if (required) {
57
+ throw new Error('Missing config file. Run: crawlith config <service> set <api_key>');
58
+ }
59
+ return null;
60
+ }
61
+ try {
62
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
63
+ }
64
+ catch {
65
+ throw new Error('Corrupt config file at ~/.crawlith/config.json. Refusing to continue.');
66
+ }
67
+ }
68
+ /**
69
+ * Persist config to disk with secure permissions.
70
+ */
71
+ function writeConfigFile(config) {
72
+ if (!fs.existsSync(CONFIG_DIR)) {
73
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
74
+ }
75
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: 'utf8', mode: 0o600 });
76
+ fs.chmodSync(CONFIG_PATH, 0o600);
77
+ }
78
+ /**
79
+ * Build a machine-bound secret so encrypted config blobs are not portable across systems.
80
+ */
81
+ function getMachineSecret() {
82
+ return `${os.hostname()}::${os.userInfo().username}`;
83
+ }
84
+ /**
85
+ * Encrypt plaintext using AES-256-GCM and scrypt-derived key.
86
+ */
87
+ function encryptString(plaintext) {
88
+ const salt = crypto.randomBytes(16);
89
+ const iv = crypto.randomBytes(12);
90
+ const key = crypto.scryptSync(getMachineSecret(), salt, 32);
91
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
92
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
93
+ const payload = {
94
+ salt: salt.toString('base64'),
95
+ iv: iv.toString('base64'),
96
+ tag: cipher.getAuthTag().toString('base64'),
97
+ data: encrypted.toString('base64')
98
+ };
99
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
100
+ }
101
+ /**
102
+ * Decrypt an encrypted base64 payload from config.json.
103
+ */
104
+ function decryptString(encodedPayload) {
105
+ let payload;
106
+ try {
107
+ payload = JSON.parse(Buffer.from(encodedPayload, 'base64').toString('utf8'));
108
+ }
109
+ catch {
110
+ throw new Error('Corrupt config payload: unable to parse encrypted key data.');
111
+ }
112
+ if (!payload?.salt || !payload?.iv || !payload?.tag || !payload?.data) {
113
+ throw new Error('Corrupt config payload: required encryption fields are missing.');
114
+ }
115
+ try {
116
+ const salt = Buffer.from(payload.salt, 'base64');
117
+ const iv = Buffer.from(payload.iv, 'base64');
118
+ const tag = Buffer.from(payload.tag, 'base64');
119
+ const data = Buffer.from(payload.data, 'base64');
120
+ const key = crypto.scryptSync(getMachineSecret(), salt, 32);
121
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
122
+ decipher.setAuthTag(tag);
123
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
124
+ }
125
+ catch {
126
+ throw new Error('Unable to decrypt config key. Config may be invalid or tied to another machine/user.');
127
+ }
128
+ }
package/package.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "@crawlith/core",
3
- "version": "0.1.0",
3
+ "license": "Apache-2.0",
4
+ "version": "0.1.2",
5
+ "description": "Headless intelligence engine for Crawlith. Handles crawling, graph analysis, scoring, and data persistence.",
4
6
  "type": "module",
5
7
  "main": "dist/index.js",
6
8
  "types": "dist/index.d.ts",
@@ -11,23 +13,27 @@
11
13
  "default": "./dist/index.js"
12
14
  }
13
15
  },
16
+ "files": [
17
+ "dist"
18
+ ],
14
19
  "dependencies": {
15
20
  "better-sqlite3": "^12.6.2",
16
- "chalk": "^5.3.0",
17
21
  "cheerio": "^1.0.0-rc.12",
18
- "p-limit": "^5.0.0",
22
+ "commander": "^12.1.0",
23
+ "p-limit": "^7.3.0",
19
24
  "robots-parser": "^3.0.1",
20
- "undici": "^6.13.0",
21
- "vite": "7.3.1"
25
+ "undici": "^6.13.0"
22
26
  },
23
27
  "devDependencies": {
24
28
  "@types/better-sqlite3": "^7.6.13",
29
+ "@types/cheerio": "0.22.35",
25
30
  "@types/node": "^20.12.7",
26
31
  "typescript": "^5.4.5",
32
+ "vite": "7.3.1",
27
33
  "vitest": "^4.0.18"
28
34
  },
29
35
  "scripts": {
30
- "build": "tsc",
36
+ "build": "tsc && node scripts/copy-assets.js",
31
37
  "test": "vitest run"
32
38
  }
33
39
  }
package/CHANGELOG.md DELETED
@@ -1,7 +0,0 @@
1
- # @crawlith/core
2
-
3
- ## 0.1.0
4
-
5
- ### Minor Changes
6
-
7
- - First test
@@ -1,2 +0,0 @@
1
- import { Database } from 'better-sqlite3';
2
- export declare function initSchema(db: Database): void;
@@ -1,6 +0,0 @@
1
- import { Graph, ClusterInfo } from './graph.js';
2
- /**
3
- * Detects content clusters using 64-bit SimHash and Hamming Distance.
4
- * Uses band optimization to reduce O(n^2) comparisons.
5
- */
6
- export declare function detectContentClusters(graph: Graph, threshold?: number, minSize?: number): ClusterInfo[];
@@ -1,173 +0,0 @@
1
- import { SimHash } from './simhash.js';
2
- /**
3
- * Detects content clusters using 64-bit SimHash and Hamming Distance.
4
- * Uses band optimization to reduce O(n^2) comparisons.
5
- */
6
- export function detectContentClusters(graph, threshold = 10, minSize = 3) {
7
- const nodes = graph.getNodes().filter(n => n.simhash && n.status === 200);
8
- if (nodes.length === 0)
9
- return [];
10
- const adjacency = new Map();
11
- // Banding Optimization (4 bands of 16 bits)
12
- // Note: For threshold > 3, this is a heuristic and may miss some pairs,
13
- // but it dramatically reduces the search space as requested.
14
- const bands = 4;
15
- const bandWidth = 16;
16
- const buckets = Array.from({ length: bands }, () => new Map());
17
- for (const node of nodes) {
18
- const hash = BigInt(node.simhash);
19
- for (let b = 0; b < bands; b++) {
20
- const bandValue = Number((hash >> BigInt(b * bandWidth)) & 0xffffn);
21
- if (!buckets[b].has(bandValue)) {
22
- buckets[b].set(bandValue, new Set());
23
- }
24
- buckets[b].get(bandValue).add(node.url);
25
- }
26
- }
27
- const checkedPairs = new Set();
28
- for (let b = 0; b < bands; b++) {
29
- for (const bucket of buckets[b].values()) {
30
- if (bucket.size < 2)
31
- continue;
32
- const bucketNodes = Array.from(bucket);
33
- for (let i = 0; i < bucketNodes.length; i++) {
34
- for (let j = i + 1; j < bucketNodes.length; j++) {
35
- const u1 = bucketNodes[i];
36
- const u2 = bucketNodes[j];
37
- if (u1 === u2)
38
- continue;
39
- const pairKey = u1 < u2 ? `${u1}|${u2}` : `${u2}|${u1}`;
40
- if (checkedPairs.has(pairKey))
41
- continue;
42
- checkedPairs.add(pairKey);
43
- const n1 = graph.nodes.get(u1);
44
- const n2 = graph.nodes.get(u2);
45
- const dist = SimHash.hammingDistance(BigInt(n1.simhash), BigInt(n2.simhash));
46
- if (dist <= threshold) {
47
- if (!adjacency.has(u1))
48
- adjacency.set(u1, new Set());
49
- if (!adjacency.has(u2))
50
- adjacency.set(u2, new Set());
51
- adjacency.get(u1).add(u2);
52
- adjacency.get(u2).add(u1);
53
- }
54
- }
55
- }
56
- }
57
- }
58
- // Find connected components (Clusters)
59
- const visited = new Set();
60
- const clusters = [];
61
- for (const node of nodes) {
62
- if (visited.has(node.url))
63
- continue;
64
- const component = [];
65
- const queue = [node.url];
66
- visited.add(node.url);
67
- while (queue.length > 0) {
68
- const current = queue.shift();
69
- component.push(current);
70
- const neighbors = adjacency.get(current);
71
- if (neighbors) {
72
- for (const neighbor of neighbors) {
73
- if (!visited.has(neighbor)) {
74
- visited.add(neighbor);
75
- queue.push(neighbor);
76
- }
77
- }
78
- }
79
- }
80
- if (component.length >= minSize) {
81
- clusters.push(component);
82
- }
83
- }
84
- // Sort clusters by size (descending) then by primary URL (ascending) for deterministic IDs
85
- clusters.sort((a, b) => {
86
- if (b.length !== a.length)
87
- return b.length - a.length;
88
- const aPrimary = selectPrimaryUrl(a, graph);
89
- const bPrimary = selectPrimaryUrl(b, graph);
90
- return aPrimary.localeCompare(bPrimary);
91
- });
92
- const clusterInfos = [];
93
- clusters.forEach((memberUrls, index) => {
94
- const clusterId = index + 1;
95
- const clusterNodes = memberUrls.map(url => graph.nodes.get(url));
96
- for (const node of clusterNodes) {
97
- node.clusterId = clusterId;
98
- }
99
- const primaryUrl = selectPrimaryUrl(memberUrls, graph);
100
- const risk = calculateClusterRisk(clusterNodes);
101
- const sharedPathPrefix = findSharedPathPrefix(memberUrls);
102
- clusterInfos.push({
103
- id: clusterId,
104
- count: memberUrls.length,
105
- primaryUrl,
106
- risk,
107
- sharedPathPrefix
108
- });
109
- });
110
- graph.contentClusters = clusterInfos;
111
- return clusterInfos;
112
- }
113
- /**
114
- * Selects the primary URL for a cluster based on:
115
- * 1. Highest PageRank
116
- * 2. Shortest URL
117
- * 3. Lexicographic fallback
118
- */
119
- function selectPrimaryUrl(urls, graph) {
120
- return urls.reduce((best, current) => {
121
- const nBest = graph.nodes.get(best);
122
- const nCurrent = graph.nodes.get(current);
123
- if ((nCurrent.pageRank || 0) > (nBest.pageRank || 0))
124
- return current;
125
- if ((nCurrent.pageRank || 0) < (nBest.pageRank || 0))
126
- return best;
127
- if (current.length < best.length)
128
- return current;
129
- if (current.length > best.length)
130
- return best;
131
- return current.localeCompare(best) < 0 ? current : best;
132
- });
133
- }
134
- /**
135
- * Calculates cannibalization risk based on title and H1 similarity within the cluster.
136
- */
137
- function calculateClusterRisk(nodes) {
138
- // Logic: Check if there's significant overlap in Titles or H1s among cluster members.
139
- // This is a heuristic as requested.
140
- // Simplified heuristic: risk is based on cluster density and size
141
- // Large clusters of highly similar content are high risk.
142
- // Fallback to a safe categorization
143
- if (nodes.length > 5)
144
- return 'high';
145
- if (nodes.length > 2)
146
- return 'medium';
147
- return 'low';
148
- }
149
- /**
150
- * Finds the common path prefix among a set of URLs.
151
- */
152
- function findSharedPathPrefix(urls) {
153
- if (urls.length < 2)
154
- return undefined;
155
- try {
156
- const paths = urls.map(u => new URL(u).pathname.split('/').filter(Boolean));
157
- const first = paths[0];
158
- const common = [];
159
- for (let i = 0; i < first.length; i++) {
160
- const segment = first[i];
161
- if (paths.every(p => p[i] === segment)) {
162
- common.push(segment);
163
- }
164
- else {
165
- break;
166
- }
167
- }
168
- return common.length > 0 ? '/' + common.join('/') : undefined;
169
- }
170
- catch {
171
- return undefined;
172
- }
173
- }
@@ -1,10 +0,0 @@
1
- import { Graph } from './graph.js';
2
- export interface DuplicateOptions {
3
- collapse?: boolean;
4
- simhashThreshold?: number;
5
- }
6
- /**
7
- * Detects exact and near duplicates, identifies canonical conflicts,
8
- * and performs non-destructive collapse of edges.
9
- */
10
- export declare function detectDuplicates(graph: Graph, options?: DuplicateOptions): void;
@@ -1,251 +0,0 @@
1
- import { SimHash } from './simhash.js';
2
- /**
3
- * Detects exact and near duplicates, identifies canonical conflicts,
4
- * and performs non-destructive collapse of edges.
5
- */
6
- export function detectDuplicates(graph, options = {}) {
7
- const collapse = options.collapse !== false; // Default to true
8
- const threshold = options.simhashThreshold ?? 3;
9
- const exactClusters = [];
10
- const nearClusters = [];
11
- const nodes = graph.getNodes();
12
- // Phase 1 & 2: Exact Duplicate Detection
13
- const exactMap = new Map();
14
- for (const node of nodes) {
15
- if (!node.contentHash || node.status !== 200)
16
- continue;
17
- // Safety check: if there's no soft404 signal (soft404 is handled elsewhere, but just filter 200 OKs)
18
- let arr = exactMap.get(node.contentHash);
19
- if (!arr) {
20
- arr = [];
21
- exactMap.set(node.contentHash, arr);
22
- }
23
- arr.push(node);
24
- }
25
- // Nodes that are NOT part of an exact duplicate group are candidates for near duplicate checks
26
- const nearCandidates = [];
27
- let clusterCounter = 1;
28
- for (const [_hash, group] of exactMap.entries()) {
29
- if (group.length > 1) {
30
- const id = `cluster_exact_${clusterCounter++}`;
31
- exactClusters.push({ id, type: 'exact', nodes: group });
32
- // Mark nodes
33
- for (const n of group) {
34
- n.duplicateClusterId = id;
35
- n.duplicateType = 'exact';
36
- }
37
- }
38
- else {
39
- nearCandidates.push(group[0]);
40
- }
41
- }
42
- // Phase 3: Near Duplicate Detection (SimHash with Bands)
43
- // 64-bit simhash -> split into 4 bands of 16 bits.
44
- const bandsMaps = [
45
- new Map(),
46
- new Map(),
47
- new Map(),
48
- new Map()
49
- ];
50
- for (const node of nearCandidates) {
51
- if (!node.simhash)
52
- continue;
53
- const simhash = BigInt(node.simhash);
54
- // Extract 16 bit bands
55
- const b0 = Number(simhash & 0xffffn);
56
- const b1 = Number((simhash >> 16n) & 0xffffn);
57
- const b2 = Number((simhash >> 32n) & 0xffffn);
58
- const b3 = Number((simhash >> 48n) & 0xffffn);
59
- const bands = [b0, b1, b2, b3];
60
- for (let i = 0; i < 4; i++) {
61
- let arr = bandsMaps[i].get(bands[i]);
62
- if (!arr) {
63
- arr = [];
64
- bandsMaps[i].set(bands[i], arr);
65
- }
66
- arr.push(node);
67
- }
68
- }
69
- // Find candidate pairs
70
- const nearGroupMap = new Map(); // node.url -> cluster set
71
- const checkedPairs = new Set();
72
- for (let i = 0; i < 4; i++) {
73
- for (const [_bandVal, bucketNodes] of bandsMaps[i].entries()) {
74
- if (bucketNodes.length < 2)
75
- continue; // nothing to compare
76
- // Compare all nodes in this bucket
77
- for (let j = 0; j < bucketNodes.length; j++) {
78
- for (let k = j + 1; k < bucketNodes.length; k++) {
79
- const n1 = bucketNodes[j];
80
- const n2 = bucketNodes[k];
81
- // Ensure n1 < n2 lexicographically to avoid duplicate pairs
82
- const [a, b] = n1.url < n2.url ? [n1, n2] : [n2, n1];
83
- const pairKey = `${a.url}|${b.url}`;
84
- if (checkedPairs.has(pairKey))
85
- continue;
86
- checkedPairs.add(pairKey);
87
- const dist = SimHash.hammingDistance(BigInt(a.simhash), BigInt(b.simhash));
88
- if (dist <= threshold) {
89
- // They are near duplicates.
90
- // Find or create their cluster set using union-find or reference propagation
91
- const setA = nearGroupMap.get(a.url);
92
- const setB = nearGroupMap.get(b.url);
93
- if (!setA && !setB) {
94
- const newSet = new Set([a, b]);
95
- nearGroupMap.set(a.url, newSet);
96
- nearGroupMap.set(b.url, newSet);
97
- }
98
- else if (setA && !setB) {
99
- setA.add(b);
100
- nearGroupMap.set(b.url, setA);
101
- }
102
- else if (setB && !setA) {
103
- setB.add(a);
104
- nearGroupMap.set(a.url, setB);
105
- }
106
- else if (setA && setB && setA !== setB) {
107
- // Merge sets
108
- for (const node of setB) {
109
- setA.add(node);
110
- nearGroupMap.set(node.url, setA);
111
- }
112
- }
113
- }
114
- }
115
- }
116
- }
117
- }
118
- // Compile near duplicate clusters (deduplicated by Set reference)
119
- const uniqueNearSets = new Set();
120
- for (const group of nearGroupMap.values()) {
121
- uniqueNearSets.add(group);
122
- }
123
- for (const groupSet of uniqueNearSets) {
124
- if (groupSet.size > 1) {
125
- const id = `cluster_near_${clusterCounter++}`;
126
- const groupArr = Array.from(groupSet);
127
- nearClusters.push({ id, type: 'near', nodes: groupArr });
128
- for (const n of groupArr) {
129
- n.duplicateClusterId = id;
130
- n.duplicateType = 'near';
131
- }
132
- }
133
- }
134
- const allClusters = [...exactClusters, ...nearClusters];
135
- // Phase 4: Template-Heavy Detection
136
- // Mark classes as 'template_heavy' if ratio < 0.3
137
- for (const cluster of allClusters) {
138
- const avgRatio = cluster.nodes.reduce((sum, n) => sum + (n.uniqueTokenRatio || 0), 0) / cluster.nodes.length;
139
- if (avgRatio < 0.3) {
140
- cluster.type = 'template_heavy';
141
- cluster.nodes.forEach(n => n.duplicateType = 'template_heavy');
142
- }
143
- }
144
- // Phase 5: Canonical Conflict & Representative Selection
145
- for (const cluster of allClusters) {
146
- const canonicals = new Set();
147
- let hasMissing = false;
148
- for (const n of cluster.nodes) {
149
- if (!n.canonical)
150
- hasMissing = true;
151
- // We compare full absolute canonical URLs (assuming they are normalized during crawl)
152
- else
153
- canonicals.add(n.canonical);
154
- }
155
- if (hasMissing || canonicals.size > 1) {
156
- cluster.severity = 'high';
157
- }
158
- else if (cluster.type === 'near') {
159
- cluster.severity = 'medium';
160
- }
161
- else {
162
- cluster.severity = 'low';
163
- }
164
- // Phase 6: Select Representative
165
- // 1. Valid Canonical target in cluster
166
- // 2. Highest internal in-degree
167
- // 3. Shortest URL
168
- // 4. First discovered (relying on array order, which is from BFS map roughly)
169
- let representativeNode = cluster.nodes[0];
170
- // Evaluate best rep
171
- const urlsInCluster = new Set(cluster.nodes.map(n => n.url));
172
- const validCanonicals = cluster.nodes.filter(n => n.canonical && urlsInCluster.has(n.canonical) && n.url === n.canonical);
173
- if (validCanonicals.length > 0) {
174
- representativeNode = validCanonicals[0]; // If multiple, just pick first matching self
175
- }
176
- else {
177
- representativeNode = cluster.nodes.reduce((best, current) => {
178
- if (current.inLinks > best.inLinks)
179
- return current;
180
- if (current.inLinks < best.inLinks)
181
- return best;
182
- if (current.url.length < best.url.length)
183
- return current;
184
- return best;
185
- });
186
- }
187
- cluster.representative = representativeNode.url;
188
- cluster.nodes.forEach(n => {
189
- n.isClusterPrimary = n.url === representativeNode.url;
190
- n.isCollapsed = false; // default for JSON
191
- n.collapseInto = undefined;
192
- });
193
- // Push to Graph's final cluster list
194
- graph.duplicateClusters.push({
195
- id: cluster.id,
196
- type: cluster.type,
197
- size: cluster.nodes.length,
198
- representative: representativeNode.url,
199
- severity: cluster.severity
200
- });
201
- // Controlled Collapse
202
- if (collapse) {
203
- for (const n of cluster.nodes) {
204
- if (n.url !== representativeNode.url) {
205
- n.isCollapsed = true;
206
- n.collapseInto = representativeNode.url;
207
- }
208
- }
209
- }
210
- }
211
- // Final Edge Transfer if Collapsing
212
- if (collapse) {
213
- const edges = graph.getEdges();
214
- const updatedEdges = new Map();
215
- for (const edge of edges) {
216
- const sourceNode = graph.nodes.get(edge.source);
217
- const targetNode = graph.nodes.get(edge.target);
218
- if (!sourceNode || !targetNode)
219
- continue;
220
- // We do NOT modify source structure for out-bound edges of collapsed nodes?
221
- // Spec: "Ignore edges from collapsed nodes. Transfer inbound edges to representative."
222
- // Actually, if a node links TO a collapsed node, we repoint the edge to the representative.
223
- // If a collapsed node links to X, we ignore it (PageRank will filter it out).
224
- const actualSource = edge.source;
225
- // repoint target
226
- const actualTarget = targetNode.isCollapsed && targetNode.collapseInto ? targetNode.collapseInto : edge.target;
227
- // Skip self-referential edges caused by repointing
228
- if (actualSource === actualTarget)
229
- continue;
230
- const edgeKey = `${actualSource}|${actualTarget}`;
231
- const existingWeight = updatedEdges.get(edgeKey) || 0;
232
- updatedEdges.set(edgeKey, Math.max(existingWeight, edge.weight)); // deduplicate
233
- }
234
- // Update graph edges in-place
235
- graph.edges = updatedEdges;
236
- // Re-calculate inLinks and outLinks based on collapsed edges
237
- for (const node of graph.getNodes()) {
238
- node.inLinks = 0;
239
- node.outLinks = 0;
240
- }
241
- for (const [edgeKey, _weight] of updatedEdges.entries()) {
242
- const [src, tgt] = edgeKey.split('|');
243
- const sn = graph.nodes.get(src);
244
- const tn = graph.nodes.get(tgt);
245
- if (sn)
246
- sn.outLinks++;
247
- if (tn)
248
- tn.inLinks++;
249
- }
250
- }
251
- }