新北市垃圾車提醒 CLI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
64
README.md
Normal file
64
README.md
Normal file
@@ -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 見 <https://data.ntpc.gov.tw/openapi>。
|
||||||
143
garbage.py
Executable file
143
garbage.py
Executable file
@@ -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收工")
|
||||||
Reference in New Issue
Block a user