rsshub
Version:
Make RSS Great Again!
273 lines (269 loc) • 12.3 kB
JavaScript
import { t as rofetch } from "./ofetch-U0CdpE2g.mjs";
import { t as logger } from "./logger-DlhSwgkg.mjs";
import { t as parseDate } from "./parse-date-3kcy2Eau.mjs";
import { t as cache_default } from "./cache-C1Y-9qDV.mjs";
import { t as timezone } from "./timezone-UiMFOuvL.mjs";
import { i as warekiMonth, n as decodeText, r as isoDate, t as csvRecords } from "./utils-oPHdcFdj.mjs";
import { load } from "cheerio";
//#region lib/routes/lg/tokyo/food-permit.ts
const SOURCES = {
shibuya: {
label: "渋谷区",
link: "https://city-shibuya-data.opendata.arcgis.com/items/e68f41ebfa5f4ea490ca9af701d44e02"
},
minato: {
label: "港区",
link: "https://catalog.data.metro.tokyo.lg.jp/dataset/t131032d0000000244"
},
taito: {
label: "台東区",
link: "https://www.city.taito.lg.jp/kenkohukusi/kenkokikikanrieisei/food/syokuhin-sisetu/index.html"
},
shinagawa: {
label: "品川区",
link: "https://www.city.shinagawa.tokyo.jp/PC/kenkou/kenkou-eisei/kenkou-eisei-syokuhin/opendate.html"
},
setagaya: {
label: "世田谷区",
link: "https://www.city.setagaya.lg.jp/02245/online_tetsuzuki/3246.html"
},
meguro: {
label: "目黒区",
link: "https://data.bodik.jp/dataset/131105_food_business"
}
};
const MEGURO_API = "https://data.bodik.jp/api/3/action/package_show?id=131105_food_business";
const MONTHS_BACK = 2;
const SHIBUYA_QUERY = "https://services3.arcgis.com/UtdeFTavkHfI94t2/arcgis/rest/services/131130_food_businesses_list/FeatureServer/0/query";
const SHIBUYA_DATE = "許可開始日もしくは届出受理日";
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";
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 500;
const DATE_COLS = [
"許可年月日",
"許可決定日",
SHIBUYA_DATE,
"許可開始日",
"許可日"
];
/** First non-empty column; `※` / `※※※※※` (品川区・台東区 mask individuals' data this way) counts as empty. */
const pick = (row, cols) => {
for (const c of cols) {
const v = row[c]?.trim();
if (v && !/^[※**]+$/.test(v)) return v;
}
return null;
};
const toNumber = (v) => {
const n = v === null ? NaN : Number(v.trim());
return Number.isFinite(n) ? n : null;
};
/** 渋谷区 stores dates as text (`2026/8/7`), so the current and previous month (JST) are selected with LIKE and sorted locally. */
const shibuyaQueryUrl = (now) => {
const jst = new Date(now.getTime() + 324e5);
const months = [0, 1].map((back) => {
const d = new Date(Date.UTC(jst.getUTCFullYear(), jst.getUTCMonth() - back, 1));
return `${d.getUTCFullYear()}/${d.getUTCMonth() + 1}/`;
});
const params = new URLSearchParams({
where: months.map((m) => `${SHIBUYA_DATE} LIKE '${m}%'`).join(" OR "),
outFields: "*",
returnGeometry: "false",
resultRecordCount: "1000",
f: "json"
});
return `${SHIBUYA_QUERY}?${params.toString()}`;
};
const fetchShibuya = async () => {
return ((await rofetch(shibuyaQueryUrl(/* @__PURE__ */ new Date())))?.features ?? []).map((f) => Object.fromEntries(Object.entries(f.attributes).map(([k, v]) => [k, v === null || v === void 0 ? null : String(v)])));
};
const fetchMinato = async () => csvRecords(await rofetch(MINATO_CSV, { responseType: "text" }));
const fetchCsv = async (url) => {
const buf = await rofetch(url, { responseType: "arrayBuffer" });
return csvRecords(decodeText(buf));
};
/** The newest `MONTHS_BACK` monthly files of a page-linked ward, in the order given (newest first), concatenated. */
const fetchMonthly = async (urls) => (await Promise.all(urls.slice(0, MONTHS_BACK).map((u) => fetchCsv(u)))).flat();
/** Link `href`s with a sortable key, newest first. A ward page may list the same file twice (世田谷区 renders every link once plainly and once with `target="_self"`), so hrefs are made unique. */
const newestLinks = ($, base, keyOf) => {
const links = $("a[href$=\".csv\"]").toArray().map((a) => ({
href: new URL($(a).attr("href"), base).href,
key: keyOf($(a).text(), $(a).attr("href"))
})).filter((l) => l.key !== null).toSorted((a, b) => b.key.localeCompare(a.key)).map((l) => l.href);
return [...new Set(links)];
};
/** 台東区: the 月別 table lists each month as 業種順 + 許可日順 (same rows); the file names are not regular (`2026-07-PER-CSV.csv`, `OPD08_PER.csv`), so rows are taken in table order. */
const fetchTaito = async () => {
const html = await rofetch(SOURCES.taito.link, { responseType: "text" });
const $ = load(html);
const urls = $("table").filter((_, t) => $(t).find("caption").text().includes("月別")).find("a[href$=\".csv\"]").toArray().filter((a) => $(a).text().includes("許可日順")).map((a) => new URL($(a).attr("href"), SOURCES.taito.link).href).toReversed();
return fetchMonthly(urls);
};
/** 品川区: `令和8年7月分許可施設一覧` links (the 令和6〜7年度 yearly file is skipped). */
const fetchShinagawa = async () => {
const html = await rofetch(SOURCES.shinagawa.link, { responseType: "text" });
const $ = load(html);
return fetchMonthly(newestLinks($, SOURCES.shinagawa.link, (text) => text.includes("月分") ? warekiMonth(text) : null));
};
/** 世田谷区: `例月新規許可施設一覧(R080831)` links, keyed by the R+YYMMDD code (the yearly 全件 file has none). */
const fetchSetagaya = async () => {
const html = await rofetch(SOURCES.setagaya.link, { responseType: "text" });
const $ = load(html);
return fetchMonthly(newestLinks($, SOURCES.setagaya.link, (text) => /例月新規許可施設一覧\(R(\d{6})\)/.exec(text)?.[1] ?? null));
};
/** 目黒区: CKAN resources named `飲食店 新規 令和7年10月分` (or `新規飲食店施設一覧 …`); 更新 / 届出 / 廃業 files are skipped. */
const fetchMeguro = async () => {
const urls = ((await rofetch(MEGURO_API))?.result?.resources ?? []).filter((r) => r.format.toUpperCase() === "CSV" && r.name.includes("飲食店") && r.name.includes("新規")).map((r) => ({
url: r.url,
key: warekiMonth(r.name)
})).filter((r) => r.key !== null).toSorted((a, b) => b.key.localeCompare(a.key)).map((r) => r.url);
return fetchMonthly(urls);
};
const FETCHERS = {
shibuya: fetchShibuya,
minato: fetchMinato,
taito: fetchTaito,
shinagawa: fetchShinagawa,
setagaya: fetchSetagaya,
meguro: fetchMeguro
};
const toItem = (source, raw) => {
const permitNo = pick(raw, ["許可番号"]) ?? "";
const name = pick(raw, [
"施設名称",
"屋号",
"施設屋号",
"施設の名称"
]) ?? "(名称なし)";
const address = pick(raw, [
"所在地_連結表記",
"施設所在地_連結表記",
"施設所在地",
"営業所所在地",
"営業施設所在地"
]);
const permitDate = isoDate(pick(raw, DATE_COLS));
const businessType = pick(raw, [
"営業の種類",
"営業の種類もしくは営業の形態",
"業種"
]);
const ward = (pick(raw, ["施設所在地_市区町村", "地方公共団体名"]) ?? SOURCES[source].label).replace(/^東京都/, "");
const lat = toNumber(pick(raw, ["緯度"]));
const lon = toNumber(pick(raw, ["経度"]));
return {
title: `${name}(${businessType ?? "業種不明"})`,
guid: `lg/tokyo/food-permit:${source}:${permitNo}`,
link: SOURCES[source].link,
pubDate: permitDate === null ? void 0 : timezone(parseDate(permitDate, "YYYY-MM-DD"), 9),
description: [
ward,
address,
businessType,
permitDate,
`許可番号 ${permitNo}`
].filter(Boolean).join(" / "),
_extra: {
source,
ward,
permit_no: permitNo,
name,
address,
permit_date: permitDate,
business_type: businessType,
lat,
lon,
raw
}
};
};
/** First item per 許可番号, order kept; called after the date-desc sort, so the surviving row is the one with the latest permit date. */
const uniqueByPermitNo = (items) => {
const seen = /* @__PURE__ */ new Set();
return items.filter((it) => {
if (seen.has(it._extra.permit_no)) return false;
seen.add(it._extra.permit_no);
return true;
});
};
/** Newest `limit` 許可 rows of one source; 届出 rows are not an opening signal and are skipped. Publishers may repeat a permit (a file linked twice, or a 変更 row re-listed), so 許可番号 is deduplicated. */
const fetchSource = async (source, limit) => {
try {
const items = (await FETCHERS[source]()).filter((r) => (r["許可番号"] ?? "") !== "" && (r["許可あるいは届出"] ?? "許可") === "許可").map((raw) => toItem(source, raw)).filter((it) => it._extra.permit_date !== null).toSorted((a, b) => b._extra.permit_date.localeCompare(a._extra.permit_date));
return uniqueByPermitNo(items).slice(0, limit);
} catch (error) {
logger.warn(`lg/tokyo/food-permit: ${source} failed: ${String(error)}`);
return [];
}
};
const handler = async (ctx) => {
const ward = ctx.req.param("ward");
const sources = ward === void 0 ? Object.keys(SOURCES) : Object.hasOwn(SOURCES, ward) ? [ward] : [];
if (sources.length === 0) throw new Error(`Unknown ward "${ward}", expected one of ${Object.keys(SOURCES).join(", ")}`);
const limit = Math.min(ctx.req.query("limit") ? Number(ctx.req.query("limit")) : DEFAULT_LIMIT, MAX_LIMIT);
const items = (await Promise.all(sources.map((s) => cache_default.tryGet(`lg/tokyo/food-permit:${s}:${limit}`, () => fetchSource(s, limit))))).flat().toSorted((a, b) => (b._extra.permit_date ?? "").localeCompare(a._extra.permit_date ?? ""));
return {
title: `東京都 飲食店営業許可 新規${ward ? ` (${SOURCES[ward].label})` : ""}`,
link: "https://catalog.data.metro.tokyo.lg.jp/",
language: "ja",
item: items,
allowEmpty: true
};
};
const route = {
path: "/tokyo/food-permit/:ward?",
name: "東京都 飲食店営業許可 新規",
url: "catalog.data.metro.tokyo.lg.jp",
maintainers: ["pseudoyu"],
handler,
example: "/lg/tokyo/food-permit",
parameters: { ward: {
description: "Ward; omit for all sources",
options: [
{
value: "shibuya",
label: "渋谷区"
},
{
value: "minato",
label: "港区"
},
{
value: "taito",
label: "台東区"
},
{
value: "shinagawa",
label: "品川区"
},
{
value: "setagaya",
label: "世田谷区"
},
{
value: "meguro",
label: "目黒区"
}
]
} },
description: `Newly granted food business permits (飲食店営業許可 etc.) in Tokyo wards, from each ward's CC BY open data:
- 渋谷区: [食品営業許可施設一覧 (ArcGIS FeatureServer)](https://city-shibuya-data.opendata.arcgis.com/items/e68f41ebfa5f4ea490ca9af701d44e02) — current and previous month
- 港区: [食品営業許可一覧 (CSV)](https://catalog.data.metro.tokyo.lg.jp/dataset/t131032d0000000244) — monthly snapshot of valid permits, newest first
- 台東区: [食品衛生営業施設一覧](https://www.city.taito.lg.jp/kenkohukusi/kenkokikikanrieisei/food/syokuhin-sisetu/index.html) — the two newest monthly 新規許可 CSVs (updated on the 10th)
- 品川区: [食品衛生許可施設一覧](https://www.city.shinagawa.tokyo.jp/PC/kenkou/kenkou-eisei/kenkou-eisei-syokuhin/opendate.html) — the two newest monthly CSVs (updated on the 15th); individuals' names and addresses are masked by the publisher and come through as \`null\`
- 世田谷区: [食品関係施設情報の公開について](https://www.city.setagaya.lg.jp/02245/online_tetsuzuki/3246.html) — the two newest 例月新規許可施設一覧 CSVs (updated on the 15th)
- 目黒区: [飲食店等 (BODIK CKAN)](https://data.bodik.jp/dataset/131105_food_business) — the two newest 飲食店 新規 monthly CSVs (updated by the 10th)
Items are sorted by permit date (\`pubDate\`). \`_extra\` holds \`source\`, \`ward\`, \`permit_no\`, \`name\`, \`address\`, \`permit_date\`, \`business_type\`, \`lat\` / \`lon\` (when the publisher gives them, else \`null\`) and the publisher's original columns in \`raw\`. Only 許可 rows are included (届出 rows are skipped).
| Query | Description | Default |
| ------- | ------------------------------------- | ------- |
| \`limit\` | Number of permits per source, max 500 | 100 |`,
categories: ["government"],
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: false
}
};
//#endregion
export { handler, route };