169 lines
6.7 KiB
JavaScript
169 lines
6.7 KiB
JavaScript
/*
|
||
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 5–5" "Table 5–6" (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 5–5', 'Table 5–6', 'weapon'], // Ranged Weapons
|
||
['Table 5–6', 'Table 5–7', 'weapon'], // Melee Weapons
|
||
['Table 5–7', 'Table 5–8', 'upgrade'], // Weapon Upgrades
|
||
['Table 5–8', 'Table 5–9', 'ammo'], // Ammunition
|
||
['Table 5–9', 'Table 5–10', 'explosive'], // Explosives
|
||
['Table 5–10', 'Table 5–11', 'armor'], // Armour
|
||
['Table 5–11', 'Table 5–12', '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) })
|
||
}
|