iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Modern Web

每天一點 API:打造我的生活小工具系列 第 28

Day 28 — 最終專案:打造生活資訊系統

  • 分享至 

  • xImage
  •  

在過去幾週,我學會了怎麼使用各種 API,比如天氣 API、新聞 API,也練習過讓程式自動執行的排程功能,以及操作 CLI(命令列介面)和 GUI(圖形介面)。

今天的目標是把這些技能全部整合成一個實際可用的小應用程式——我的生活助手 App。這個 App 可以即時查詢天氣、空氣品質和最新新聞,並支援兩種操作方式:一種是在終端機使用的 CLI,另一種是用 Streamlit 建立的網頁介面。

這樣做不但能讓我熟悉多個 API 的整合,也能學會如何設計不同的使用介面,讓使用者能用自己習慣的方式快速取得生活中重要的資訊。

今天的小專案目標

今天我的小專案目標是打造一個生活資訊系統,讓使用者可以透過兩種介面:命令列(CLI)和網頁介面(使用 Streamlit)來操作。

這個系統可以做到以下幾件事:

  • 查詢即時天氣資訊,包括溫度、體感溫度、濕度和天氣描述。

  • 查詢空氣品質指數(AQI)以及 PM2.5 和 PM10 的數值。

  • 取得最新的新聞標題和連結,讓使用者隨時掌握最新消息。

  • 將這些資料整理並輸出成 JSON 與 CSV 格式的報表,並且在報表上附加查詢時間的標記。

透過這樣的設計,不論使用者是喜歡在終端機中快速操作,還是喜歡用簡單美觀的網頁介面,都可以方便地使用這個生活資訊系統,隨時掌握各種重要資訊。我也能透過實作這個專案,讓我更清楚了解如何使用 Python 來串接多個 API,並將資料整合與呈現。

實作流程

步驟 0:準備環境

  1. 建立 day28 資料夾與虛擬環境

  2. 安裝套件

python -m venv .venv
.\.venv\Scripts\Activate.ps1        # Windows PowerShell
pip install requests python-dotenv streamlit pandas
  1. 準備 .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 資料字典。

  1. 基本檢查
    if lat is None or lon is None:
        raise AQIError("缺少經緯度,無法查 AQI")

沒有經緯度就直接拒絕,避免送出無效請求

  1. 發送請求
    params = {"lat": lat, "lon": lon, "appid": key}
    r = requests.get(API_AIR, params=params, timeout=timeout)
    r.raise_for_status()
    data = r.json()
  1. 解析回應
    first = (data.get("list") or [{}])[0]
    main = first.get("main", {})
    comps = first.get("components", {})
  1. 回傳精簡結果
    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:
  1. 發送請求並處理 HTTP 狀態
    r = requests.get(API_NEWS, params={"limit": limit}, timeout=timeout)
    r.raise_for_status()
    data = r.json()
  1. 取出文章陣列,兼容不同鍵名
    items = data.get("results") or data.get("articles") or []
  1. 精簡每篇欄位
    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"),
        })
  1. 回傳結果
    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"))
  1. 查天氣,拿到座標與金鑰
    weather = get_weather(city, units=units, lang=lang)
    lat, lon = weather["lat"], weather["lon"]
    key = weather["api_key"]
  1. 查 AQI
    # AQI
    try:
        aqi = get_aqi_by_coord(lat, lon, key=key)
    except Exception:
        aqi = {"aqi": None, "pm2_5": None, "pm10": None, "raw": None}

用座標查空氣品質。

  1. 查新聞
    # News
    try:
        news = get_news(limit=news_limit)
    except Exception:
        news = {"count": 0, "items": [], "raw": None}
  1. 格式化單位與時間
    unit_symbol = "°C" if units == "metric" else "°F"
    now_iso = datetime.now().isoformat(timespec="seconds")
  1. 彙整成一包結果
    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 / 原始回應")

執行結果:
https://ithelp.ithome.com.tw/upload/images/20251010/20178708OhhfDzYgVV.png
https://ithelp.ithome.com.tw/upload/images/20251010/20178708Ya5Fv3MDg9.png
https://ithelp.ithome.com.tw/upload/images/20251010/20178708WJY6WRIMv2.png

今日總結

今天我完成了一個真正整合性的專案,我把天氣、空氣品質和新聞這三種 API 串接在一起,打造了一個可以即時查詢、視覺化,而且還能自動存檔的生活助手 App。這次的練習讓我完整體驗到,從取得資料到做出應用的整個過程,也讓我更了解模組化設計和自動化程式的重要性。未來我還可以很輕鬆地加入其他 API,不斷擴充這個 App 的功能,讓它變得更實用和全面。


上一篇
Day 27 — 生活資訊面板:整合天氣、AQI、新聞
下一篇
Day 29 — 測試與除錯:讓 API 穩穩運作
系列文
每天一點 API:打造我的生活小工具29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言