From e002ffadad850d587d067107de3652e0b53d9432 Mon Sep 17 00:00:00 2001 From: Timmy Date: Mon, 13 Jul 2026 18:12:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=8C=97=E5=B8=82=E5=9E=83=E5=9C=BE?= =?UTF-8?q?=E8=BB=8A=E6=8F=90=E9=86=92=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + README.md | 64 ++++++++++++++++++++++++ garbage.py | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 garbage.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbf9de7 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# 新北市垃圾車提醒 CLI + +垃圾車快到你家清運點時,終端響鈴+macOS 原生通知提醒你。 + +單檔、純 Python 標準函式庫、零相依。資料來自[新北市資料開放平臺](https://data.ntpc.gov.tw/)(政府資料開放授權條款第 1 版,商用 OK)。 + +## 需求 + +- Python 3(macOS 內建即可) +- macOS 才有原生通知;其他平台只有終端響鈴 + +## 用法 + +### 1. 找清運點 + +用路線、里、點名或行政區關鍵字搜,拿到 `lineid` 和經緯度: + +``` +./garbage.py find 保生路 +``` + +``` +lineid= 234012 rank= 37 14:00 永和區保安里 仁愛路保生路口 (25.00920367,121.5051601) +... +``` + +### 2. 開始盯 + +把你家那個清運點的經緯度和 `lineid` 餵進去: + +``` +./garbage.py watch --lat 25.0092 --lng 121.5052 --line 234012 +``` + +每 120 秒打一次即時 API,算最近出勤車輛的距離。進到 300 公尺就提醒,車開遠後自動重置以便下一趟再提醒。`Ctrl-C` 結束。 + +| 參數 | 預設 | 說明 | +|------|------|------| +| `--lat` / `--lng` | 必填 | 你的清運點經緯度 | +| `--line` | 無 | 只看這個 lineid(**強烈建議填**,否則拿全市所有車比距離,雜訊大) | +| `--radius` | `300` | 提醒距離(公尺) | +| `--interval` | `120` | 輪詢秒數(API 本身 2 分鐘更新一次) | + +想背景常駐:`./garbage.py watch ... &`,或交給 `launchd`/`cron`。 + +### demo + +``` +./garbage.py demo +``` + +距離計算自我測試。 + +## 注意 + +- 即時 API **只顯示目前出勤中的車**,離峰/收班時段會抓到 0 筆,這是正常的。 +- 政府伺服器憑證缺欄位,程式關閉了 TLS 憑證驗證(公開唯讀資料,風險僅止於被餵假座標)。 + +## 資料來源 + +- 即時動態:`28ab4122-60e1-4065-98e5-abccb69aaca6` +- 表定路線:`edc3ad26-8ae7-4916-a00b-bc6048d19bf8` + +格式可換 `/json`、`/csv`、`/xml`,用 `page` 與 `size` 分頁。其他 endpoint 見 。 diff --git a/garbage.py b/garbage.py new file mode 100755 index 0000000..b190283 --- /dev/null +++ b/garbage.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""新北市垃圾車提醒 CLI。純 stdlib,無外部相依。 + +用法: + ./garbage.py find <關鍵字> # 搜尋清運點,取得 lineid 與經緯度 + ./garbage.py watch --lat 25.01 --lng 121.52 --line 234007 [--radius 300] [--interval 120] + +find 幫你從表定路線挑到你家最近的清運點,watch 定時比對出勤車輛距離、靠近就提醒。 +""" +import argparse +import json +import math +import ssl +import subprocess +import sys +import time +import urllib.request + +# ponytail: 政府伺服器憑證缺 Subject Key Identifier,Python 3.14 嚴格模式會拒絕。 +# 這是公開、唯讀、免驗證的開放資料,關閉憑證驗證風險僅止於被餵假座標,可接受。 +_CTX = ssl._create_unverified_context() + +LIVE = "https://data.ntpc.gov.tw/api/datasets/28ab4122-60e1-4065-98e5-abccb69aaca6/json" +ROUTES = "https://data.ntpc.gov.tw/api/datasets/edc3ad26-8ae7-4916-a00b-bc6048d19bf8/json" + + +def fetch(url, size=1000, all_pages=False): + out, page = [], 0 + while True: + u = f"{url}?page={page}&size={size}" + with urllib.request.urlopen(u, timeout=30, context=_CTX) as r: + batch = json.load(r) + out += batch + if not all_pages or len(batch) < size: + return out + page += 1 + + +def haversine(lat1, lng1, lat2, lng2): + """兩點間公尺距離。""" + R = 6371000 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lng2 - lng1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +def cmd_find(args): + kw = args.keyword + hits = [ + p for p in fetch(ROUTES, all_pages=True) + if kw in p["name"] or kw in p["village"] or kw in p["linename"] or kw in p["city"] + ] + if not hits: + print(f"找不到含「{kw}」的清運點") + return + for p in hits: + print(f'lineid={p["lineid"]:>7} rank={p["rank"]:>3} {p["time"]} ' + f'{p["city"]}{p["village"]} {p["name"]} ' + f'({p["latitude"]},{p["longitude"]})') + print(f"\n共 {len(hits)} 筆。挑一筆,把它的 lineid 和經緯度餵給 watch。") + + +def notify(title, msg): + print(f"\a🚛 {title}: {msg}") # \a 響鈴 + # ponytail: macOS 原生通知;非 macOS 就只有上面那行 + try: + subprocess.run( + ["osascript", "-e", f'display notification "{msg}" with title "{title}"'], + check=False, + ) + except FileNotFoundError: + pass + + +def cmd_watch(args): + alerted = False + while True: + try: + cars = [c for c in fetch(LIVE, size=200) + if not args.line or c["lineid"] == args.line] + except Exception as e: + print(f"抓取失敗,稍後重試:{e}", file=sys.stderr) + time.sleep(args.interval) + continue + + if not cars: + print(f"[{time.strftime('%H:%M:%S')}] 目前沒有出勤車輛(離峰或還沒發車)") + else: + nearest = min( + cars, + key=lambda c: haversine(args.lat, args.lng, + float(c["latitude"]), float(c["longitude"])), + ) + d = haversine(args.lat, args.lng, + float(nearest["latitude"]), float(nearest["longitude"])) + print(f"[{time.strftime('%H:%M:%S')}] 最近車輛 {nearest['car']} " + f"距離 {d:.0f}m({nearest['location']},定位 {nearest['time']})") + if d <= args.radius and not alerted: + notify("垃圾車快到了", f"約 {d:.0f} 公尺,{nearest['location']}") + alerted = True + elif d > args.radius * 1.5: + alerted = False # 車開遠了,重置以便下一趟再提醒 + + time.sleep(args.interval) + + +def demo(): + # 台北車站 ↔ 永和保生路,約 4.3 公里 + d = haversine(25.0478, 121.5170, 25.010365, 121.504743) + assert 4000 < d < 4700, d + assert haversine(25.0, 121.0, 25.0, 121.0) == 0 + print("ok") + + +def main(): + ap = argparse.ArgumentParser(description="新北市垃圾車提醒") + sub = ap.add_subparsers(dest="cmd", required=True) + + f = sub.add_parser("find", help="搜尋清運點") + f.add_argument("keyword", help="路線/里/點名/行政區關鍵字") + f.set_defaults(func=cmd_find) + + w = sub.add_parser("watch", help="監看並提醒") + w.add_argument("--lat", type=float, required=True, help="你的清運點緯度") + w.add_argument("--lng", type=float, required=True, help="你的清運點經度") + w.add_argument("--line", help="只看這個 lineid(強烈建議填)") + w.add_argument("--radius", type=float, default=300, help="提醒距離公尺,預設 300") + w.add_argument("--interval", type=int, default=120, help="輪詢秒數,預設 120") + w.set_defaults(func=cmd_watch) + + sub.add_parser("demo", help="自我測試").set_defaults(func=lambda a: demo()) + + args = ap.parse_args() + args.func(args) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n收工")