Skip to content

Commit bc2a357

Browse files
committed
feat(route): add lg/tokyo food business permits
Add /lg/tokyo/food-permit/:ward? aggregating newly granted food business permits from Tokyo ward open data (渋谷区 ArcGIS FeatureServer, 港区 CSV; both CC BY). The lg namespace mirrors the existing go (go.jp) namespace for lg.jp local governments.
1 parent 56f659c commit bc2a357

3 files changed

Lines changed: 248 additions & 0 deletions

File tree

lib/routes/lg/namespace.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Namespace } from '@/types';
2+
3+
export const namespace: Namespace = {
4+
name: 'Japan Local Government',
5+
url: 'lg.jp',
6+
categories: ['government'],
7+
description: 'Local governments of Japan (`lg.jp`)',
8+
lang: 'ja',
9+
10+
ja: {
11+
name: '地方公共団体',
12+
},
13+
};

lib/routes/lg/tokyo/food-permit.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
};

lib/routes/lg/tokyo/utils.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/** Minimal RFC 4180 CSV parser: quoted fields, `""` escapes, newlines inside quotes, CRLF. */
2+
const parseCsv = (text: string): string[][] => {
3+
const rows: string[][] = [];
4+
let row: string[] = [];
5+
let field = '';
6+
let quoted = false;
7+
for (let i = 0; i < text.length; i++) {
8+
const c = text[i];
9+
if (quoted) {
10+
if (c !== '"') {
11+
field += c;
12+
} else if (text[i + 1] === '"') {
13+
field += '"';
14+
i++;
15+
} else {
16+
quoted = false;
17+
}
18+
continue;
19+
}
20+
switch (c) {
21+
case '"':
22+
quoted = true;
23+
break;
24+
case ',':
25+
row.push(field);
26+
field = '';
27+
break;
28+
case '\r':
29+
case '\n':
30+
if (c === '\r' && text[i + 1] === '\n') {
31+
i++;
32+
}
33+
row.push(field);
34+
rows.push(row);
35+
row = [];
36+
field = '';
37+
break;
38+
default:
39+
field += c;
40+
}
41+
}
42+
if (field !== '' || row.length > 0) {
43+
row.push(field);
44+
rows.push(row);
45+
}
46+
return rows;
47+
};
48+
49+
/** CSV text → records keyed by the (trimmed) header row; a missing cell is `null`. */
50+
export const csvRecords = (text: string): Array<Record<string, string | null>> => {
51+
const [header, ...rows] = parseCsv(text);
52+
if (!header) {
53+
return [];
54+
}
55+
const keys = header.map((h) => h.trim());
56+
return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(keys.map((k, i) => [k, r[i] ?? null])));
57+
};

0 commit comments

Comments
 (0)