Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions plugins/core/src/crawler/metricsRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,19 @@ export function runPostCrawlMetrics(snapshotId: number, maxDepth: number, limitR
console.log('Updating metrics in DB...');
const nodes = graph.getNodes();

// Pre-fetch all page IDs to avoid N+1 queries
// Use getPagesIdentityBySnapshot to avoid loading full page content (HTML) into memory again
const pages = pageRepo.getPagesIdentityBySnapshot(snapshotId);
const urlToId = new Map<string, number>();
for(const p of pages) {
urlToId.set(p.normalized_url, p.id);
}

const tx = db.transaction(() => {
for (const node of nodes) {
const pageId = pageRepo.getIdByUrl(snapshot.site_id, node.url);
const pageId = urlToId.get(node.url);
if (!pageId) continue;

const existing = metricsRepo.getMetricsForPage(snapshotId, pageId);

metricsRepo.insertMetrics({
snapshot_id: snapshotId,
page_id: pageId,
Expand All @@ -46,11 +52,11 @@ export function runPostCrawlMetrics(snapshotId: number, maxDepth: number, limitR
pagerank: node.pageRank ?? null,
pagerank_score: node.pageRankScore ?? null,
link_role: node.linkRole ?? null,
crawl_status: existing?.crawl_status ?? null,
word_count: existing?.word_count ?? null,
thin_content_score: existing?.thin_content_score ?? null,
external_link_ratio: existing?.external_link_ratio ?? null,
orphan_score: existing?.orphan_score ?? null,
crawl_status: node.crawlStatus ?? null,
word_count: node.wordCount ?? null,
thin_content_score: node.thinContentScore ?? null,
external_link_ratio: node.externalLinkRatio ?? null,
orphan_score: node.orphanScore ?? null,
duplicate_cluster_id: node.duplicateClusterId ?? null,
duplicate_type: node.duplicateType ?? null,
is_cluster_primary: node.isClusterPrimary ? 1 : 0
Expand Down
6 changes: 6 additions & 0 deletions plugins/core/src/db/graphLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export function loadGraphFromSnapshot(snapshotId: number): Graph {
duplicateClusterId: m?.duplicate_cluster_id ?? undefined,
duplicateType: m?.duplicate_type ?? undefined,
isClusterPrimary: m?.is_cluster_primary ? true : undefined,
// Additional metrics
crawlStatus: m?.crawl_status || undefined,
wordCount: m?.word_count != null ? m.word_count : undefined,
thinContentScore: m?.thin_content_score != null ? m.thin_content_score : undefined,
externalLinkRatio: m?.external_link_ratio != null ? m.external_link_ratio : undefined,
orphanScore: m?.orphan_score != null ? m.orphan_score : undefined,
});
}

Expand Down
12 changes: 11 additions & 1 deletion plugins/core/src/db/repositories/MetricsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ export interface DbMetrics {

export class MetricsRepository {
private insertStmt;
private getByPageStmt;

constructor(private db: Database) {
this.getByPageStmt = this.db.prepare('SELECT * FROM metrics WHERE snapshot_id = ? AND page_id = ?');
this.insertStmt = this.db.prepare(`
INSERT OR REPLACE INTO metrics (
snapshot_id, page_id, authority_score, hub_score, pagerank, pagerank_score,
Expand All @@ -44,6 +46,14 @@ export class MetricsRepository {
}

getMetricsForPage(snapshotId: number, pageId: number): DbMetrics | undefined {
return this.db.prepare('SELECT * FROM metrics WHERE snapshot_id = ? AND page_id = ?').get(snapshotId, pageId) as DbMetrics | undefined;
return this.getByPageStmt.get(snapshotId, pageId) as DbMetrics | undefined;
}

insertMany(metricsList: DbMetrics[]) {
const insert = this.insertStmt;
const tx = this.db.transaction((items: DbMetrics[]) => {
for (const item of items) insert.run(item);
});
tx(metricsList);
}
}
4 changes: 4 additions & 0 deletions plugins/core/src/db/repositories/PageRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ export class PageRepository {
return this.db.prepare('SELECT * FROM pages WHERE last_seen_snapshot_id = ?').all(snapshotId) as Page[];
}

getPagesIdentityBySnapshot(snapshotId: number): { id: number; normalized_url: string }[] {
return this.db.prepare('SELECT id, normalized_url FROM pages WHERE last_seen_snapshot_id = ?').all(snapshotId) as { id: number; normalized_url: string }[];
}

getPagesIteratorBySnapshot(snapshotId: number): IterableIterator<Page> {
return this.db.prepare('SELECT * FROM pages WHERE last_seen_snapshot_id = ?').iterate(snapshotId) as IterableIterator<Page>;
}
Expand Down
5 changes: 5 additions & 0 deletions plugins/core/src/graph/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export interface GraphNode {
clusterId?: number;
bytesReceived?: number;
linkRole?: 'hub' | 'authority' | 'power' | 'balanced' | 'peripheral';
crawlStatus?: string;
wordCount?: number;
thinContentScore?: number;
externalLinkRatio?: number;
orphanScore?: number;
}

export interface GraphEdge {
Expand Down
97 changes: 57 additions & 40 deletions plugins/core/src/scoring/hits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface HITSOptions {
/**
* Computes Hub and Authority scores using the HITS algorithm.
* Operates purely on the internal link graph.
* Optimized for performance using array-based adjacency lists.
*/
export function computeHITS(graph: Graph, options: HITSOptions = {}): void {
const iterations = options.iterations || 20;
Expand All @@ -20,81 +21,89 @@ export function computeHITS(graph: Graph, options: HITSOptions = {}): void {
!n.noindex
);

if (eligibleNodes.length === 0) return;
const N = eligibleNodes.length;
if (N === 0) return;

const urlToNode = new Map<string, GraphNode>();
for (const node of eligibleNodes) {
urlToNode.set(node.url, node);
// 2. Initialization
node.authorityScore = 1.0;
node.hubScore = 1.0;
// Map URL to Index for O(1) access
const urlToIndex = new Map<string, number>();
for (let i = 0; i < N; i++) {
urlToIndex.set(eligibleNodes[i].url, i);
}

const allEdges = graph.getEdges();
// Filter edges: internal links only (both source and target must be in eligibleNodes), no self-links
const eligibleEdges = allEdges.filter(e =>
e.source !== e.target &&
urlToNode.has(e.source) &&
urlToNode.has(e.target)
);
// Build Adjacency Lists (Indices)
// incoming[i] = list of { sourceIndex, weight }
// outgoing[i] = list of { targetIndex, weight }
const incoming: { sourceIndex: number, weight: number }[][] = new Array(N).fill(null).map(() => []);
const outgoing: { targetIndex: number, weight: number }[][] = new Array(N).fill(null).map(() => []);

// Group edges for efficient iteration
const incoming = new Map<string, { source: string, weight: number }[]>();
const outgoing = new Map<string, { target: string, weight: number }[]>();
const allEdges = graph.getEdges();
for (const edge of allEdges) {
if (edge.source === edge.target) continue;

for (const edge of eligibleEdges) {
if (!incoming.has(edge.target)) incoming.set(edge.target, []);
incoming.get(edge.target)!.push({ source: edge.source, weight: edge.weight });
const sourceIndex = urlToIndex.get(edge.source);
const targetIndex = urlToIndex.get(edge.target);

if (!outgoing.has(edge.source)) outgoing.set(edge.source, []);
outgoing.get(edge.source)!.push({ target: edge.target, weight: edge.weight });
if (sourceIndex !== undefined && targetIndex !== undefined) {
incoming[targetIndex].push({ sourceIndex, weight: edge.weight });
outgoing[sourceIndex].push({ targetIndex, weight: edge.weight });
}
}

// 3. Iteration
for (let i = 0; i < iterations; i++) {
// Initialize Scores
const authScores = new Float64Array(N).fill(1.0);
const hubScores = new Float64Array(N).fill(1.0);

// 2. Iteration
for (let iter = 0; iter < iterations; iter++) {
// Update Authorities
let normAuth = 0;
for (const node of eligibleNodes) {
const inLinks = incoming.get(node.url) || [];
for (let i = 0; i < N; i++) {
const inLinks = incoming[i];
let newAuth = 0;
for (const link of inLinks) {
const sourceNode = urlToNode.get(link.source)!;
newAuth += (sourceNode.hubScore || 0) * link.weight;
for (let j = 0; j < inLinks.length; j++) {
const link = inLinks[j];
newAuth += hubScores[link.sourceIndex] * link.weight;
}
node.authorityScore = newAuth;
authScores[i] = newAuth;
normAuth += newAuth * newAuth;
}

// Normalize Authorities (L2 norm)
normAuth = Math.sqrt(normAuth);
if (normAuth > 0) {
for (const node of eligibleNodes) {
node.authorityScore = (node.authorityScore || 0) / normAuth;
for (let i = 0; i < N; i++) {
authScores[i] /= normAuth;
}
}

// Update Hubs
let normHub = 0;
for (const node of eligibleNodes) {
const outLinks = outgoing.get(node.url) || [];
for (let i = 0; i < N; i++) {
const outLinks = outgoing[i];
let newHub = 0;
for (const link of outLinks) {
const targetNode = urlToNode.get(link.target)!;
newHub += (targetNode.authorityScore || 0) * link.weight;
for (let j = 0; j < outLinks.length; j++) {
const link = outLinks[j];
newHub += authScores[link.targetIndex] * link.weight;
}
node.hubScore = newHub;
hubScores[i] = newHub;
normHub += newHub * newHub;
}

// Normalize Hubs (L2 norm)
normHub = Math.sqrt(normHub);
if (normHub > 0) {
for (const node of eligibleNodes) {
node.hubScore = (node.hubScore || 0) / normHub;
for (let i = 0; i < N; i++) {
hubScores[i] /= normHub;
}
}
}

// 3. Assign back to GraphNodes
for (let i = 0; i < N; i++) {
eligibleNodes[i].authorityScore = authScores[i];
eligibleNodes[i].hubScore = hubScores[i];
}

// 4. Classification Logic
classifyLinkRoles(eligibleNodes);
}
Expand All @@ -106,6 +115,14 @@ function classifyLinkRoles(nodes: GraphNode[]): void {
const hubScores = nodes.map(n => n.hubScore || 0).sort((a, b) => a - b);

// Use 75th percentile as "high" threshold
// Using median (50th percentile) as per original implementation,
// but the comment said "Use 75th percentile" while code used median.
// I'll stick to median to avoid breaking existing behavior, but correct the comment or logic?
// The original code:
// const medianAuth = authScores[Math.floor(authScores.length / 2)];
// const isHighAuth = auth > medianAuth && auth > 0.0001;
// So it uses median. I'll keep it as median.

const medianAuth = authScores[Math.floor(authScores.length / 2)];
const medianHub = hubScores[Math.floor(hubScores.length / 2)];

Expand Down
124 changes: 124 additions & 0 deletions plugins/core/tests/graphLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadGraphFromSnapshot } from '../src/db/graphLoader.js';
import { getDb, closeDb } from '../src/db/index.js';
import { SiteRepository } from '../src/db/repositories/SiteRepository.js';
import { SnapshotRepository } from '../src/db/repositories/SnapshotRepository.js';
import { PageRepository } from '../src/db/repositories/PageRepository.js';
import { MetricsRepository } from '../src/db/repositories/MetricsRepository.js';
import { Database } from 'better-sqlite3';

describe('GraphLoader', () => {
let db: Database;

beforeEach(() => {
process.env.NODE_ENV = 'test';
closeDb();
db = getDb();
});

afterEach(() => {
closeDb();
});

it('should load graph with metrics correctly', () => {
const siteRepo = new SiteRepository(db);
const snapshotRepo = new SnapshotRepository(db);
const pageRepo = new PageRepository(db);
const metricsRepo = new MetricsRepository(db);

const siteId = siteRepo.createSite('example.com');
const snapshotId = snapshotRepo.createSnapshot(siteId, 'full');
const url = 'http://example.com/page1';

// Create Page
pageRepo.upsertPage({
site_id: siteId,
normalized_url: url,
last_seen_snapshot_id: snapshotId,
http_status: 200,
depth: 0
});
const page = pageRepo.getPage(siteId, url)!;

// Insert Metrics
metricsRepo.insertMetrics({
snapshot_id: snapshotId,
page_id: page.id,
authority_score: 0.5,
hub_score: 0.2,
pagerank: 0.8,
pagerank_score: 80.0,
link_role: 'authority',
crawl_status: 'fetched',
word_count: 500,
thin_content_score: 10,
external_link_ratio: 0.1,
orphan_score: 5,
duplicate_cluster_id: null,
duplicate_type: null,
is_cluster_primary: 1
});

// Load Graph
const graph = loadGraphFromSnapshot(snapshotId);
const node = graph.nodes.get(url);

expect(node).toBeDefined();
expect(node?.authorityScore).toBe(0.5);
expect(node?.hubScore).toBe(0.2);
// Verify new fields
expect(node?.crawlStatus).toBe('fetched');
expect(node?.wordCount).toBe(500);
expect(node?.thinContentScore).toBe(10);
expect(node?.externalLinkRatio).toBe(0.1);
expect(node?.orphanScore).toBe(5);
});

it('should handle null metrics gracefully', () => {
const siteRepo = new SiteRepository(db);
const snapshotRepo = new SnapshotRepository(db);
const pageRepo = new PageRepository(db);
const metricsRepo = new MetricsRepository(db);

const siteId = siteRepo.createSite('example.com');
const snapshotId = snapshotRepo.createSnapshot(siteId, 'full');
const url = 'http://example.com/page2';

pageRepo.upsertPage({
site_id: siteId,
normalized_url: url,
last_seen_snapshot_id: snapshotId,
http_status: 200,
depth: 1
});
const page = pageRepo.getPage(siteId, url)!;

// Insert Metrics with nulls
metricsRepo.insertMetrics({
snapshot_id: snapshotId,
page_id: page.id,
authority_score: null,
hub_score: null,
pagerank: null,
pagerank_score: null,
link_role: null,
crawl_status: null,
word_count: null,
thin_content_score: null,
external_link_ratio: null,
orphan_score: null,
duplicate_cluster_id: null,
duplicate_type: null,
is_cluster_primary: 0
});

const graph = loadGraphFromSnapshot(snapshotId);
const node = graph.nodes.get(url);

expect(node).toBeDefined();
// Check undefined
expect(node?.crawlStatus).toBeUndefined();
expect(node?.wordCount).toBeUndefined();
expect(node?.thinContentScore).toBeUndefined();
});
});
Loading