|
| 1 | +import type { Data, DataItem, Route } from '@/types'; |
| 2 | +import cache from '@/utils/cache'; |
| 3 | +import logger from '@/utils/logger'; |
| 4 | +import ofetch from '@/utils/ofetch'; |
| 5 | +import { parseDate } from '@/utils/parse-date'; |
| 6 | +import timezone from '@/utils/timezone'; |
| 7 | + |
| 8 | +import { csvRecords } from './utils'; |
| 9 | + |
| 10 | +/** |
| 11 | + * 飲食店営業許可 (food business permits) newly granted in Tokyo wards, from each ward's CC BY open data. |
| 12 | + * |
| 13 | + * Only two of the 23 wards publish a machine-readable, regularly updated 台帳: |
| 14 | + * shibuya — 渋谷区 ArcGIS FeatureServer (complete ledger incl. 廃業; the current and previous month are queried) |
| 15 | + * minato — 港区 自治体標準オープンデータ CSV (snapshot of valid permits, monthly, ~2 MB) |
| 16 | + * Rows are the publisher's original columns and are exposed verbatim in `_extra.raw`. |
| 17 | + */ |
| 18 | + |
| 19 | +type Source = 'shibuya' | 'minato'; |
| 20 | +type Row = Record<string, string | null>; |
| 21 | + |
| 22 | +export interface PermitExtra { |
| 23 | + source: Source; |
| 24 | + ward: string; |
| 25 | + permit_no: string; |
| 26 | + name: string; |
| 27 | + address: string | null; |
| 28 | + permit_date: string | null; // YYYY-MM-DD |
| 29 | + business_type: string | null; |
| 30 | + raw: Row; |
| 31 | +} |
| 32 | + |
| 33 | +const SOURCES: Record<Source, { label: string; link: string }> = { |
| 34 | + shibuya: { |
| 35 | + label: '渋谷区', |
| 36 | + link: 'https://city-shibuya-data.opendata.arcgis.com/items/e68f41ebfa5f4ea490ca9af701d44e02', |
| 37 | + }, |
| 38 | + minato: { |
| 39 | + label: '港区', |
| 40 | + link: 'https://catalog.data.metro.tokyo.lg.jp/dataset/t131032d0000000244', |
| 41 | + }, |
| 42 | +}; |
| 43 | +const SHIBUYA_QUERY = 'https://services3.arcgis.com/UtdeFTavkHfI94t2/arcgis/rest/services/131130_food_businesses_list/FeatureServer/0/query'; |
| 44 | +const SHIBUYA_DATE = '許可開始日もしくは届出受理日'; |
| 45 | +const MINATO_CSV = 'https://opendata.city.minato.tokyo.jp/dataset/54d8c582-00e2-4730-a23f-4a5befec9ae5/resource/c9d0299e-8e05-4317-877f-83055709e41f/download/food_business_all.csv'; |
| 46 | +const DEFAULT_LIMIT = 100; |
| 47 | +const MAX_LIMIT = 500; |
| 48 | +const DATE_COLS = ['許可年月日', SHIBUYA_DATE, '許可開始日', '許可日']; |
| 49 | + |
| 50 | +const pick = (row: Row, cols: readonly string[]): string | null => { |
| 51 | + for (const c of cols) { |
| 52 | + const v = row[c]?.trim(); |
| 53 | + if (v) { |
| 54 | + return v; |
| 55 | + } |
| 56 | + } |
| 57 | + return null; |
| 58 | +}; |
| 59 | + |
| 60 | +/** `2024-04-08` / `2026/8/7` → `YYYY-MM-DD`; anything else (和暦 …) → null. */ |
| 61 | +const isoDate = (raw: string | null): string | null => { |
| 62 | + const m = raw === null ? null : /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.exec(raw.normalize('NFKC')); |
| 63 | + return m ? `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}` : null; |
| 64 | +}; |
| 65 | + |
| 66 | +/** 渋谷区 stores dates as text (`2026/8/7`), so the current and previous month (JST) are selected with LIKE and sorted locally. */ |
| 67 | +const shibuyaQueryUrl = (now: Date): string => { |
| 68 | + const jst = new Date(now.getTime() + 9 * 3600 * 1000); |
| 69 | + const months = [0, 1].map((back) => { |
| 70 | + const d = new Date(Date.UTC(jst.getUTCFullYear(), jst.getUTCMonth() - back, 1)); |
| 71 | + return `${d.getUTCFullYear()}/${d.getUTCMonth() + 1}/`; |
| 72 | + }); |
| 73 | + const params = new URLSearchParams({ |
| 74 | + where: months.map((m) => `${SHIBUYA_DATE} LIKE '${m}%'`).join(' OR '), |
| 75 | + outFields: '*', |
| 76 | + returnGeometry: 'false', |
| 77 | + resultRecordCount: '1000', |
| 78 | + f: 'json', |
| 79 | + }); |
| 80 | + return `${SHIBUYA_QUERY}?${params.toString()}`; |
| 81 | +}; |
| 82 | + |
| 83 | +const fetchShibuya = async (): Promise<Row[]> => { |
| 84 | + const body = await ofetch(shibuyaQueryUrl(new Date())); |
| 85 | + const features: Array<{ attributes: Record<string, unknown> }> = body?.features ?? []; |
| 86 | + return features.map((f) => Object.fromEntries(Object.entries(f.attributes).map(([k, v]) => [k, v === null || v === undefined ? null : String(v)]))); |
| 87 | +}; |
| 88 | + |
| 89 | +const fetchMinato = async (): Promise<Row[]> => csvRecords(await ofetch(MINATO_CSV, { responseType: 'text' })); |
| 90 | + |
| 91 | +const toItem = (source: Source, raw: Row): DataItem & { _extra: PermitExtra } => { |
| 92 | + const permitNo = pick(raw, ['許可番号']) ?? ''; |
| 93 | + const name = pick(raw, ['施設名称', '屋号']) ?? '(名称なし)'; |
| 94 | + const address = pick(raw, ['所在地_連結表記', '施設所在地_連結表記']); |
| 95 | + const permitDate = isoDate(pick(raw, DATE_COLS)); |
| 96 | + const businessType = pick(raw, ['営業の種類', '営業の種類もしくは営業の形態', '業種']); |
| 97 | + const ward = (pick(raw, ['施設所在地_市区町村', '地方公共団体名']) ?? SOURCES[source].label).replace(/^東京都/, ''); |
| 98 | + return { |
| 99 | + title: `${name}(${businessType ?? '業種不明'})`, |
| 100 | + guid: `lg/tokyo/food-permit:${source}:${permitNo}`, |
| 101 | + link: SOURCES[source].link, |
| 102 | + pubDate: permitDate === null ? undefined : timezone(parseDate(permitDate, 'YYYY-MM-DD'), 9), |
| 103 | + description: [ward, address, businessType, permitDate, `許可番号 ${permitNo}`].filter(Boolean).join(' / '), |
| 104 | + _extra: { source, ward, permit_no: permitNo, name, address, permit_date: permitDate, business_type: businessType, raw }, |
| 105 | + }; |
| 106 | +}; |
| 107 | + |
| 108 | +/** Newest `limit` 許可 rows of one source; 届出 rows are not an opening signal and are skipped. */ |
| 109 | +const fetchSource = async (source: Source, limit: number): Promise<Array<DataItem & { _extra: PermitExtra }>> => { |
| 110 | + try { |
| 111 | + const rows = source === 'shibuya' ? await fetchShibuya() : await fetchMinato(); |
| 112 | + return rows |
| 113 | + .filter((r) => (r['許可番号'] ?? '') !== '' && (r['許可あるいは届出'] ?? '許可') === '許可') |
| 114 | + .map((raw) => toItem(source, raw)) |
| 115 | + .filter((it) => it._extra.permit_date !== null) |
| 116 | + .toSorted((a, b) => b._extra.permit_date!.localeCompare(a._extra.permit_date!)) |
| 117 | + .slice(0, limit); |
| 118 | + } catch (error) { |
| 119 | + // One failing publisher must not take the whole feed down. |
| 120 | + logger.warn(`lg/tokyo/food-permit: ${source} failed: ${String(error)}`); |
| 121 | + return []; |
| 122 | + } |
| 123 | +}; |
| 124 | + |
| 125 | +export const handler = async (ctx): Promise<Data> => { |
| 126 | + const ward: string | undefined = ctx.req.param('ward'); |
| 127 | + const sources: Source[] = ward === undefined ? (Object.keys(SOURCES) as Source[]) : Object.hasOwn(SOURCES, ward) ? [ward as Source] : []; |
| 128 | + if (sources.length === 0) { |
| 129 | + throw new Error(`Unknown ward "${ward}", expected one of ${Object.keys(SOURCES).join(', ')}`); |
| 130 | + } |
| 131 | + const limit = Math.min(ctx.req.query('limit') ? Number(ctx.req.query('limit')) : DEFAULT_LIMIT, MAX_LIMIT); |
| 132 | + |
| 133 | + const lists = await Promise.all(sources.map((s) => cache.tryGet(`lg/tokyo/food-permit:${s}:${limit}`, () => fetchSource(s, limit)) as Promise<Array<DataItem & { _extra: PermitExtra }>>)); |
| 134 | + const items = lists.flat().toSorted((a, b) => (b._extra.permit_date ?? '').localeCompare(a._extra.permit_date ?? '')); |
| 135 | + |
| 136 | + return { |
| 137 | + title: `東京都 飲食店営業許可 新規${ward ? ` (${SOURCES[ward as Source].label})` : ''}`, |
| 138 | + link: 'https://catalog.data.metro.tokyo.lg.jp/', |
| 139 | + language: 'ja', |
| 140 | + item: items, |
| 141 | + allowEmpty: true, |
| 142 | + }; |
| 143 | +}; |
| 144 | + |
| 145 | +export const route: Route = { |
| 146 | + path: '/tokyo/food-permit/:ward?', |
| 147 | + name: '東京都 飲食店営業許可 新規', |
| 148 | + url: 'catalog.data.metro.tokyo.lg.jp', |
| 149 | + maintainers: ['pseudoyu'], |
| 150 | + handler, |
| 151 | + example: '/lg/tokyo/food-permit', |
| 152 | + parameters: { |
| 153 | + ward: { |
| 154 | + description: 'Ward; omit for all sources', |
| 155 | + options: [ |
| 156 | + { value: 'shibuya', label: '渋谷区' }, |
| 157 | + { value: 'minato', label: '港区' }, |
| 158 | + ], |
| 159 | + }, |
| 160 | + }, |
| 161 | + description: `Newly granted food business permits (飲食店営業許可 etc.) in Tokyo wards, from each ward's CC BY open data: |
| 162 | +
|
| 163 | +- 渋谷区: [食品営業許可施設一覧 (ArcGIS FeatureServer)](https://city-shibuya-data.opendata.arcgis.com/items/e68f41ebfa5f4ea490ca9af701d44e02) — current and previous month |
| 164 | +- 港区: [食品営業許可一覧 (CSV)](https://catalog.data.metro.tokyo.lg.jp/dataset/t131032d0000000244) — monthly snapshot of valid permits, newest first |
| 165 | +
|
| 166 | +Items are sorted by permit date (\`pubDate\`). \`_extra\` holds \`source\`, \`ward\`, \`permit_no\`, \`name\`, \`address\`, \`permit_date\`, \`business_type\` and the publisher's original columns in \`raw\`. Only 許可 rows are included (届出 rows are skipped). |
| 167 | +
|
| 168 | +| Query | Description | Default | |
| 169 | +| ------- | ------------------------------------- | ------- | |
| 170 | +| \`limit\` | Number of permits per source, max 500 | 100 |`, |
| 171 | + categories: ['government'], |
| 172 | + features: { |
| 173 | + requireConfig: false, |
| 174 | + requirePuppeteer: false, |
| 175 | + antiCrawler: false, |
| 176 | + supportRadar: false, |
| 177 | + }, |
| 178 | +}; |
0 commit comments