在過去幾週,我學會了怎麼使用各種 API,比如天氣 API、新聞 API,也練習過讓程式自動執行的排程功能,以及操作 CLI(命令列介面)和 GUI(圖形介面)。
今天的目標是把這些技能全部整合成一個實際可用的小應用程式——我的生活助手 App。這個 App 可以即時查詢天氣、空氣品質和最新新聞,並支援兩種操作方式:一種是在終端機使用的 CLI,另一種是用 Streamlit 建立的網頁介面。
這樣做不但能讓我熟悉多個 API 的整合,也能學會如何設計不同的使用介面,讓使用者能用自己習慣的方式快速取得生活中重要的資訊。
今天我的小專案目標是打造一個生活資訊系統,讓使用者可以透過兩種介面:命令列(CLI)和網頁介面(使用 Streamlit)來操作。
這個系統可以做到以下幾件事:
查詢即時天氣資訊,包括溫度、體感溫度、濕度和天氣描述。
查詢空氣品質指數(AQI)以及 PM2.5 和 PM10 的數值。
取得最新的新聞標題和連結,讓使用者隨時掌握最新消息。
將這些資料整理並輸出成 JSON 與 CSV 格式的報表,並且在報表上附加查詢時間的標記。
透過這樣的設計,不論使用者是喜歡在終端機中快速操作,還是喜歡用簡單美觀的網頁介面,都可以方便地使用這個生活資訊系統,隨時掌握各種重要資訊。我也能透過實作這個專案,讓我更清楚了解如何使用 Python 來串接多個 API,並將資料整合與呈現。
步驟 0:準備環境
建立 day28 資料夾與虛擬環境
安裝套件
python -m venv .venv
.\.venv\Scripts\Activate.ps1 # Windows PowerShell
pip install requests python-dotenv streamlit pandas
.env
OWM_API_KEY=我們的OpenWeatherMap金鑰
CITY=Taipei
UNITS=metric
LANG=zh_tw
NEWS_LIMIT=5
接著記得把 .env
加到 .gitignore
,避免金鑰上傳。
步驟 1:共用小工具
import json, csv
from pathlib import Path
from datetime import datetime
def ts(): # 生出時間戳字串,拿來當檔名的一部分,避免覆蓋舊檔。
return datetime.now().strftime("%Y%m%d_%H%M%S")
def ensure_dir(p: Path): # 如果資料夾不存在就自動建立。
p.mkdir(parents=True, exist_ok=True)
def save_json(data, path: Path): # 把 Python 資料存成漂亮縮排的 JSON 檔
ensure_dir(path.parent)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def save_csv_row(path: Path, fieldnames, row: dict):
# 把一列資料追加寫入 CSV;若是新檔會先寫表頭。
ensure_dir(path.parent)
newfile = not path.exists()
with open(path, "a", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=fieldnames)
if newfile:
w.writeheader()
w.writerow(row)
步驟 2:天氣模組(回傳城市、溫度與座標)
匯入與常數
import os, requests
from pathlib import Path
from dotenv import load_dotenv
API_WEATHER = "https://api.openweathermap.org/data/2.5/weather"
自訂錯誤型別,之後只要天氣流程出錯都用這行程式碼丟出清楚訊息。
class WeatherError(RuntimeError):
pass
讀同資料夾的 .env,取 OWM_API_KEY;沒取到就丟 WeatherError。
def _load_api_key():
load_dotenv(Path(__file__).with_name(".env"))
key = os.getenv("OWM_API_KEY")
if not key:
raise WeatherError("找不到 OWM_API_KEY(請在 .env 或環境變數設定)")
return key
先拿金鑰,組查詢參數,發送 requests.get()。
針對常見狀態碼做友善提示:401 金鑰無效或未啟用、404 城市找不到,其它錯誤交給 raise_for_status()。
成功後用 .json() 取回資料,使用 .get() 安全取值,把需要的欄位整理成一個字典回傳。
def get_weather(city: str, *, units="metric", lang="zh_tw", timeout=15) -> dict:
key = _load_api_key()
params = {"q": city, "appid": key, "units": units, "lang": lang}
r = requests.get(API_WEATHER, params=params, timeout=timeout)
if r.status_code == 401:
raise WeatherError("401 未授權:API Key 無效或未啟用")
if r.status_code == 404:
raise WeatherError(f"找不到城市:{city}")
r.raise_for_status()
data = r.json()
main = data.get("main", {})
weather0 = (data.get("weather") or [{}])[0]
sys = data.get("sys", {})
coord = data.get("coord", {})
return {
"name": data.get("name") or city,
"country_code": sys.get("country"),
"lat": coord.get("lat"),
"lon": coord.get("lon"),
"temp": main.get("temp"), # units=metric 時為 °C
"feels_like": main.get("feels_like"),
"humidity": main.get("humidity"),
"desc": weather0.get("description"),
"raw": data,
"api_key": key, # 之後 AQI 要用
}
步驟 3:空氣品質 AQI 模組(用 OWM air_pollution)
匯入與常數
import requests
API_AIR = "https://api.openweathermap.org/data/2.5/air_pollution"
自訂錯誤類別
class AQIError(RuntimeError):
pass
主要函式:用座標查 AQI
def get_aqi_by_coord(lat: float, lon: float, *, key: str, timeout=15) -> dict:
輸入經緯度與 OWM_API_KEY
,回傳整理好的 AQI 資料字典。
if lat is None or lon is None:
raise AQIError("缺少經緯度,無法查 AQI")
沒有經緯度就直接拒絕,避免送出無效請求
params = {"lat": lat, "lon": lon, "appid": key}
r = requests.get(API_AIR, params=params, timeout=timeout)
r.raise_for_status()
data = r.json()
first = (data.get("list") or [{}])[0]
main = first.get("main", {})
comps = first.get("components", {})
return {
"aqi": main.get("aqi"), # 1=Good, 5=Very Poor
"pm2_5": comps.get("pm2_5"),
"pm10": comps.get("pm10"),
"raw": data,
}
提供 AQI、PM2.5、PM10,並附上 raw 方便除錯或日後擴充。
步驟 4:新聞模組
匯入與常數
import requests
API_NEWS = "https://api.spaceflightnewsapi.net/v4/articles/"
自訂錯誤類別
class NewsError(RuntimeError):
pass
主要函式:抓最新新聞清單
def get_news(limit=5, timeout=15) -> dict:
r = requests.get(API_NEWS, params={"limit": limit}, timeout=timeout)
r.raise_for_status()
data = r.json()
items = data.get("results") or data.get("articles") or []
slim = []
for x in items:
slim.append({
"title": x.get("title"),
"source": x.get("news_site") or (x.get("source",{}) or {}).get("name"),
"published": x.get("published_at") or x.get("publishedAt"),
"url": x.get("url"),
})
return {"count": len(slim), "items": slim, "raw": data}
步驟 5:核心整合
這一步,我們會一次取三種資料,再統一回傳。
import os
from pathlib import Path
from dotenv import load_dotenv
from datetime import datetime
from weather_client import get_weather, WeatherError
from aqi_client import get_aqi_by_coord, AQIError
from news_client import get_news, NewsError
載入設定
def fetch_all(city: str, *, units="metric", lang="zh_tw", news_limit=5) -> dict:
load_dotenv(Path(__file__).with_name(".env"))
weather = get_weather(city, units=units, lang=lang)
lat, lon = weather["lat"], weather["lon"]
key = weather["api_key"]
# AQI
try:
aqi = get_aqi_by_coord(lat, lon, key=key)
except Exception:
aqi = {"aqi": None, "pm2_5": None, "pm10": None, "raw": None}
用座標查空氣品質。
# News
try:
news = get_news(limit=news_limit)
except Exception:
news = {"count": 0, "items": [], "raw": None}
unit_symbol = "°C" if units == "metric" else "°F"
now_iso = datetime.now().isoformat(timespec="seconds")
merged = {
"timestamp": now_iso,
"city": weather["name"],
"country_code": weather["country_code"],
"temp": weather["temp"],
"feels_like": weather["feels_like"],
"humidity": weather["humidity"],
"weather_desc": weather["desc"],
"unit": unit_symbol,
"aqi": aqi["aqi"],
"pm2_5": aqi["pm2_5"],
"pm10": aqi["pm10"],
"news_count": news["count"],
"news_items": news["items"],
"raw": {
"weather": weather["raw"],
"aqi": aqi["raw"],
"news": news.get("raw"),
}
}
return merged
把天氣、AQI、新聞與原始回應集中在一個字典裡,方便後續印出、寫入 JSON/CSV,或給 GUI/CLI 使用。
步驟 6:命令列 CLI
匯入模組與工具
import os, argparse
from pathlib import Path
from dotenv import load_dotenv
from day28_utils import ts, ensure_dir, save_json, save_csv_row
from app_core import fetch_all
路徑常數
ROOT = Path(__file__).resolve().parent
OUT_DIR = ROOT / "output"
RAW_DIR = OUT_DIR / "raw"
入口函式:讀 .env
和解析參數
def main():
load_dotenv(ROOT / ".env")
ap = argparse.ArgumentParser(description="Day28 我的生活助手:天氣 + AQI + 新聞")
ap.add_argument("-c", "--city", default=os.getenv("CITY","Taipei"))
ap.add_argument("--units", choices=["metric","imperial"], default=os.getenv("UNITS","metric"))
ap.add_argument("--lang", default=os.getenv("LANG","zh_tw"))
ap.add_argument("--news-limit", type=int, default=int(os.getenv("NEWS_LIMIT","5")))
ap.add_argument("--save", action="store_true", help="輸出 CSV/JSON 到 output/")
args = ap.parse_args()
取得彙整資料
data = fetch_all(args.city, units=args.units, lang=args.lang, news_limit=args.news_limit)
終端機摘要輸出
print(f"時間:{data['timestamp']}")
print(f"地點:{data['city']}({data.get('country_code') or '-'})")
print(f"天氣:{data['weather_desc']},{data['temp']}{data['unit']}(體感 {data['feels_like']}{data['unit']}),濕度 {data['humidity']}%")
print(f"AQI :{data['aqi']}(1=好、5=很差),PM2.5={data['pm2_5']},PM10={data['pm10']}")
if data["news_count"]:
top = data["news_items"][0]
print(f"新聞:{data['news_count']} 則|Top1:{top['title']}|{top['source']}")
print(f"連結:{top['url']}")
else:
print("新聞:無")
選擇性寫檔
if args.save:
ensure_dir(OUT_DIR); ensure_dir(RAW_DIR)
# 存彙整
json_path = OUT_DIR / f"day28_summary_{ts()}.json"
save_json(data, json_path)
# 累積 CSV
csv_path = OUT_DIR / "day28_summary.csv"
fields = ["timestamp","city","country_code","temp","feels_like","humidity","weather_desc","unit","aqi","pm2_5","pm10","news_count"]
save_csv_row(csv_path, fields, {k: data.get(k) for k in fields})
# 存原始
save_json(data["raw"]["weather"], RAW_DIR / f"weather_{ts()}.json")
if data["raw"]["aqi"] is not None:
save_json(data["raw"]["aqi"], RAW_DIR / f"aqi_{ts()}.json")
if data["raw"]["news"] is not None:
save_json(data["raw"]["news"], RAW_DIR / f"news_{ts()}.json")
print(f"\n已輸出:\n- {json_path}\n- {csv_path}\n- 原始回應在 {RAW_DIR}")
if __name__ == "__main__":
main()
執行結果:
時間:2025-10-10T16:13:07
地點:Taipei(TW)
天氣:晴,31.92°C(體感 36.59°C),濕度 59%
AQI :3(1=好、5=很差),PM2.5=39.34,PM10=47.37
新聞:5 則|Top1:Former U.S. defense officials urge Pentagon to scale up hypersonic weapons to match China, Russia|SpaceNews
連結:https://spacenews.com/former-u-s-defense-officials-urge-pentagon-to-scale-up-hypersonic-weapons-to-match-china-russia/
步驟 7:Streamlit 網頁版
匯入與路徑
import os
from pathlib import Path
import pandas as pd
import streamlit as st
from dotenv import load_dotenv
from day28_utils import ts, ensure_dir, save_json, save_csv_row
from app_core import fetch_all
ROOT = Path(__file__).resolve().parent
OUT_DIR = ROOT / "output"
RAW_DIR = OUT_DIR / "raw"
頁面設定與環境變數
st.set_page_config(page_title="Day28 我的生活助手", page_icon="Weather", layout="centered")
load_dotenv(ROOT / ".env")
介面:標題與輸入區
st.title("Day28 我的生活助手")
colA, colB = st.columns(2)
city = colA.text_input("城市", os.getenv("CITY","Taipei"))
units = colB.radio("單位", options=["metric","imperial"], index=0 if os.getenv("UNITS","metric")=="metric" else 1,
format_func=lambda x: "攝氏 (°C)" if x=="metric" else "華氏 (°F)")
news_limit = st.slider("抓取新聞數量", min_value=1, max_value=10, value=int(os.getenv("NEWS_LIMIT","5")), step=1)
查詢按鈕:抓資料與顯示重點
if st.button("查詢"):
with st.spinner("抓資料中…"):
data = fetch_all(city, units=units, news_limit=news_limit)
st.success(f"{data['city']} 現在 {data['temp']}{data['unit']}(體感 {data['feels_like']}{data['unit']}),濕度 {data['humidity']}%,{data['weather_desc']}")
三欄指標卡與 AQI
c1, c2, c3 = st.columns(3)
c1.metric("溫度", f"{data['temp']}{data['unit']}")
c2.metric("體感", f"{data['feels_like']}{data['unit']}")
c3.metric("濕度", f"{data['humidity']}%")
st.write(f"**AQI**:{data['aqi']}(1=好、5=很差),PM2.5={data['pm2_5']},PM10={data['pm10']}")
新聞清單
st.subheader("最新新聞")
if data["news_count"]:
for i, item in enumerate(data["news_items"], 1):
st.markdown(f"{i}. [{item['title']}]({item['url']}) — {item['source']} | {item['published']}")
else:
st.caption("暫無新聞")
原始回應
with st.expander("原始回應 JSON(debug)"):
st.json(data["raw"])
儲存結果到 output/
if st.button("儲存到 output/"):
ensure_dir(OUT_DIR); ensure_dir(RAW_DIR)
json_path = OUT_DIR / f"day28_summary_{ts()}.json"
save_json(data, json_path)
csv_path = OUT_DIR / "day28_summary.csv"
fields = ["timestamp","city","country_code","temp","feels_like","humidity","weather_desc","unit","aqi","pm2_5","pm10","news_count"]
save_csv_row(csv_path, fields, {k: data.get(k) for k in fields})
save_json(data["raw"]["weather"], RAW_DIR / f"weather_{ts()}.json")
if data["raw"]["aqi"] is not None:
save_json(data["raw"]["aqi"], RAW_DIR / f"aqi_{ts()}.json")
if data["raw"]["news"] is not None:
save_json(data["raw"]["news"], RAW_DIR / f"news_{ts()}.json")
st.success("已輸出 JSON / CSV / 原始回應")
執行結果:
今天我完成了一個真正整合性的專案,我把天氣、空氣品質和新聞這三種 API 串接在一起,打造了一個可以即時查詢、視覺化,而且還能自動存檔的生活助手 App。這次的練習讓我完整體驗到,從取得資料到做出應用的整個過程,也讓我更了解模組化設計和自動化程式的重要性。未來我還可以很輕鬆地加入其他 API,不斷擴充這個 App 的功能,讓它變得更實用和全面。