- Implemented `upsert-allewis-from-5001.js` to fetch and update Allewis data from API. - Created `upsert-missing-movement-from-5001.js` to fill missing movement and wounds data from API. - Developed `upsert-missing-movement-from-pdfs.js` to extract movement and wounds from PDF text. - Added `upsert-missing-profiles-from-pdfs.js` to fill missing profiles from PDF data. - Introduced `upsert-missing-wounds-from-pdfs.js` to update wounds data from PDF sources. - Created `validate-bestiary.js` to validate the structure of the bestiary JSON. - Updated `BestiaryTab.jsx` component to display bestiary data with improved structure and search functionality. - Added tests for `BestiaryTab` to ensure proper rendering and functionality.
148 lines
4.5 KiB
JavaScript
148 lines
4.5 KiB
JavaScript
#!/usr/bin/env node
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const https = require('https');
|
|
// lightweight HTML parsing (avoid loading cheerio to prevent extraneous deps)
|
|
|
|
|
|
const BESTIARY_URL = 'https://www.40krpgtools.com/bestiary/';
|
|
const DB_PATH = path.resolve(__dirname, '..', 'database', 'deathwatch-enemies-highconfidence.json');
|
|
const OUT_PATH = path.resolve(__dirname, '..', 'database', 'bestiary-matches.json');
|
|
|
|
function normalizeName(s) {
|
|
return (s||'').toString().toLowerCase()
|
|
.replace(/[\u2018\u2019\u201c\u201d]/g,'')
|
|
.replace(/[^a-z0-9 ]+/g,' ')
|
|
.replace(/\s+/g,' ').trim();
|
|
}
|
|
|
|
function levenshtein(a, b) {
|
|
a = a || '';
|
|
b = b || '';
|
|
const m = a.length, n = b.length;
|
|
if (!m) return n;
|
|
if (!n) return m;
|
|
const dp = Array.from({length: m+1}, () => Array(n+1).fill(0));
|
|
for (let i=0;i<=m;i++) dp[i][0]=i;
|
|
for (let j=0;j<=n;j++) dp[0][j]=j;
|
|
for (let i=1;i<=m;i++){
|
|
for (let j=1;j<=n;j++){
|
|
const cost = a[i-1]===b[j-1] ? 0 : 1;
|
|
dp[i][j] = Math.min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost);
|
|
}
|
|
}
|
|
return dp[m][n];
|
|
}
|
|
|
|
async function fetchBestiary() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = https.get(BESTIARY_URL, { timeout: 20000 }, (res) => {
|
|
if (res.statusCode && res.statusCode >= 400) return reject(new Error('HTTP '+res.statusCode));
|
|
let buf = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', d => buf += d);
|
|
res.on('end', () => resolve(buf));
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(new Error('timeout')); });
|
|
});
|
|
}
|
|
|
|
function stripTags(s) {
|
|
return s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function parseBestiary(html) {
|
|
const rows = [];
|
|
// extract all table row contents
|
|
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
let m;
|
|
while ((m = trRe.exec(html)) !== null) {
|
|
const tr = m[1];
|
|
const tdRe = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
|
const cols = [];
|
|
let mm;
|
|
while ((mm = tdRe.exec(tr)) !== null) {
|
|
cols.push(stripTags(mm[1]));
|
|
}
|
|
if (cols.length >= 1) {
|
|
const name = cols[0] || '';
|
|
const page = cols[cols.length-1] || '';
|
|
const book = cols[cols.length-2] || '';
|
|
if (name) rows.push({name, book, page});
|
|
}
|
|
}
|
|
// fallback: look for pipe table lines
|
|
if (rows.length === 0) {
|
|
const lines = html.split('\n');
|
|
for (const line of lines) {
|
|
if (/\|/.test(line)) {
|
|
const parts = line.replace(/<[^>]+>/g,'').split('|').map(p=>p.trim()).filter(Boolean);
|
|
if (parts.length >= 3) {
|
|
const name = parts[0];
|
|
const page = parts[parts.length-1];
|
|
const book = parts[parts.length-2];
|
|
rows.push({name, book, page});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function bestMatchFor(name, db) {
|
|
const n = normalizeName(name);
|
|
// exact normalized match
|
|
for (const e of db) {
|
|
if (normalizeName(e.name) === n) return {score:0, entry:e, method:'exact'};
|
|
}
|
|
// substring match
|
|
for (const e of db) {
|
|
if (normalizeName(e.name).includes(n) || n.includes(normalizeName(e.name))) return {score:1, entry:e, method:'substr'};
|
|
}
|
|
// levenshtein best candidate
|
|
let best = null;
|
|
for (const e of db) {
|
|
const cand = normalizeName(e.name);
|
|
const d = levenshtein(n, cand);
|
|
if (!best || d < best.d) best = {d, entry:e};
|
|
}
|
|
if (best) {
|
|
return {score: best.d, entry: best.entry, method:'lev'};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function main(){
|
|
console.log('Reading DB:', DB_PATH);
|
|
if (!fs.existsSync(DB_PATH)) {
|
|
console.error('DB file not found:', DB_PATH);
|
|
process.exit(2);
|
|
}
|
|
const db = JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
|
|
console.log('Fetching bestiary page...');
|
|
const html = await fetchBestiary();
|
|
const entries = parseBestiary(html);
|
|
console.log('Parsed bestiary entries:', entries.length);
|
|
const out = [];
|
|
let matched = 0;
|
|
for (const be of entries) {
|
|
const m = bestMatchFor(be.name, db);
|
|
const rec = {bestiaryName: be.name, book: be.book, page: be.page, matched: !!m, method: m?m.method:null};
|
|
if (m && m.entry) {
|
|
rec.matchName = m.entry.name;
|
|
rec.matchSources = m.entry.sources || m.entry.sources || [];
|
|
rec.matchPages = m.entry.pages || m.entry.pages || [];
|
|
matched++;
|
|
}
|
|
out.push(rec);
|
|
}
|
|
const summary = {total: entries.length, matched, unmatched: entries.length-matched};
|
|
fs.writeFileSync(OUT_PATH, JSON.stringify({summary, matches: out}, null, 2));
|
|
console.log('Wrote matches to', OUT_PATH, 'summary:', summary);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch(err=>{ console.error(err); process.exit(1); });
|
|
}
|