AM/FM 廣播搜尋器 (AM/FM Radio Finder)
無線調幅AM廣播電臺頻率及發射機位址紀錄
資料資源描述
無線調幅AM廣播電臺頻率及發射機位址資料表
無線調頻FM廣播電臺頻率及發射機位址紀錄
資料資源描述
無線調頻FM廣播電臺頻率及發射機位址紀錄
Python程式
import json
import urllib.request
import urllib.parse
import tkinter as tk
from tkinter import ttk, messagebox
import threading
# NCC Open Data URL
AM_URL = "https://api.ncc.gov.tw/uploaddowndoc?file=datagov/1556563683676434432.json&filedisplay=1556563683676434432_調幅AM_115_7_3.json&flag=doc"
FM_URL = "https://api.ncc.gov.tw/uploaddowndoc?file=datagov/1556562735747244032.json&filedisplay=1556562735747244032_調頻FM_115_7_3.json&flag=doc"
# 台灣縣市清單
COUNTIES = [
"全部縣市", "基隆市", "臺北市", "新北市", "桃園市", "新竹市", "新竹縣", "苗栗縣",
"臺中市", "彰化縣", "南投縣", "雲林縣", "嘉義市", "嘉義縣", "臺南市", "高雄市",
"屏東縣", "宜蘭縣", "花蓮縣", "臺東縣", "澎湖縣", "金門縣", "連江縣"
]
class RadioQueryApp:
def __init__(self, root):
self.root = root
self.root.title("台灣各縣市 AM/FM 廣播電台查詢系統")
self.root.geometry("900x550")
self.am_data = []
self.fm_data = []
self.create_widgets()
# 背景下載資料
threading.Thread(target=self.load_data, daemon=True).start()
def create_widgets(self):
# 控制區
ctrl_frame = ttk.Frame(self.root, padding=10)
ctrl_frame.pack(fill=tk.X)
# 縣市選擇
ttk.Label(ctrl_frame, text="選擇縣市:").pack(side=tk.LEFT, padx=5)
self.city_cb = ttk.Combobox(ctrl_frame, values=COUNTIES, state="readonly", width=12)
self.city_cb.current(0)
self.city_cb.pack(side=tk.LEFT, padx=5)
self.city_cb.bind("<<ComboboxSelected>>", lambda e: self.filter_data())
# 廣播類別
ttk.Label(ctrl_frame, text="廣播類別:").pack(side=tk.LEFT, padx=(15, 5))
self.type_cb = ttk.Combobox(ctrl_frame, values=["全部", "AM (調幅)", "FM (調頻)"], state="readonly", width=10)
self.type_cb.current(0)
self.type_cb.pack(side=tk.LEFT, padx=5)
self.type_cb.bind("<<ComboboxSelected>>", lambda e: self.filter_data())
# 關鍵字
ttk.Label(ctrl_frame, text="關鍵字:").pack(side=tk.LEFT, padx=(15, 5))
self.search_entry = ttk.Entry(ctrl_frame, width=15)
self.search_entry.pack(side=tk.LEFT, padx=5)
self.search_entry.bind("<KeyRelease>", lambda e: self.filter_data())
# 狀態顯示
self.status_label = ttk.Label(self.root, text="資料載入中,請稍候...", font=("Microsoft JhengHei", 10, "italic"))
self.status_label.pack(fill=tk.X, padx=10)
# 表格 (Treeview)
table_frame = ttk.Frame(self.root, padding=10)
table_frame.pack(fill=tk.BOTH, expand=True)
columns = ("type", "name", "freq", "power", "area")
self.tree = ttk.Treeview(table_frame, columns=columns, show="headings")
self.tree.heading("type", text="類別")
self.tree.heading("name", text="電台/發射台名稱")
self.tree.heading("freq", text="頻率")
self.tree.heading("power", text="功率")
self.tree.heading("area", text="設置地點/播音區域")
self.tree.column("type", width=80, anchor=tk.CENTER)
self.tree.column("name", width=220, anchor=tk.W)
self.tree.column("freq", width=110, anchor=tk.CENTER)
self.tree.column("power", width=100, anchor=tk.CENTER)
self.tree.column("area", width=340, anchor=tk.W)
# 捲軸
scrollbar = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscroll=scrollbar.set)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
def safe_quote_url(self, url):
"""處理網址內的中文轉碼"""
parsed = urllib.parse.urlparse(url)
encoded_path = urllib.parse.quote(parsed.path)
encoded_query = urllib.parse.quote(parsed.query, safe='=&')
return urllib.parse.urlunparse((
parsed.scheme, parsed.netloc, encoded_path,
parsed.params, encoded_query, parsed.fragment
))
def fetch_json(self, url):
"""獲取 JSON 資料"""
encoded_url = self.safe_quote_url(url)
req = urllib.request.Request(encoded_url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req) as response:
data = response.read().decode('utf-8-sig')
return json.loads(data)
def find_val(self, item, keywords, default="-"):
"""根據可能的 Key 關鍵字尋找 JSON 裡的欄位值"""
for k, v in item.items():
for kw in keywords:
if kw in str(k):
return str(v).strip()
return default
def parse_records(self, raw_data, radio_type):
"""相容多種 JSON 結構與動態欄位解析"""
records = []
# 判斷 JSON 格式外層
if isinstance(raw_data, list):
items = raw_data
elif isinstance(raw_data, dict):
items = raw_data.get("result", {}).get("records", raw_data.get("data", []))
else:
items = []
for item in items:
if not isinstance(item, dict):
continue
# 多重比對常見欄位名稱
name = self.find_val(item, ["事業名稱", "電台名稱", "台名", "廣播事業", "名稱"])
freq = self.find_val(item, ["頻率", "MHz", "kHz"])
power = self.find_val(item, ["功率", "發射功率"])
area = self.find_val(item, ["設置地點", "發射台位置", "播音區域", "地址", "縣市", "設置區域"])
# 將整個物件所有 value 轉成字串,用於廣泛比對
full_text = " ".join([str(v) for v in item.values()])
# 補全頻率單位
unit = 'kHz' if radio_type == 'AM' else 'MHz'
formatted_freq = f"{freq} {unit}" if freq != "-" and unit not in freq else freq
records.append({
"type": radio_type,
"name": name,
"freq": formatted_freq,
"power": power,
"area": area,
"full_text": full_text
})
return records
def load_data(self):
try:
am_raw = self.fetch_json(AM_URL)
fm_raw = self.fetch_json(FM_URL)
self.am_data = self.parse_records(am_raw, "AM")
self.fm_data = self.parse_records(fm_raw, "FM")
msg = f"載入完成!共 AM {len(self.am_data)} 筆,FM {len(self.fm_data)} 筆。"
self.root.after(0, self.on_data_loaded, True, msg)
except Exception as e:
self.root.after(0, self.on_data_loaded, False, f"資料載入失敗: {str(e)}")
def on_data_loaded(self, success, message):
self.status_label.config(text=message)
if success:
self.filter_data()
else:
messagebox.showerror("錯誤", message)
def filter_data(self):
"""資料篩選與繪製"""
selected_city = self.city_cb.get()
selected_type = self.type_cb.get()
keyword = self.search_entry.get().strip().lower()
# 清空清單
for row in self.tree.get_children():
self.tree.delete(row)
dataset = []
if selected_type in ["全部", "AM (調幅)"]:
dataset.extend(self.am_data)
if selected_type in ["全部", "FM (調頻)"]:
dataset.extend(self.fm_data)
count = 0
for item in dataset:
# 縣市比對(同時比對「臺」與「台」)
city_match = True
if selected_city != "全部縣市":
city_1 = selected_city
city_2 = selected_city.replace("臺", "台") if "臺" in selected_city else selected_city.replace("台", "臺")
city_match = (city_1 in item["full_text"]) or (city_2 in item["full_text"])
# 關鍵字比對
keyword_match = True
if keyword:
keyword_match = keyword in item["full_text"].lower()
if city_match and keyword_match:
self.tree.insert("", tk.END, values=(
item["type"],
item["name"],
item["freq"],
item["power"],
item["area"]
))
count += 1
self.status_label.config(text=f"查詢結果:共 {count} 筆資料")
if __name__ == "__main__":
root = tk.Tk()
app = RadioQueryApp(root)
root.mainloop()
Flutter 專案的 pubspec.yaml
name: am_fm_app
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.13.5
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
http: ^1.2.0 # 加入 HTTP 套件
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
main.dart 完整程式碼
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() {
runApp(const RadioApp());
}
class RadioApp extends StatelessWidget {
const RadioApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '台灣各縣市 AM/FM 廣播電台查詢系統',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const RadioHomePage(),
);
}
}
class RadioStation {
final String type;
final String name;
final String freq;
final String power;
final String area;
final String fullText;
RadioStation({
required this.type,
required this.name,
required this.freq,
required this.power,
required this.area,
required this.fullText,
});
}
class RadioHomePage extends StatefulWidget {
const RadioHomePage({super.key});
@override
State<RadioHomePage> createState() => _RadioHomePageState();
}
class _RadioHomePageState extends State<RadioHomePage> {
// NCC Open Data URL
final String amUrl =
"https://api.ncc.gov.tw/uploaddowndoc?file=datagov/1556563683676434432.json&filedisplay=1556563683676434432_調幅AM_115_7_3.json&flag=doc";
final String fmUrl =
"https://api.ncc.gov.tw/uploaddowndoc?file=datagov/1556562735747244032.json&filedisplay=1556562735747244032_調頻FM_115_7_3.json&flag=doc";
final List<String> counties = [
"全部縣市",
"基隆市",
"臺北市",
"新北市",
"桃園市",
"新竹市",
"新竹縣",
"苗栗縣",
"臺中市",
"彰化縣",
"南投縣",
"雲林縣",
"嘉義市",
"嘉義縣",
"臺南市",
"高雄市",
"屏東縣",
"宜蘭縣",
"花蓮縣",
"臺東縣",
"澎湖縣",
"金門縣",
"連江縣",
];
final List<String> radioTypes = ["全部", "AM (調幅)", "FM (調頻)"];
String selectedCity = "全部縣市";
String selectedType = "全部";
String keyword = "";
List<RadioStation> amData = [];
List<RadioStation> fmData = [];
List<RadioStation> filteredData = [];
bool isLoading = true;
String statusMessage = "資料載入中...";
@override
void initState() {
super.initState();
loadData();
}
// 尋找 JSON 中的動態 Key
String findVal(
Map<String, dynamic> item,
List<String> keywords, {
String defaultValue = "-",
}) {
for (var entry in item.entries) {
for (var kw in keywords) {
if (entry.key.toString().contains(kw)) {
return entry.value?.toString().trim() ?? defaultValue;
}
}
}
return defaultValue;
}
// 解析 JSON
List<RadioStation> parseRecords(dynamic rawData, String radioType) {
List<RadioStation> records = [];
List items = [];
if (rawData is List) {
items = rawData;
} else if (rawData is Map) {
items = rawData["result"]?["records"] ?? rawData["data"] ?? [];
}
for (var item in items) {
if (item is! Map<String, dynamic>) continue;
String name = findVal(item, ["事業名稱", "電台名稱", "台名", "廣播事業", "名稱"]);
String freq = findVal(item, ["頻率", "MHz", "kHz"]);
String power = findVal(item, ["功率", "發射功率"]);
String area = findVal(item, [
"設置地點",
"發射台位置",
"播音區域",
"地址",
"縣市",
"設置區域",
]);
String fullText = item.values.map((v) => v?.toString() ?? "").join(" ");
String unit = radioType == 'AM' ? 'kHz' : 'MHz';
String formattedFreq = (freq != "-" && !freq.contains(unit))
? "$freq $unit"
: freq;
records.add(
RadioStation(
type: radioType,
name: name,
freq: formattedFreq,
power: power,
area: area,
fullText: fullText,
),
);
}
return records;
}
// 抓取 API 資料
Future<void> loadData() async {
try {
final responses = await Future.wait([
http.get(Uri.parse(amUrl), headers: {'User-Agent': 'Mozilla/5.0'}),
http.get(Uri.parse(fmUrl), headers: {'User-Agent': 'Mozilla/5.0'}),
]);
if (responses[0].statusCode == 200 && responses[1].statusCode == 200) {
final amRaw = jsonDecode(utf8.decode(responses[0].bodyBytes));
final fmRaw = jsonDecode(utf8.decode(responses[1].bodyBytes));
setState(() {
amData = parseRecords(amRaw, "AM");
fmData = parseRecords(fmRaw, "FM");
isLoading = false;
filterData();
});
} else {
throw Exception("HTTP 請求失敗");
}
} catch (e) {
setState(() {
isLoading = false;
statusMessage = "資料載入失敗,請檢查網路連線。";
});
}
}
// 資料過濾
void filterData() {
List<RadioStation> dataset = [];
if (selectedType == "全部" || selectedType == "AM (調幅)") {
dataset.addAll(amData);
}
if (selectedType == "全部" || selectedType == "FM (調頻)") {
dataset.addAll(fmData);
}
setState(() {
filteredData = dataset.where((item) {
bool cityMatch = true;
if (selectedCity != "全部縣市") {
String city1 = selectedCity;
String city2 = selectedCity.contains("臺")
? selectedCity.replaceAll("臺", "台")
: selectedCity.replaceAll("台", "臺");
cityMatch =
item.fullText.contains(city1) || item.fullText.contains(city2);
}
bool keywordMatch = true;
if (keyword.isNotEmpty) {
keywordMatch = item.fullText.toLowerCase().contains(
keyword.toLowerCase(),
);
}
return cityMatch && keywordMatch;
}).toList();
statusMessage = "找到 ${filteredData.length} 筆電台資料";
});
}
// 顯示詳細資料彈窗
void showDetailsDialog(RadioStation station) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Chip(
label: Text(
station.type,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: station.type == "AM"
? Colors.orange
: Colors.indigo,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Expanded(
child: Text(
station.name,
style: const TextStyle(fontSize: 18),
overflow: TextOverflow.ellipsis,
),
),
],
),
content: SingleChildScrollView(
child: ListBody(
children: [
_detailRow("廣播頻率", station.freq),
_detailRow("發射功率", station.power),
_detailRow("設置地點/播音區域", station.area),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("關閉"),
),
],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("台灣廣播電台查詢"),
centerTitle: true,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
),
body: SafeArea(
child: Column(
children: [
// 上方選擇面板 (專為手機優化)
Container(
padding: const EdgeInsets.all(10.0),
color: Colors.grey.shade50,
child: Column(
children: [
Row(
children: [
// 縣市選單
Expanded(
child: DropdownButtonFormField<String>(
isExpanded: true,
value: selectedCity,
decoration: const InputDecoration(
labelText: "縣市",
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
border: OutlineInputBorder(),
),
items: counties.map((city) {
return DropdownMenuItem(
value: city,
child: Text(
city,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (val) {
if (val != null) {
selectedCity = val;
filterData();
}
},
),
),
const SizedBox(width: 8),
// 類別選單
Expanded(
child: DropdownButtonFormField<String>(
isExpanded: true,
value: selectedType,
decoration: const InputDecoration(
labelText: "類別",
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
border: OutlineInputBorder(),
),
items: radioTypes.map((type) {
return DropdownMenuItem(
value: type,
child: Text(
type,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (val) {
if (val != null) {
selectedType = val;
filterData();
}
},
),
),
],
),
const SizedBox(height: 8),
// 搜尋輸入框
SizedBox(
height: 45,
child: TextField(
decoration: const InputDecoration(
hintText: "搜尋電台名稱或地址",
prefixIcon: Icon(Icons.search, size: 20),
contentPadding: EdgeInsets.symmetric(
vertical: 0,
horizontal: 10,
),
border: OutlineInputBorder(),
),
onChanged: (val) {
keyword = val.trim();
filterData();
},
),
),
],
),
),
// 統計條
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
color: Theme.of(context).colorScheme.primaryContainer
.withOpacity(0.4),
child: Text(
statusMessage,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
// 電台列表 (手機卡片視圖)
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: filteredData.isEmpty
? const Center(child: Text("沒有找到符合條件的廣播電台"))
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: filteredData.length,
itemBuilder: (context, index) {
final station = filteredData[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
elevation: 1.5,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
leading: CircleAvatar(
radius: 20,
backgroundColor: station.type == "AM"
? Colors.orange
: Colors.indigo,
child: Text(
station.type,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
title: Text(
station.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
station.area,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
station.freq,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.primary,
),
),
const SizedBox(height: 2),
const Icon(
Icons.chevron_right,
size: 16,
color: Colors.grey,
),
],
),
onTap: () => showDetailsDialog(station),
),
);
},
),
),
],
),
),
);
}
}