hololake-system-architecture/language-world/reconstruction/tools/build-notion-index.mjs

346 lines
11 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { DatabaseSync } from "node:sqlite";
function arg(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
}
const configPath = path.resolve(arg("--config", "../index-config.json"));
const dbPath = path.resolve(arg("--db", "../index/notion-world-index.sqlite"));
const reportPath = path.resolve(arg("--report", "../index/corpus-inventory.json"));
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const sourceRoot = path.resolve(config.source_roots[0]);
const previewBytes = Number(config.preview_bytes ?? 4096);
const storedPreviewBytes = 512;
const threshold = Number(config.candidate_score_threshold ?? 6);
const tempDbPath = `${dbPath}.building`;
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
if (fs.existsSync(tempDbPath)) fs.rmSync(tempDbPath);
function normalizePageId(hex) {
const value = hex.toLowerCase();
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
}
function pageIdentity(filename) {
const basename = filename.replace(/\.md$/i, "");
const match = basename.match(/(?:^| )([0-9a-f]{32})$/i);
if (!match) return { title: basename, pageId: null };
return {
title: basename.slice(0, match.index).trim(),
pageId: normalizePageId(match[1])
};
}
function logicalPath(relativePath, title) {
return path.posix.join(path.posix.dirname(relativePath), title);
}
function readPreview(file) {
const fd = fs.openSync(file, "r");
try {
const buffer = Buffer.allocUnsafe(previewBytes);
const length = fs.readSync(fd, buffer, 0, previewBytes, 0);
return buffer.subarray(0, length).toString("utf8").replaceAll("\u0000", "");
} finally {
fs.closeSync(fd);
}
}
function classify(relativePath, title, preview) {
const haystack = `${relativePath}\n${title}\n${preview}`.toLowerCase();
const titlePath = `${relativePath}\n${title}`.toLowerCase();
let score = 0;
const positive = [];
const negative = [];
for (const term of config.positive_terms) {
const normalized = term.toLowerCase();
if (titlePath.includes(normalized)) {
score += 4;
positive.push(term);
} else if (haystack.includes(normalized)) {
score += 1;
positive.push(term);
}
}
for (const term of config.negative_terms) {
const normalized = term.toLowerCase();
if (titlePath.includes(normalized)) {
score -= 5;
negative.push(term);
}
}
const alwaysInclude = config.always_include_path_fragments.some(fragment =>
relativePath.includes(fragment)
);
if (alwaysInclude) score += 100;
const domainHints = [];
const hints = [
["MAIN", ["光湖主域", "domain-main", "dom-main"]],
["SUB", ["光湖分域", "domain-sub", "dom-sub"]],
["ZERO", ["光湖零域", "domain-zero", "dom-zero"]],
["ZEROSENSE", ["光湖零感域", "零感域", "zero-sense", "zerosense"]],
["FIFTH", ["第五域", "fifth domain", "sys-5th", "domain-fifth"]]
];
for (const [domain, terms] of hints) {
if (terms.some(term => haystack.includes(term))) domainHints.push(domain);
}
return {
score,
candidate: score >= threshold,
alwaysInclude,
positive,
negative,
domainHints
};
}
function* walk(root) {
const stack = [root];
while (stack.length) {
const directory = stack.pop();
const entries = fs.readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === ".DS_Store") continue;
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) stack.push(fullPath);
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) yield fullPath;
}
}
}
const db = new DatabaseSync(tempDbPath);
db.exec(`
PRAGMA journal_mode = DELETE;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
CREATE TABLE pages (
file_path TEXT PRIMARY KEY,
relative_path TEXT NOT NULL,
export_batch TEXT NOT NULL,
title TEXT NOT NULL,
page_id TEXT,
logical_path TEXT NOT NULL,
parent_logical_path TEXT,
parent_file_path TEXT,
size_bytes INTEGER NOT NULL,
mtime_ms INTEGER NOT NULL,
first_heading TEXT,
preview TEXT,
relevance_score INTEGER NOT NULL,
is_candidate INTEGER NOT NULL,
always_include INTEGER NOT NULL,
positive_terms TEXT NOT NULL,
negative_terms TEXT NOT NULL,
domain_hints TEXT NOT NULL
);
CREATE INDEX pages_page_id_idx ON pages(page_id);
CREATE INDEX pages_logical_path_idx ON pages(logical_path);
CREATE INDEX pages_candidate_idx ON pages(is_candidate, relevance_score DESC);
CREATE INDEX pages_title_idx ON pages(title);
CREATE TABLE links (
source_file_path TEXT NOT NULL,
target_raw TEXT NOT NULL,
target_file_path TEXT,
target_page_id TEXT,
link_kind TEXT NOT NULL,
resolved INTEGER NOT NULL,
PRIMARY KEY(source_file_path, target_raw)
);
CREATE INDEX links_source_idx ON links(source_file_path);
CREATE INDEX links_target_path_idx ON links(target_file_path);
CREATE INDEX links_target_page_idx ON links(target_page_id);
CREATE TABLE identifiers (
source_file_path TEXT NOT NULL,
identifier TEXT NOT NULL,
PRIMARY KEY(source_file_path, identifier)
);
CREATE INDEX identifiers_value_idx ON identifiers(identifier);
CREATE VIRTUAL TABLE page_search USING fts5(
file_path UNINDEXED,
title,
relative_path,
tokenize='unicode61'
);
`);
const insertPage = db.prepare(`
INSERT INTO pages (
file_path, relative_path, export_batch, title, page_id, logical_path,
parent_logical_path, size_bytes, mtime_ms, first_heading, preview,
relevance_score, is_candidate, always_include, positive_terms,
negative_terms, domain_hints
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertSearch = db.prepare(
"INSERT INTO page_search(file_path, title, relative_path) VALUES (?, ?, ?)"
);
const pageFiles = [...walk(sourceRoot)];
console.log(`DISCOVERED ${pageFiles.length} markdown files`);
db.exec("BEGIN");
let indexed = 0;
for (const file of pageFiles) {
const relativePath = path.relative(sourceRoot, file).split(path.sep).join("/");
const exportBatch = relativePath.split("/")[0];
const { title, pageId } = pageIdentity(path.basename(file));
const preview = readPreview(file);
const headingMatch = preview.match(/^#\s+(.+)$/m);
const classification = classify(relativePath, title, preview);
const stat = fs.statSync(file);
const logical = logicalPath(relativePath, title);
const parentLogical = path.posix.dirname(relativePath) === "."
? null
: path.posix.dirname(relativePath);
insertPage.run(
file,
relativePath,
exportBatch,
title,
pageId,
logical,
parentLogical,
stat.size,
stat.mtimeMs,
headingMatch?.[1]?.trim() ?? null,
preview.slice(0, storedPreviewBytes),
classification.score,
classification.candidate ? 1 : 0,
classification.alwaysInclude ? 1 : 0,
JSON.stringify(classification.positive),
JSON.stringify(classification.negative),
JSON.stringify(classification.domainHints)
);
insertSearch.run(file, title, relativePath);
indexed++;
if (indexed % 10000 === 0) console.log(`INDEXED ${indexed}/${pageFiles.length}`);
}
db.exec("COMMIT");
db.exec(`
UPDATE pages
SET parent_file_path = (
SELECT parent.file_path
FROM pages AS parent
WHERE parent.logical_path = pages.parent_logical_path
AND parent.export_batch = pages.export_batch
ORDER BY parent.size_bytes DESC
LIMIT 1
)
WHERE parent_logical_path IS NOT NULL;
`);
const candidateRows = db.prepare(
"SELECT file_path, relative_path FROM pages WHERE is_candidate = 1"
).all();
console.log(`CANDIDATES ${candidateRows.length}`);
const insertLink = db.prepare(`
INSERT OR IGNORE INTO links(
source_file_path, target_raw, target_file_path, target_page_id, link_kind, resolved
) VALUES (?, ?, ?, ?, ?, ?)
`);
const insertIdentifier = db.prepare(
"INSERT OR IGNORE INTO identifiers(source_file_path, identifier) VALUES (?, ?)"
);
const markdownLink = /\[[^\]]*\]\((?:<([^>]+)>|([^) \t\r\n]+))(?:\s+["'][^"']*["'])?\)/g;
const notionId = /(?:notion\.(?:so|com)\/(?:p\/)?(?:[^/?#]*-)?|\/p\/)([0-9a-f]{32})(?:[?#/]|$)/i;
const identifierPattern = /\b(?:SYS|HLDP-DOMAIN|DOM|GLS|TCS|ICE|GH|BC|CH|AGE|PER|5TH|DEV|AG|NAV|FD-MIG)-[A-Z0-9∞]+(?:-[A-Z0-9∞]+)*\b/gi;
db.exec("BEGIN");
let relationCount = 0;
for (const row of candidateRows) {
const content = fs.readFileSync(row.file_path, "utf8");
const sourceDirectory = path.dirname(row.file_path);
for (const match of content.matchAll(markdownLink)) {
const targetRaw = match[1] ?? match[2];
if (!targetRaw || targetRaw.startsWith("#") || targetRaw.startsWith("mailto:")) continue;
const notionMatch = targetRaw.match(notionId);
if (/^https?:/i.test(targetRaw)) {
insertLink.run(
row.file_path,
targetRaw,
null,
notionMatch ? normalizePageId(notionMatch[1]) : null,
notionMatch ? "notion" : "external",
notionMatch ? 1 : 0
);
continue;
}
const cleanTarget = targetRaw.split("#")[0];
let decoded = cleanTarget;
try { decoded = decodeURIComponent(cleanTarget); } catch {}
const resolvedPath = path.resolve(sourceDirectory, decoded);
const exists = fs.existsSync(resolvedPath);
const identity = pageIdentity(path.basename(resolvedPath));
insertLink.run(
row.file_path,
targetRaw,
exists ? resolvedPath : null,
identity.pageId,
"local",
exists ? 1 : 0
);
relationCount++;
}
const identifiers = new Set(content.match(identifierPattern) ?? []);
for (const identifier of identifiers) {
insertIdentifier.run(row.file_path, identifier.toUpperCase());
}
}
db.exec("COMMIT");
db.exec("PRAGMA optimize");
const stats = {
schema: "guanghu.notion-reconstruction-inventory/v0.1",
generated_at: new Date().toISOString(),
source_root: sourceRoot,
source_size_bytes: Number(
db.prepare("SELECT COALESCE(SUM(size_bytes), 0) AS n FROM pages").get().n
),
markdown_pages: Number(db.prepare("SELECT COUNT(*) AS n FROM pages").get().n),
unique_notion_page_ids: Number(
db.prepare("SELECT COUNT(DISTINCT page_id) AS n FROM pages WHERE page_id IS NOT NULL").get().n
),
duplicate_page_id_groups: Number(
db.prepare(`
SELECT COUNT(*) AS n FROM (
SELECT page_id FROM pages
WHERE page_id IS NOT NULL
GROUP BY page_id HAVING COUNT(*) > 1
)
`).get().n
),
candidate_pages: candidateRows.length,
candidate_links: Number(db.prepare("SELECT COUNT(*) AS n FROM links").get().n),
unresolved_candidate_links: Number(
db.prepare("SELECT COUNT(*) AS n FROM links WHERE link_kind = 'local' AND resolved = 0").get().n
),
identifiers: Number(db.prepare("SELECT COUNT(*) AS n FROM identifiers").get().n),
database_sha256: null
};
db.close();
if (fs.existsSync(dbPath)) fs.rmSync(dbPath);
fs.renameSync(tempDbPath, dbPath);
const databaseHash = crypto.createHash("sha256");
for await (const chunk of fs.createReadStream(dbPath)) databaseHash.update(chunk);
stats.database_sha256 = databaseHash.digest("hex");
fs.writeFileSync(reportPath, `${JSON.stringify(stats, null, 2)}\n`);
console.log(JSON.stringify(stats, null, 2));