/** * Taiwan 大樂透 (Lotto 6/49) Scraper - Uses the JSON API endpoint * Directly calls the internal JSON API to fetch all historical draw data * Numbers: 1-49, pick 6 regular numbers + 1 special number (特別號) * Draws: Monday and Thursday each week */ const https = require('https'); const fs = require('fs'); // The site loads data via this JSON API - much cleaner than HTML parsing const JSON_API = 'https://www.pilio.idv.tw/Json_ltonew.asp'; function httpsRequest(urlStr, method, postData) { return new Promise((resolve, reject) => { const url = new URL(urlStr); const body = postData || ''; const opts = { hostname: url.hostname, port: 443, path: url.pathname + url.search, method: method || 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json, text/javascript, */*', 'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8', 'Referer': 'https://www.pilio.idv.tw/ltobig/list.asp', 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) } }; const req = https.request(opts, (res) => { let data = ''; res.setEncoding('utf8'); res.on('data', chunk => { data += chunk; }); res.on('end', () => resolve({ status: res.statusCode, body: data })); }); req.on('error', reject); req.setTimeout(15000, () => { req.destroy(); reject(new Error('Request timeout')); }); if (body) req.write(body); req.end(); }); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Fetch a page of data using the JSON API // Lindex: the index to fetch from (0 = oldest, use dex value from response to continue) // Ldesc=0 means ascending (old to new), 1 means descending (new to old) async function fetchJsonBatch(lindex, ldesc) { const postBody = `Lkind=ltobig&Lindex=${lindex}&Ldesc=${ldesc}`; const res = await httpsRequest(JSON_API, 'POST', postBody); if (res.status !== 200) { throw new Error(`HTTP ${res.status}`); } try { return JSON.parse(res.body); } catch (e) { // Try to clean and parse const cleaned = res.body.trim(); if (!cleaned || cleaned === 'null' || cleaned === '{}') return null; throw new Error(`JSON parse error: ${e.message}, body: ${res.body.slice(0, 200)}`); } } // The paginated HTML endpoint as fallback async function fetchPageHtml(pageNum) { const url = `https://www.pilio.idv.tw/ltobig/list.asp?indexpage=${pageNum}&orderby=old`; const res = await httpsRequest(url, 'GET'); return res.body; } function parseLottoItem(item) { // item.date format: "01/01
07(一)" = MM/DD
YY(DayOfWeek) // item.num format: "02, 06, 11, 23, 38, 44" (6 numbers for 大樂透) // item.snum format: "07" (special number 特別號), or may be 7th number in item.num // item.dex: serial index number if (!item.date || !item.num) return null; // Parse date: split on
tag const dateParts = item.date.split(//i); const mmdd = dateParts[0] ? dateParts[0].replace(/<[^>]+>/g, '').trim() : ''; const yearDow = dateParts[1] ? dateParts[1].replace(/<[^>]+>/g, '').trim() : ''; // Extract MM and DD const mmddMatch = mmdd.match(/(\d{1,2})[\/-](\d{2})/); const mm = mmddMatch ? mmddMatch[1].padStart(2, '0') : '??'; const dd = mmddMatch ? mmddMatch[2] : '??'; // Extract 2-digit year and day-of-week: e.g. "07(一)" const ydMatch = yearDow.match(/(\d{1,2})\(([^)]+)\)/); let fullYear = '????'; let dow = ''; if (ydMatch) { const yy = parseInt(ydMatch[1]); fullYear = yy < 50 ? `20${String(yy).padStart(2, '0')}` : `19${String(yy).padStart(2,'0')}`; dow = ydMatch[2]; } const dateStr = `${fullYear}/${mm}/${dd}${dow ? ' (' + dow + ')' : ''}`; // Clean and parse numbers (comma-separated: "02, 06, 11, 23, 38, 44") const numText = item.num.replace(/<[^>]+>/gi, '').replace(/ /gi, ' '); const nums = numText.match(/\d{1,2}/g) || []; const validNums = nums .filter(n => parseInt(n) >= 1 && parseInt(n) <= 49) .map(n => String(parseInt(n)).padStart(2, '0')); // Determine special number (特別號): // Priority 1: item.snum field // Priority 2: 7th number in item.num let specialNum = null; // API returns special number in item.sp field if (item.sp) { const snumText = String(item.sp).replace(/<[^>]+>/gi, '').trim(); const snumMatch = snumText.match(/\d{1,2}/); if (snumMatch) { const sn = parseInt(snumMatch[0]); if (sn >= 1 && sn <= 49) { specialNum = String(sn).padStart(2, '0'); } } } // If no snum field and we have 7 numbers in item.num, use the 7th as special if (!specialNum && validNums.length >= 7) { specialNum = validNums[6]; } // Need exactly 6 regular numbers const regularNums = validNums.slice(0, 6); if (regularNums.length < 6) return null; // If special not found yet, use a fallback placeholder if (!specialNum) { // Attempt to derive from raw data length — if exactly 6, special is unknown specialNum = '00'; } return { date: dateStr, dateSort: `${fullYear}${mm}${dd}`, // for sorting numbers: regularNums, special: specialNum, dex: item.dex }; } // ─── Try the JSON API approach ─────────────────────────────────────────────── async function testJsonApi() { console.log('Testing JSON API endpoint...'); // Try with Lindex=0 (fetch from beginning) try { const result = await fetchJsonBatch(0, 0); if (result && result.lotto && result.lotto.length > 0) { console.log('JSON API works! Sample data:'); console.log('Total in batch:', result.lotto.length); console.log('First item:', JSON.stringify(result.lotto[0])); console.log('Last item:', JSON.stringify(result.lotto[result.lotto.length - 1])); return { works: true, data: result }; } } catch (e) { console.log('JSON API error:', e.message); } return { works: false }; } async function main() { // First test the JSON API const test = await testJsonApi(); if (test.works) { console.log('\nUsing JSON API to collect all data...'); await collectViaJsonApi(test.data); } else { console.log('\nJSON API not available, falling back to HTML scraping...'); await collectViaHtml(); } } async function collectViaJsonApi(firstBatch) { const allDraws = []; // Process first batch for (const item of firstBatch.lotto) { const draw = parseLottoItem(item); if (draw) allDraws.push(draw); } let lastDex = firstBatch.lotto[firstBatch.lotto.length - 1].dex; let batchNum = 1; // Keep fetching batches until no more data while (true) { await sleep(300); batchNum++; process.stdout.write(`\r📥 Batch ${batchNum}, fetching after dex=${lastDex}... (${allDraws.length} draws)`); try { const batch = await fetchJsonBatch(lastDex, 0); if (!batch || !batch.lotto || batch.lotto.length === 0) { console.log('\n✅ No more data.'); break; } let newCount = 0; for (const item of batch.lotto) { const draw = parseLottoItem(item); if (draw) { allDraws.push(draw); newCount++; } } if (newCount === 0) { console.log('\n✅ No new items in batch, done.'); break; } lastDex = batch.lotto[batch.lotto.length - 1].dex; } catch (e) { console.log(`\n⚠️ Batch ${batchNum} failed: ${e.message}`); await sleep(1000); } } await finalize(allDraws); } async function collectViaHtml() { // Use the paginated HTML approach as fallback const page1 = await fetchPageHtml(1); const pageCountMatch = page1.match(/indexpage=(\d+)&orderby=old">\s*(?:最末頁|Last)/); const totalPages = pageCountMatch ? parseInt(pageCountMatch[1]) : 100; console.log(`📄 Total pages: ${totalPages}`); const allDraws = []; for (let p = 1; p <= totalPages; p++) { process.stdout.write(`\r📥 Page ${p}/${totalPages} (${allDraws.length} draws)`); try { const html = p === 1 ? page1 : await fetchPageHtml(p); // Try to extract embedded JSON const jsonMatch = html.match(/\{"lotto":\[[\s\S]*?\]\}/); if (jsonMatch) { try { const obj = JSON.parse(jsonMatch[0]); for (const item of obj.lotto) { const draw = parseLottoItem(item); if (draw) allDraws.push(draw); } } catch (e) { // ignore parse errors per page } } await sleep(250); } catch (e) { console.log(`\n⚠️ Page ${p} failed: ${e.message}`); } } await finalize(allDraws); } // ─── Prediction Model ──────────────────────────────────────────────────────── function computeFrequency(draws) { const freq = {}; for (let i = 1; i <= 49; i++) freq[String(i).padStart(2, '0')] = 0; for (const draw of draws) { for (const n of draw.numbers) { const key = String(parseInt(n)).padStart(2, '0'); if (freq[key] !== undefined) freq[key]++; } } return freq; } function computeSpecialFrequency(draws) { const freq = {}; for (let i = 1; i <= 49; i++) freq[String(i).padStart(2, '0')] = 0; for (const draw of draws) { if (draw.special && draw.special !== '00') { const key = String(parseInt(draw.special)).padStart(2, '0'); if (freq[key] !== undefined) freq[key]++; } } return freq; } function getLastSeenMap(draws) { const lastSeen = {}; for (let i = 1; i <= 49; i++) lastSeen[String(i).padStart(2, '0')] = draws.length; for (let i = draws.length - 1; i >= 0; i--) { for (const num of draws[i].numbers) { const key = String(parseInt(num)).padStart(2, '0'); if (lastSeen[key] === draws.length) lastSeen[key] = draws.length - 1 - i; } } return lastSeen; } function getSpecialLastSeenMap(draws) { const lastSeen = {}; for (let i = 1; i <= 49; i++) lastSeen[String(i).padStart(2, '0')] = draws.length; for (let i = draws.length - 1; i >= 0; i--) { if (draws[i].special && draws[i].special !== '00') { const key = String(parseInt(draws[i].special)).padStart(2, '0'); if (lastSeen[key] === draws.length) lastSeen[key] = draws.length - 1 - i; } } return lastSeen; } // 6 buckets spanning 1-49 const SPREAD_BUCKETS = [ [1,2,3,4,5,6,7,8], [9,10,11,12,13,14,15,16], [17,18,19,20,21,22,23,24,25], [26,27,28,29,30,31,32,33], [34,35,36,37,38,39,40,41], [42,43,44,45,46,47,48,49], ]; function predictNextDraw(draws) { if (draws.length < 10) return { numbers: [], special: '00' }; // M3 SpreadOverdue: for each of 6 buckets, pick the most-overdue regular number const lastSeen = getLastSeenMap(draws); const numbers = SPREAD_BUCKETS.map(bucket => { return bucket .map(n => ({ n: String(n).padStart(2, '0'), gap: lastSeen[String(n).padStart(2, '0')] || 0 })) .sort((a, b) => b.gap - a.gap)[0].n; }).sort((a, b) => parseInt(a) - parseInt(b)); // Predict special number: most overdue from 1-49 that is NOT in the predicted regular numbers const specialLastSeen = getSpecialLastSeenMap(draws); const predictedSet = new Set(numbers); const specialCandidates = Object.entries(specialLastSeen) .filter(([k]) => !predictedSet.has(k)) .sort((a, b) => b[1] - a[1]); const special = specialCandidates.length > 0 ? specialCandidates[0][0] : '00'; return { numbers, special }; } // ─── Markdown ───────────────────────────────────────────────────────────────── function generateMarkdown(draws, prediction) { const freq = computeFrequency(draws); const lastSeen = getLastSeenMap(draws); const total = draws.length; const hot = Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, 10); const cold = Object.entries(freq).sort((a, b) => a[1] - b[1]).slice(0, 10); const overdue = Object.entries(lastSeen).sort((a, b) => b[1] - a[1]).slice(0, 10); const lines = [ '# 大樂透 歷史開獎號碼 & 預測分析', '', `> 資料來源:https://www.pilio.idv.tw/lotto/list.asp`, `> 共收錄 **${total}** 期開獎紀錄`, `> 產生時間:${new Date().toLocaleString('zh-TW')}`, '', '---', '', '## 🎯 下期預測號碼', '', '> 採用 M3 SpreadOverdue 模型:將 1–49 分成 6 區間,每區選最久未出現號碼', '>', '> ⚠️ 彩券具有不確定性,本預測僅供參考娛樂,請理性購買。', '', `### 預測號碼:**${prediction.numbers.join(' · ')}** 特別號:**${prediction.special}**`, '', '---', '', '## 📊 統計分析', '', '### 🔥 熱門號碼 Top 10', '', '| 號碼 | 出現次數 | 出現率 |', '|:---:|:---:|:---:|', ...hot.map(([num, count]) => `| **${num}** | ${count} | ${((count / total) * 100).toFixed(2)}% |`), '', '### 🧊 冷門號碼 Top 10', '', '| 號碼 | 出現次數 | 出現率 |', '|:---:|:---:|:---:|', ...cold.map(([num, count]) => `| **${num}** | ${count} | ${((count / total) * 100).toFixed(2)}% |`), '', '### ⏰ 最久未出現 (遲到號) Top 10', '', '| 號碼 | 距上次出現(期數)|', '|:---:|:---:|', ...overdue.map(([num, gap]) => `| **${num}** | ${gap === total ? '未曾出現' : gap} |`), '', '### 📈 全號碼出現頻率 (01–49)', '', '| 號碼 | 次數 | 出現率 | 號碼 | 次數 | 出現率 |', '|:---:|:---:|:---:|:---:|:---:|:---:|', ]; const freqEntries = Object.entries(freq).sort((a, b) => parseInt(a[0]) - parseInt(b[0])); for (let i = 0; i < freqEntries.length; i += 2) { const [n1, c1] = freqEntries[i]; const r1 = ((c1 / total) * 100).toFixed(2); if (i + 1 < freqEntries.length) { const [n2, c2] = freqEntries[i + 1]; const r2 = ((c2 / total) * 100).toFixed(2); lines.push(`| ${n1} | ${c1} | ${r1}% | ${n2} | ${c2} | ${r2}% |`); } else { lines.push(`| ${n1} | ${c1} | ${r1}% | — | — | — |`); } } lines.push('', '---', '', '## 📋 歷史開獎紀錄(由新到舊)', '', '| # | 日期 | 號碼 1 | 號碼 2 | 號碼 3 | 號碼 4 | 號碼 5 | 號碼 6 | 特別號 |', '|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|'); const reversed = [...draws].reverse(); for (let i = 0; i < reversed.length; i++) { const draw = reversed[i]; const idx = total - i; const [n1, n2, n3, n4, n5, n6] = draw.numbers; lines.push(`| ${idx} | ${draw.date} | ${n1} | ${n2} | ${n3} | ${n4} | ${n5} | ${n6} | **${draw.special}** |`); } return lines.join('\n'); } async function finalize(allDraws) { console.log(`\n\n✅ Total draws collected: ${allDraws.length}`); // Dedup const seen = new Set(); const unique = allDraws.filter(d => { const key = `${d.date}|${d.numbers.join(',')}`; if (seen.has(key)) return false; seen.add(key); return true; }); console.log(`🔢 Unique draws: ${unique.length}`); // Validate: all draws should have exactly 6 unique numbers 1-49 const valid = unique.filter(d => { if (d.numbers.length !== 6) return false; const nums = d.numbers.map(n => parseInt(n)); const allValid = nums.every(n => n >= 1 && n <= 49); const allUnique = new Set(nums).size === 6; return allValid && allUnique; }); console.log(`✔️ Valid draws (6 unique numbers 1-49): ${valid.length}`); if (valid.length < 50) { console.error('❌ Too few valid draws. Parsing may have failed.'); console.log('Sample raw draws:'); unique.slice(0, 5).forEach(d => console.log(d)); process.exit(1); } console.log('\nSample draws (first 3):'); valid.slice(0, 3).forEach(d => console.log(JSON.stringify(d))); console.log('Sample draws (last 3):'); valid.slice(-3).forEach(d => console.log(JSON.stringify(d))); // Run prediction console.log('\n🔮 Running prediction model...'); const prediction = predictNextDraw(valid); console.log(`🎯 Predicted next draw: ${prediction.numbers.join(', ')} 特別號: ${prediction.special}`); // Save markdown console.log('\n📝 Generating markdown...'); const md = generateMarkdown(valid, prediction); fs.writeFileSync('lottery_649_history.md', md, 'utf8'); console.log(`✅ Markdown saved: lottery_649_history.md (${(fs.statSync('lottery_649_history.md').size / 1024).toFixed(1)} KB)`); // Save JSON const jsonOut = { draws: valid, prediction, generatedAt: new Date().toISOString(), total: valid.length }; fs.writeFileSync('lottery_649_data.json', JSON.stringify(jsonOut, null, 2), 'utf8'); console.log(`✅ JSON saved: lottery_649_data.json`); } main().catch(console.error);