bigtech-sucks/packages/core/db/ingest/run.mjs

153 lines
5.9 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* Self-authoring ingest.
*
* The roster writes itself. The scores do not.
*
* DISCOVERY automatic. A source names a piece of software and a usage number.
* ASSESSMENT never automatic. A score requires a written rationale and tier-1
* or tier-2 evidence, and the build gate enforces that.
*
* That split is the whole design. If the ingest wrote scores, the oath on the
* front page would be a lie the same afternoon.
*
* Run: npm run db:ingest
* Dry run: npm run db:ingest -- --dry
*/
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ADAPTERS, looksLikeUserSoftware } from './sources.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const DB_PATH = join(HERE, '..', 'bigtech.db');
const DRY = process.argv.includes('--dry');
/** What to go looking for, and what to rank it by. */
const TARGETS = [
{ category: 'messaging', source: 'flathub', query: 'messaging' },
{ category: 'messaging', source: 'github', query: 'messaging' },
{ category: 'email', source: 'flathub', query: 'email' },
{ category: 'email', source: 'github', query: 'mail-client' },
{ category: 'passwords', source: 'flathub', query: 'password' },
{ category: 'browser', source: 'flathub', query: 'web browser' },
{ category: 'notes', source: 'flathub', query: 'notes' },
{ category: 'files', source: 'github', query: 'file-sync' },
{ category: 'photos', source: 'flathub', query: 'photo' },
// Flathub is a curated store of end-user Linux apps, so relevance is free.
// GitHub topics are uncurated, so relevance has to be earned by a classifier.
// Prefer the store wherever it has the category. Verified 2026-10-29: the
// GitHub topic 'local-llm' returned Langroid and Pangu.js, neither of which
// is a product a person installs.
{ category: 'ai', source: 'flathub', query: 'llm' },
];
const slug = (s) =>
s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
const now = new Date().toISOString();
async function main() {
const db = new DatabaseSync(DB_PATH);
db.exec('PRAGMA foreign_keys = ON');
db.exec(readFileSync(join(HERE, '..', 'schema.sql'), 'utf8'));
const existing = new Map(
db.prepare('SELECT id, LOWER(name) AS n FROM product').all().map((r) => [r.n, r.id]),
);
const taken = new Set(db.prepare('SELECT id FROM product').all().map((r) => r.id));
const insProduct = db.prepare(`
INSERT OR IGNORE INTO product
(id, vendor_id, name, category_id, platform, source_url, license, sovereign, boss, art_slot, monthly_cost, notes)
VALUES (?, 'unknown', ?, ?, ?, ?, ?, 0, 0, NULL, NULL, ?)`);
const insDiscovery = db.prepare(`
INSERT OR IGNORE INTO discovery
(product_id, source, source_url, usage_signal, usage_metric, observed_at, raw)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
const stats = { targets: 0, rowsSeen: 0, newProducts: 0, newReceipts: 0, failed: [] };
for (const target of TARGETS) {
const adapter = ADAPTERS[target.source];
if (!adapter) { console.warn(` ! unknown source ${target.source}`); continue; }
stats.targets++;
let rows;
try {
rows = await adapter(target.query);
} catch (err) {
stats.failed.push(`${target.source}/${target.query}: ${err.message}`);
console.warn(` FAIL ${target.source}/${target.query} ${err.message}`);
continue;
}
let added = 0;
let dropped = 0;
for (const row of rows) {
// Relevance gate first. A highly starred message broker is not a product
// a person chooses, and a roster full of them is a roster nobody trusts.
if (row.platform === 'source' && !looksLikeUserSoftware(row)) { dropped++; continue; }
stats.rowsSeen++;
const key = row.name.toLowerCase();
let id = existing.get(key);
if (!id) {
let candidate = slug(row.name);
if (taken.has(candidate)) candidate = `${candidate}-${slug(target.category)}`;
if (taken.has(candidate)) continue;
if (!DRY) {
insProduct.run(
candidate, row.name, target.category, row.platform,
row.sourceUrl, row.raw?.license ?? null,
`Discovered by ${target.source} ingest on ${now.slice(0, 10)}. Unassessed.`,
);
}
taken.add(candidate);
existing.set(key, candidate);
id = candidate;
stats.newProducts++;
added++;
}
if (!DRY) {
const r = insDiscovery.run(
id, target.source, row.sourceUrl, row.usage, row.usageMetric,
now, JSON.stringify(row.raw),
);
if (r.changes) stats.newReceipts++;
}
}
console.log(
` ${target.source.padEnd(8)} ${String(target.query).padEnd(16)} ${target.category.padEnd(11)} ${String(rows.length).padStart(3)} seen, ${added} new, ${dropped} not-an-app`,
);
}
const backlog = db.prepare('SELECT * FROM v_backlog LIMIT 12').all();
const cov = db.prepare('SELECT * FROM v_coverage').all();
const assessed = cov.filter((c) => c.status !== 'unassessed').length;
db.close();
console.log('');
console.log(DRY ? 'DRY RUN, nothing written' : 'INGEST COMPLETE');
console.log(` targets attempted : ${stats.targets}`);
console.log(` rows seen : ${stats.rowsSeen}`);
console.log(` new products : ${stats.newProducts}`);
console.log(` new receipts : ${stats.newReceipts}`);
console.log(` failed sources : ${stats.failed.length}`);
console.log('');
console.log(`COVERAGE ${assessed} assessed / ${cov.length} products`);
if (backlog.length) {
console.log('');
console.log('BACKLOG, ordered by usage');
for (const b of backlog) {
console.log(` ${String(b.usage).padStart(8)} ${b.name.padEnd(28)} ${String(b.category_id).padEnd(11)} via ${b.found_via}`);
}
}
}
main().catch((err) => { console.error(err); process.exit(1); });