// LoGoフォーム(小平市)の手続き一覧を取得する。 // // LoGoフォームのページ自体は自動アクセス防止(TRUSTBANKの確認画面)で // ブラウザから開けないことがある。一方、一覧のAPIは直接呼べる。 // ただし Node の fetch では 403 になるため、curl を使って取得している。 // // 使い方: // node scripts/fetch-logoform-events.mjs # イベント情報(49006) // node scripts/fetch-logoform-events.mjs 49789 # 市議会からのお知らせ // node scripts/fetch-logoform-events.mjs 49006 --json // node scripts/fetch-logoform-events.mjs --uri 2524 # 手続きページの番号から自動で調べる // // 手続きページ(https://logoform.jp/procedure/exdH/)の内部IDは、 // ページを開いたときに呼ばれる一覧APIのURL(.../detail/search/)に出る。 // --uri を指定すると、ブラウザで開いてそのURLからIDを調べる。 // // 既知の内部ID: // 49006 … exdH/2517 イベント情報 // 48668 … exdH/666 電子申請(手続き一覧) // 49789 … exdH/2524 市議会からのお知らせ import { execFileSync } from 'node:child_process'; const KNOWN_IDS = { '2517': '49006', '666': '48668', '2524': '49789', }; const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; const args = process.argv.slice(2); const asJson = args.includes('--json'); const uriIndex = args.indexOf('--uri'); const uri = uriIndex >= 0 ? args[uriIndex + 1] : null; const directId = args.find((a) => /^\d+$/.test(a)); let procedureId = directId ?? '49006'; if (uri) { procedureId = KNOWN_IDS[uri] ?? (await discoverId(uri)); } /** 手続きページをブラウザで開き、一覧APIのURLから内部IDを調べる */ async function discoverId(procedureUri) { const { chromium } = await import('playwright'); const browser = await chromium.launch({ args: ['--disable-blink-features=AutomationControlled', '--no-sandbox'], }); const context = await browser.newContext({ locale: 'ja-JP', userAgent: UA, viewport: { width: 1400, height: 1000 }, }); await context.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }); let found = null; const page = await context.newPage(); page.on('response', (res) => { const match = res.url().match(/\/api\/procedure\/branch\/detail\/search\/(\d+)/); if (match) found = match[1]; }); await page.goto(`https://logoform.jp/procedure/exdH/${procedureUri}`, { waitUntil: 'domcontentloaded', timeout: 60000, }); for (let i = 0; i < 10 && !found; i++) await page.waitForTimeout(2000); await browser.close(); if (!found) throw new Error(`内部IDが分かりませんでした(exdH/${procedureUri})`); return found; } // 空のオブジェクトで全件返る。page などの項目を足すと「不明な項目」で弾かれる。 const curlArgs = [ '-s', '--max-time', '30', '-X', 'POST', '-H', 'Content-Type: application/json', '-H', 'Accept: application/json', '-H', `Referer: https://logoform.jp/procedure/exdH/${uri ?? '2517'}`, '-H', `User-Agent: ${UA}`, '-d', '{}', `https://logoform.jp/api/procedure/branch/detail/search/${procedureId}`, ]; let raw; try { raw = execFileSync('curl', curlArgs, { encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }); } catch (error) { console.error('curl の実行に失敗しました。curl が使えるか確認してください。'); console.error(String(error).slice(0, 300)); process.exit(1); } let data; try { data = JSON.parse(raw); } catch { console.error('JSONとして読めませんでした(自動アクセス防止の画面が返っている可能性があります)。'); console.error(raw.slice(0, 300)); process.exit(1); } const items = Array.isArray(data.items) ? data.items : []; if (asJson) { console.log(JSON.stringify(items, null, 2)); process.exit(0); } console.log(`procedure_id=${procedureId} 件数=${items.length}`); for (const item of items) { const description = (item.description ?? '').replace(/\s+/g, ' ').trim(); console.log( `- [${item.category_name}] ${item.procedure_name}` + (description ? `\n ${description.slice(0, 120)}` : '') + `\n https://logoform.jp${item.form_url ?? ''}`, ); }