#!/usr/bin/env node /** * index.mjs — 主流程 * * Playwright 渲染 → Gemini 結構化抽取 → 正規化 → 曼谷過濾 * → Fingerprint 去重(比對 events + 待審核售票)→ 寫進待審核售票 * → 欄位不齊的儲存格標黃底 * * AI 永遠不會直接寫進 events。核准仍然是人做的(CLAUDE.md §97-5)。 * * 環境變數 * GEMINI_API_KEY 必填(跟 image-ai-pipeline.gs 同一支金鑰) * GEMINI_MODEL 選填,預設 gemini-3.6-flash * GOOGLE_SERVICE_ACCOUNT_JSON 必填,服務帳號的完整 JSON * SHEET_ID 必填 * DRY_RUN 'true' 時只印結果不寫入 */ import { launch, newContext, harvest } from './browser.mjs'; import { extractEvents } from './extract.mjs'; import { createClient, toObjects, req, YELLOW } from './sheets.mjs'; import { SOURCES, TICKET_HEADER, REQUIRED_COLS } from './sources.mjs'; import { toISODate, toHHmm, fingerprint, findMatch, bkkState, isTargetEvent, todayBangkok, } from './normalize.mjs'; const { GEMINI_API_KEY, GEMINI_MODEL = 'gemini-3.6-flash', GOOGLE_SERVICE_ACCOUNT_JSON, SHEET_ID, DRY_RUN, } = process.env; const dry = String(DRY_RUN).toLowerCase() === 'true'; const SHEET_TICKET = '待審核售票'; const SHEET_EVENTS = 'events'; function log(...a) { console.log(...a); } function need(name, v) { if (!v) { console.error(`✗ 缺少環境變數 ${name}`); process.exit(1); } return v; } /** 讀 events + 待審核售票,建立去重索引 */ async function buildIndex(sheets) { const list = []; const ev = toObjects(await sheets.read(SHEET_EVENTS)); for (const o of ev) { list.push({ id: o.id, artist: o.artist, name: o.name, date: toISODate(o.date), venue: o.location, fp: fingerprint(o.artist, o.name, o.date, o.location), }); } const tkRows = await sheets.read(SHEET_TICKET); const tk = toObjects(tkRows); for (const o of tk) { list.push({ id: `待審核售票!${o._row}`, artist: o['藝人'], name: o['活動名稱'], date: toISODate(o['日期']), venue: o['地點'], fp: fingerprint(o['藝人'], o['活動名稱'], o['日期'], o['地點']), }); } log(`去重索引:events ${ev.length} 筆 + 待審核售票 ${tk.length} 筆 = ${list.length} 筆`); return { list, ticketRowCount: tkRows.length }; } async function main() { need('GEMINI_API_KEY', GEMINI_API_KEY); need('SHEET_ID', SHEET_ID); need('GOOGLE_SERVICE_ACCOUNT_JSON', GOOGLE_SERVICE_ACCOUNT_JSON); log('═'.repeat(46)); log(`narak scraper ${new Date().toISOString()}`); log(`模型 ${GEMINI_MODEL} ${dry ? '【DRY RUN 不寫入】' : '【正式寫入】'}`); log('═'.repeat(46)); const sheets = createClient(GOOGLE_SERVICE_ACCOUNT_JSON, SHEET_ID); const props = await sheets.meta(); let ticketProps = props.find((p) => p.title === SHEET_TICKET); if (!ticketProps) { await sheets.batch([req.addSheet(SHEET_TICKET)]); await sheets.write(`'${SHEET_TICKET}'!A1`, [TICKET_HEADER], true); ticketProps = (await sheets.meta()).find((p) => p.title === SHEET_TICKET); } const index = await buildIndex(sheets); const browser = await launch(); const ctx = await newContext(browser); const today = todayBangkok(); const accepted = []; const stats = { pages: 0, pageFail: 0, raw: 0, dup: 0, nonBkk: 0, noDate: 0, past: 0, kept: 0 }; for (const src of SOURCES) { if (!src.enabled) continue; for (const p of src.pages) { let page; try { log(`\n▸ ${src.name} ${p.url}`); page = await harvest(ctx, p.url, { scroll: p.scroll, waitMs: p.waitMs }); stats.pages++; log(` 取得文字 ${page.text.length} 字、連結 ${page.links.length}、圖片 ${page.images.length}`); } catch (e) { stats.pageFail++; // §51 單一來源失敗不得中斷整個流程 log(` ✗ 頁面載入失敗(略過):${String(e).slice(0, 140)}`); continue; } if (page.text.length < 200 && !page.links.length) { log(' ⚠ 頁面幾乎沒有內容,可能被擋或網址已失效'); if (!p.optional) log(' → 這是必要頁面,請檢查網址是否改過'); continue; } const r = await extractEvents({ apiKey: GEMINI_API_KEY, model: GEMINI_MODEL, page, sourceName: src.name, }); if (!r.ok) { log(` ✗ 抽取失敗(略過):${r.error}`); continue; } log(` 抽出 ${r.events.length} 筆 tokens ${r.usage?.total_tokens ?? '?'}`); stats.raw += r.events.length; for (const e of r.events) { const name = e.event_name || ''; const date = toISODate(e.event_date); const venue = e.venue || ''; const artist = e.artist_name || ''; if (!name) continue; if (!date) { stats.noDate++; } // 沒日期仍收,但會被標黃 if (date && date < today) { stats.past++; continue; } const bkk = e.is_bangkok === true ? 'BKK' : e.is_bangkok === false ? 'NON_BKK' : bkkState(venue); if (bkk === 'NON_BKK') { stats.nonBkk++; continue; } const hit = findMatch({ artist, name, date, venue }, index.list); if (hit.match === 'EXACT' || hit.match === 'DUPLICATE') { stats.dup++; continue; } const type = String(e.event_type || 'other').toLowerCase(); const saleDate = toISODate(e.ticket_sale_date); const saleTime = toHHmm(e.ticket_sale_time); const row = [ new Date().toISOString().replace('T', ' ').slice(0, 19), `${src.name}${e.ticket_platform ? ' / ' + e.ticket_platform : ''}`, name, date, venue, artist, type, saleDate ? (saleTime ? `${saleDate} ${saleTime}` : saleDate) : '', e.ticket_url || page.url, false, isTargetEvent(type, '', name) ? '高' : '低', ]; accepted.push({ row, fp: hit.fp, missing: REQUIRED_COLS.filter((c) => { const i = TICKET_HEADER.indexOf(c); return i < 0 || String(row[i] ?? '').trim() === ''; }), note: [ bkk === 'UNKNOWN' ? '曼谷未確認' : '', hit.match === 'MANUAL_REVIEW' ? `近似 ${hit.id} (${hit.score})` : '', Number(e.confidence) < 0.8 ? `信心 ${e.confidence}` : '', ].filter(Boolean).join(' / '), }); index.list.push({ id: 'new', artist, name, date, venue, fp: hit.fp }); stats.kept++; } } } await browser.close(); log('\n' + '─'.repeat(46)); log(`頁面成功 ${stats.pages} / 失敗 ${stats.pageFail}`); log(`AI 抽出 ${stats.raw} 筆 → 重複 ${stats.dup} 非曼谷 ${stats.nonBkk} 已過期 ${stats.past}`); log(`準備寫入 ${stats.kept} 筆(其中 ${accepted.filter(a => a.missing.length).length} 筆欄位不齊)`); if (!accepted.length) { log('沒有新資料,結束。'); return; } if (dry) { accepted.slice(0, 20).forEach((a) => { log(` [${a.row[10]}] ${a.row[3] || '無日期'} ${a.row[2]} @ ${a.row[4] || '無場地'}` + (a.missing.length ? ` ⚠ 缺 ${a.missing.join('/')}` : '') + (a.note ? ` (${a.note})` : '')); }); if (accepted.length > 20) log(` …其餘 ${accepted.length - 20} 筆`); log('\nDRY RUN:一筆都沒有寫入試算表。'); return; } const startRow = index.ticketRowCount + 1; // append 會接在現有資料之後 await sheets.append(SHEET_TICKET, accepted.map((a) => a.row)); log(`✓ 已寫入 ${accepted.length} 筆到「${SHEET_TICKET}」`); // 欄位不齊 → 標黃底提示補充 const cells = []; accepted.forEach((a, i) => { a.missing.forEach((c) => { const col = TICKET_HEADER.indexOf(c) + 1; if (col > 0) cells.push(req.bgColor(ticketProps.sheetId, startRow + i + 1, col, YELLOW)); }); }); if (cells.length) { await sheets.batch(cells); log(`✓ 已標記 ${cells.length} 個待補欄位(黃底)`); } } main().catch((e) => { console.error('✗ 執行失敗:', e); process.exit(1); });