← Public packages
@reng/air-quality
Scrape air-quality.com for AQI, pollutants, pollen, and weather (HA-style field set).
src/app.ts
162 lines · 6.5 KB · TypeScriptimport { packageContext } from 'kody:runtime'
import { getSnapshot } from './getSnapshot'
import { AQI_STANDARDS, type AqiStandard } from './getSnapshot'
function appUrl(path: string): URL {
if (!packageContext?.hostedUrl) {
throw new Error('This module must run as a package app.')
}
const relative = path.replace(/^\/+/, '')
const mount = packageContext.appBasePath.endsWith('/')
? packageContext.appBasePath
: `${packageContext.appBasePath}/`
return new URL(`${mount}${relative}`, packageContext.hostedUrl)
}
function renderPage(): Response {
const apiPath = appUrl('api/getSnapshot').pathname
const standards = Object.entries(AQI_STANDARDS)
.map(([k, label]) => `<option value="${k}"${k === 'aqi_cn' ? ' selected' : ''}>${label}</option>`)
.join('')
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>空气质量快照</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b1220; color: #e2e8f0; }
main { width: min(720px, 94vw); background: #111827; border-radius: 16px; padding: 1.4rem; box-shadow: 0 20px 50px rgba(0,0,0,.4); }
h1 { margin: 0 0 .35rem; font-size: 1.35rem; }
p.lead { margin: 0 0 1rem; color: #94a3b8; font-size: .92rem; }
label { display: block; margin: .55rem 0 .35rem; font-size: .88rem; color: #cbd5e1; }
input, select { width: 100%; border-radius: 10px; border: 1px solid #334155; background: #0b1220; color: inherit; padding: .7rem; font-size: 1rem; box-sizing: border-box; }
.row { display: flex; gap: .75rem; align-items: center; margin: .9rem 0 1rem; flex-wrap: wrap; }
button { border-radius: 999px; border: 0; padding: .55rem 1.15rem; font-size: .95rem; background: #38bdf8; color: #0f172a; font-weight: 700; cursor: pointer; }
button:disabled { opacity: .6; cursor: wait; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .6rem; margin-top: .8rem; }
.card { background: #0b1220; border: 1px solid #334155; border-radius: 12px; padding: .75rem; }
.card .k { color: #94a3b8; font-size: .75rem; }
.card .v { font-size: 1.15rem; font-weight: 700; margin-top: .2rem; }
.out { margin-top: .8rem; padding: .9rem; border-radius: 10px; background: #0b1220; border: 1px solid #334155; white-space: pre-wrap; word-break: break-word; font-size: .9rem; }
.err { color: #fca5a5; margin-top: .6rem; }
.note { margin-top: 1rem; color: #64748b; font-size: .78rem; line-height: 1.4; }
</style>
</head>
<body>
<main>
<h1>空气质量快照</h1>
<p class="lead">Scrapes air-quality.com(同 HA air_quality_cn 字段):AQI / 污染物 / 花粉 / 天气。上游 HTML 可能变更。</p>
<label for="city">地点</label>
<input id="city" value="北京" placeholder="北京 / 上海 / Berlin / 海淀" />
<label for="standard">AQI 标准</label>
<select id="standard">${standards}</select>
<div class="row"><button id="go" type="button">查询</button></div>
<div class="grid" id="cards"></div>
<div class="out" id="out">—</div>
<div class="err" id="err"></div>
<p class="note">Not medical advice. Primary: scraped air-quality.com. Fallback: Open-Meteo / 北京花粉监测平台.</p>
</main>
<script>
const apiPath = ${JSON.stringify(apiPath)};
const cityEl = document.getElementById('city');
const standardEl = document.getElementById('standard');
const outEl = document.getElementById('out');
const errEl = document.getElementById('err');
const goEl = document.getElementById('go');
const cardsEl = document.getElementById('cards');
function card(k, v) {
const d = document.createElement('div');
d.className = 'card';
d.innerHTML = '<div class="k"></div><div class="v"></div>';
d.querySelector('.k').textContent = k;
d.querySelector('.v').textContent = v == null || v === '' ? '—' : String(v);
return d;
}
async function run() {
errEl.textContent = '';
goEl.disabled = true;
cardsEl.innerHTML = '';
try {
const res = await fetch(apiPath, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ location: cityEl.value, standard: standardEl.value, fallback: true }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
const p = data.pollutants || {};
const w = data.weather || {};
const pol = data.pollen || {};
;[
['AQI', data.aqi],
['等级', data.level],
['PM2.5', p.pm25],
['PM10', p.pm10],
['O3', p.o3],
['NO2', p.no2],
['花粉', pol.raw],
['温度°C', w.temperatureC],
].forEach(([k, v]) => cardsEl.appendChild(card(k, v)));
outEl.textContent = [
data.summary,
'source: ' + data.source,
data.url ? 'url: ' + data.url : '',
data.coverageNote || '',
(data.warnings || []).join('\\n'),
].filter(Boolean).join('\\n');
} catch (e) {
outEl.textContent = '—';
errEl.textContent = String(e && e.message ? e.message : e);
} finally {
goEl.disabled = false;
}
}
goEl.addEventListener('click', run);
cityEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
</script>
</body>
</html>`
return new Response(html, {
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
})
}
async function handleApi(request: Request): Promise<Response> {
if (request.method !== 'POST') {
return Response.json({ error: 'POST required' }, { status: 405 })
}
let body: {
location?: string
place?: string
standard?: AqiStandard
fallback?: boolean
} = {}
try {
body = (await request.json()) as typeof body
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
try {
const result = await getSnapshot(body)
return Response.json(result)
} catch (e) {
return Response.json(
{ error: e instanceof Error ? e.message : String(e) },
{ status: 400 },
)
}
}
export default {
async fetch(request: Request): Promise<Response> {
const path = new URL(request.url).pathname
if (path === '/' || path === '') return renderPage()
if (path === '/api/getSnapshot') return handleApi(request)
return new Response('Not found', { status: 404 })
},
}