Files
dwroller/scripts/extract-armoury.js
2025-08-15 10:35:27 +02:00

169 lines
6.7 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Usage:
node scripts/extract-armoury.js "/absolute/path/to/Deathwatch-Core-Rulebook.pdf" public/deathwatch-armoury.json
Optional env/args to narrow extraction:
--between "Table 55" "Table 56" (slice text between two anchors)
*/
const fs = require('fs')
const path = require('path')
const pdfParse = require('pdf-parse')
function slugify(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'') }
async function extractTextFromPDF(pdfPath) {
const dataBuffer = fs.readFileSync(pdfPath)
const data = await pdfParse(dataBuffer)
return String(data.text || '')
}
function sliceBetween(text, startAnchor, endAnchor) {
if (!startAnchor || !endAnchor) return text
const t = String(text)
const sIdx = t.indexOf(startAnchor)
if (sIdx === -1) return text
const eIdx = t.indexOf(endAnchor, sIdx + startAnchor.length)
if (eIdx === -1) return t.slice(sIdx)
return t.slice(sIdx, eIdx)
}
function cleanName(s) {
let n = String(s||'').trim()
n = n.replace(/^[-–—•\s"'`]+/,'')
n = n.replace(/^(a|an|the)\s+/i,'')
const m = n.match(/([A-Z][A-Za-z0-9'()\-\/ ]{3,80})/)
if (m) n = m[1].trim()
n = n.replace(/\s{2,}/g,' ')
return n
}
function normalizeItem(it) {
const name = cleanName(it.name)
if (!name || name.length < 3) return null
const id = String(it.id||slugify(name))
const category = String(it.category||'Gear').trim()
const req = Math.max(0, Number.isFinite(+it.req) ? +it.req : 0)
const renown = String(it.renown||'None')
const desc = String(it.desc||'').trim()
return { id, name, category, req, renown, desc }
}
const RANKS = ['None','Respected','Distinguished','Famed','Hero']
function detectRenown(s) {
const m = String(s||'').match(/\b(Respected|Distinguished|Famed|Hero)\b/i)
return m ? (m[1][0].toUpperCase()+m[1].slice(1).toLowerCase()) : 'None'
}
// Improved req extraction: try explicit, then tail, then last number
function detectReq(s) {
// 1. Explicit Req/Cost
const reqMatch = String(s||'').match(/\b(Req(?:uisition)?|Cost)\s*[:=\-]?\s*(\d{1,3})\b/i)
if (reqMatch) return Number(reqMatch[2])
// 2. Number before renown
const m2 = String(s||'').match(/(?:^|\s)(\d{1,3})\s+(Respected|Distinguished|Famed|Hero)\b/i)
if (m2) return Number(m2[1])
// 3. Last number in line (common for gear/armor)
const nums = String(s||'').match(/(\d{1,3})(?!.*\d)/)
if (nums) return Number(nums[1])
return 0
}
function detectCategory(s) {
const t = String(s||'').toLowerCase()
if (/grenade|missile/.test(t)) return 'Explosives'
if (/sword|axe|hammer|fist|claw|melee|knife|chainfist|thunder/.test(t)) return 'Melee'
if (/bolter|boltgun|pistol|rifle|gun|cannon|lascannon|plasma|melta|flamer|shotgun|launcher|assault cannon|sniper|cyclone/.test(t)) return 'Ranged'
if (/armor|armour|shield|helmet|helm|terminator|artificer/.test(t)) return 'Armor'
if (/ammo|rounds|shells|backpack|charge pack|fuel|canister/.test(t)) return 'Ammunition'
return 'Gear'
}
const KEYWORDS = /(chainsword|bolter|boltgun|storm bolter|plasma|melta|flamer|assault cannon|shotgun|autogun|autopistol|lasgun|laspistol|lascannon|missile|launcher|grenade|knife|sword|axe|hammer|fist|claw|shield|armor|armour|power|force|stasis|vortex|nova|sniper|needle|auspex|medicae|preysense|telescopic|red[- ]?dot|silencer|suspensor|backpack|ammo|rounds)/i
const CLASS_TOK = /(Basic|Pistol|Heavy|Mounted)\b/i
const ROW_SIGNATURE = /\b\d{1,3}m\b.*S\//i
function extractItemsFromText(text, tableType = 'weapon') {
const lines = text.split(/\n|\.|;|\s{2,}/).map(s=>s.trim()).filter(Boolean)
const items = []
for (const s of lines) {
// Table type awareness
if (tableType === 'weapon') {
if (!CLASS_TOK.test(s) || !ROW_SIGNATURE.test(s)) continue
if (!KEYWORDS.test(s)) continue
} else {
// For gear/armor/upgrades: just need a keyword and a number
if (!KEYWORDS.test(s)) continue
if (!/(\d{1,3})/.test(s)) continue
}
const req = detectReq(s)
const name = cleanName(s)
const renown = detectRenown(s)
const category = detectCategory(s)
const desc = s
const it = normalizeItem({ name, req, renown, category, desc })
if (it) items.push(it)
}
// Deduplicate by name (case-insensitive)
const byName = new Map()
for (const it of items) {
const key = it.name.toLowerCase()
const prev = byName.get(key)
if (!prev) byName.set(key, it)
else {
const rankIdx = (r)=>RANKS.indexOf(r)
const better = (it.req > prev.req) || (it.req === prev.req && rankIdx(it.renown) > rankIdx(prev.renown)) || (it.desc.length > prev.desc.length)
if (better) byName.set(key, it)
}
}
return Array.from(byName.values()).sort((a,b)=> String(a.category).localeCompare(String(b.category)) || a.name.localeCompare(b.name))
}
async function main() {
const input = process.argv[2] || path.resolve('Deathwatch-Core-Rulebook.pdf')
const output = process.argv[3] || path.resolve('public/deathwatch-armoury.json')
// List of [startAnchor, endAnchor, tableType] for all relevant tables
const tableSections = [
['Table 55', 'Table 56', 'weapon'], // Ranged Weapons
['Table 56', 'Table 57', 'weapon'], // Melee Weapons
['Table 57', 'Table 58', 'upgrade'], // Weapon Upgrades
['Table 58', 'Table 59', 'ammo'], // Ammunition
['Table 59', 'Table 510', 'explosive'], // Explosives
['Table 510', 'Table 511', 'armor'], // Armour
['Table 511', 'Table 512', 'gear'], // Gear
]
if (!fs.existsSync(input)) {
console.error('PDF not found:', input)
process.exit(1)
}
let text = await extractTextFromPDF(input)
let allItems = []
for (const [startAnchor, endAnchor, tableType] of tableSections) {
const sectionText = sliceBetween(text, startAnchor, endAnchor)
const items = extractItemsFromText(sectionText, tableType)
allItems = allItems.concat(items)
}
// Deduplicate by name (case-insensitive)
const byName = new Map()
for (const it of allItems) {
const key = it.name.toLowerCase()
const prev = byName.get(key)
if (!prev) byName.set(key, it)
else {
const rankIdx = (r)=>RANKS.indexOf(r)
const better = (it.req > prev.req) || (it.req === prev.req && rankIdx(it.renown) > rankIdx(prev.renown)) || (it.desc.length > prev.desc.length)
if (better) byName.set(key, it)
}
}
const merged = Array.from(byName.values()).sort((a,b)=> String(a.category).localeCompare(String(b.category)) || a.name.localeCompare(b.name))
fs.mkdirSync(path.dirname(output), { recursive: true })
fs.writeFileSync(output, JSON.stringify(merged, null, 2), 'utf8')
console.log('Wrote items:', merged.length, '->', output)
}
if (require.main === module) {
main().catch(err => { console.error(err); process.exit(1) })
}