Spaces:
Sleeping
Sleeping
Commit
·
b0a371d
1
Parent(s):
5c3d1cd
增加範圍公里可選
Browse files
README.md
CHANGED
@@ -11,3 +11,73 @@ short_description: 便利商店的打折品
|
|
11 |
---
|
12 |
|
13 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
---
|
12 |
|
13 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
14 |
+
|
15 |
+
|
16 |
+
---
|
17 |
+
|
18 |
+
# 便利商店即期食品查詢 (7-11 / FamilyMart)
|
19 |
+
|
20 |
+
本專案透過非官方 API,查詢台灣 7-11 與全家便利商店「即期食品」的資訊。
|
21 |
+
|
22 |
+
> **注意:**
|
23 |
+
> 1. 需要有效的 `MID_V` 值才能查詢 7-11 資料。`MID_V` 可能會隨官方更新而失效,需自行抓包或取得授權。
|
24 |
+
> 2. 全家 API 可能會變動或停止。
|
25 |
+
> 3. 本專案僅供研究教學之用,請勿用於商業或非法目的。
|
26 |
+
|
27 |
+
## 功能
|
28 |
+
- 以 GPS 座標或地址(目前僅示意)搜尋附近的 7-11 / 全家門市。
|
29 |
+
- 可自訂搜尋範圍(3 / 5 / 7 / 13 / 21 公里)。
|
30 |
+
- 顯示每間門市的即期食品清單與剩餘數量。
|
31 |
+
|
32 |
+
## 使用方式
|
33 |
+
1. 安裝 Python 3.8+ 及套件:
|
34 |
+
```bash
|
35 |
+
pip install gradio requests pandas geopy
|
36 |
+
|
37 |
+
2. 執行:
|
38 |
+
|
39 |
+
python app.py
|
40 |
+
|
41 |
+
|
42 |
+
3. 瀏覽器打開 http://127.0.0.1:7860,即可看到查詢介面。
|
43 |
+
|
44 |
+
注意事項
|
45 |
+
• 此為個人練習與技術示範,非官方專案。
|
46 |
+
• 若出現「憑證過期」或「Token 失敗」等訊息,表示 MID_V 失效,需要更新。
|
47 |
+
|
48 |
+
Convenience Store Expiring-Food Query (7-11 / FamilyMart)
|
49 |
+
|
50 |
+
This project uses unofficial APIs to query expiring-food items in Taiwan’s 7-11 and FamilyMart convenience stores.
|
51 |
+
|
52 |
+
Note:
|
53 |
+
1. A valid MID_V is required to access 7-11’s data. MID_V may expire as the official app updates. You must capture or obtain it by yourself.
|
54 |
+
2. FamilyMart’s API might change or be discontinued without notice.
|
55 |
+
3. This project is for educational and research purposes only. Please do not use it for commercial or illegal purposes.
|
56 |
+
|
57 |
+
Features
|
58 |
+
• Search nearby 7-11 / FamilyMart stores by GPS coordinates.
|
59 |
+
• Customizable search radius (3 / 5 / 7 / 13 / 21 km).
|
60 |
+
• Display each store’s expiring-food items and remaining quantity.
|
61 |
+
|
62 |
+
Usage
|
63 |
+
1. Install Python 3.8+ and dependencies:
|
64 |
+
|
65 |
+
pip install gradio requests pandas geopy
|
66 |
+
|
67 |
+
|
68 |
+
2. Run:
|
69 |
+
|
70 |
+
python app.py
|
71 |
+
|
72 |
+
|
73 |
+
3. Open http://127.0.0.1:7860 in your browser to see the interface.
|
74 |
+
|
75 |
+
Disclaimer
|
76 |
+
• This is a personal practice/demo project, not affiliated with or endorsed by 7-11 or FamilyMart.
|
77 |
+
• If you see “certificate expired” or “token error,” it means MID_V is invalid and must be updated.
|
78 |
+
|
79 |
+
> - 你可以將上述內容直接複製到 `README.md` 放到 GitHub。
|
80 |
+
> - 若有更多需求,可自行在 README 中補充專案背景、技術細節、授權條款等。
|
81 |
+
|
82 |
+
---
|
83 |
+
|
app.py
CHANGED
@@ -7,7 +7,7 @@ from geopy.distance import geodesic
|
|
7 |
|
8 |
# =============== 7-11 所需常數 ===============
|
9 |
# 請確認此處的 MID_V 是否有效,若過期請更新
|
10 |
-
MID_V = "W0_DiF4DlgU5OeQoRswrRcaaNHMWOL7K3ra3385ocZcv-bBOWySZvoUtH6j-7pjiccl0C5h30uRUNbJXsABCKMqiekSb7tdiBNdVq8Ro5jgk6sgvhZla5iV0H3-8dZfASc7AhEm85679LIK3hxN7Sam6D0LAnYK9Lb0DZhn7xeTeksB4IsBx4Msr_VI"
|
11 |
USER_AGENT_7_11 = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
|
12 |
API_7_11_BASE = "https://lovefood.openpoint.com.tw/LoveFood/api"
|
13 |
|
@@ -15,28 +15,17 @@ API_7_11_BASE = "https://lovefood.openpoint.com.tw/LoveFood/api"
|
|
15 |
FAMILY_PROJECT_CODE = "202106302" # 若有需要請自行調整
|
16 |
API_FAMILY = "https://stamp.family.com.tw/api/maps/MapProductInfo"
|
17 |
|
18 |
-
# 3 公里範圍
|
19 |
-
MAX_DISTANCE = 3000
|
20 |
|
21 |
-
# -----------------------------------------------------------
|
22 |
-
# 7-11: 取得 AccessToken
|
23 |
-
# -----------------------------------------------------------
|
24 |
def get_7_11_token():
|
25 |
url = f"{API_7_11_BASE}/Auth/FrontendAuth/AccessToken?mid_v={MID_V}"
|
26 |
-
headers = {
|
27 |
-
"user-agent": USER_AGENT_7_11
|
28 |
-
}
|
29 |
resp = requests.post(url, headers=headers, data="")
|
30 |
resp.raise_for_status()
|
31 |
js = resp.json()
|
32 |
if not js.get("isSuccess"):
|
33 |
raise RuntimeError(f"取得 7-11 token 失敗: {js}")
|
34 |
-
|
35 |
-
return token
|
36 |
|
37 |
-
# -----------------------------------------------------------
|
38 |
-
# 7-11: 取得附近門市清單 (含剩餘即期品總數量)
|
39 |
-
# -----------------------------------------------------------
|
40 |
def get_7_11_nearby_stores(token, lat, lon):
|
41 |
url = f"{API_7_11_BASE}/Search/FrontendStoreItemStock/GetNearbyStoreList?token={token}"
|
42 |
headers = {
|
@@ -54,9 +43,6 @@ def get_7_11_nearby_stores(token, lat, lon):
|
|
54 |
raise RuntimeError(f"取得 7-11 附近門市失敗: {js}")
|
55 |
return js["element"].get("StoreStockItemList", [])
|
56 |
|
57 |
-
# -----------------------------------------------------------
|
58 |
-
# 7-11: 取得單一門市的即期品清單
|
59 |
-
# -----------------------------------------------------------
|
60 |
def get_7_11_store_detail(token, lat, lon, store_no):
|
61 |
url = f"{API_7_11_BASE}/Search/FrontendStoreItemStock/GetStoreDetail?token={token}"
|
62 |
headers = {
|
@@ -74,13 +60,8 @@ def get_7_11_store_detail(token, lat, lon, store_no):
|
|
74 |
raise RuntimeError(f"取得 7-11 門市({store_no})資料失敗: {js}")
|
75 |
return js["element"].get("StoreStockItem", {})
|
76 |
|
77 |
-
# -----------------------------------------------------------
|
78 |
-
# FamilyMart: 取得附近門市即期品清單 (單次呼叫可拿到所有商品細項)
|
79 |
-
# -----------------------------------------------------------
|
80 |
def get_family_nearby_stores(lat, lon):
|
81 |
-
headers = {
|
82 |
-
"Content-Type": "application/json;charset=utf-8",
|
83 |
-
}
|
84 |
body = {
|
85 |
"ProjectCode": FAMILY_PROJECT_CODE,
|
86 |
"latitude": lat,
|
@@ -89,19 +70,21 @@ def get_family_nearby_stores(lat, lon):
|
|
89 |
resp = requests.post(API_FAMILY, headers=headers, json=body)
|
90 |
resp.raise_for_status()
|
91 |
js = resp.json()
|
92 |
-
# 根據回傳範例,成功時 code 為 1
|
93 |
if js.get("code") != 1:
|
94 |
raise RuntimeError(f"取得全家門市資料失敗: {js}")
|
95 |
return js["data"]
|
96 |
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
print(f"🔍 收到查詢請求: address={address}, lat={lat}, lon={lon}")
|
102 |
if lat == 0 or lon == 0:
|
103 |
return [["❌ 請輸入地址或提供 GPS 座標", "", "", "", ""]]
|
104 |
|
|
|
|
|
|
|
105 |
result_rows = []
|
106 |
|
107 |
# ------------------ 7-11 ------------------
|
@@ -110,7 +93,7 @@ def find_nearest_store(address, lat, lon):
|
|
110 |
nearby_stores_711 = get_7_11_nearby_stores(token_711, lat, lon)
|
111 |
for store in nearby_stores_711:
|
112 |
dist_m = store.get("Distance", 999999)
|
113 |
-
if dist_m <=
|
114 |
store_no = store.get("StoreNo")
|
115 |
store_name = store.get("StoreName", "7-11 未提供店名")
|
116 |
remaining_qty = store.get("RemainingQty", 0)
|
@@ -121,13 +104,12 @@ def find_nearest_store(address, lat, lon):
|
|
121 |
for item in cat.get("ItemList", []):
|
122 |
item_name = item.get("ItemName", "")
|
123 |
item_qty = item.get("RemainingQty", 0)
|
124 |
-
# 在最後加一個 float 距離欄位以便排序
|
125 |
row = [
|
126 |
f"7-11 {store_name}",
|
127 |
f"{dist_m:.1f} m",
|
128 |
f"{cat_name} - {item_name}",
|
129 |
str(item_qty),
|
130 |
-
dist_m #
|
131 |
]
|
132 |
result_rows.append(row)
|
133 |
else:
|
@@ -136,7 +118,7 @@ def find_nearest_store(address, lat, lon):
|
|
136 |
f"{dist_m:.1f} m",
|
137 |
"即期品 0 項",
|
138 |
"0",
|
139 |
-
dist_m
|
140 |
]
|
141 |
result_rows.append(row)
|
142 |
except Exception as e:
|
@@ -147,7 +129,7 @@ def find_nearest_store(address, lat, lon):
|
|
147 |
nearby_stores_family = get_family_nearby_stores(lat, lon)
|
148 |
for store in nearby_stores_family:
|
149 |
dist_m = store.get("distance", 999999)
|
150 |
-
if dist_m <=
|
151 |
store_name = store.get("name", "全家 未提供店名")
|
152 |
info_list = store.get("info", [])
|
153 |
has_item = False
|
@@ -165,7 +147,7 @@ def find_nearest_store(address, lat, lon):
|
|
165 |
f"{dist_m:.1f} m",
|
166 |
f"{big_cat_name} - {subcat_name} - {product_name}",
|
167 |
str(qty),
|
168 |
-
dist_m
|
169 |
]
|
170 |
result_rows.append(row)
|
171 |
if not has_item:
|
@@ -174,82 +156,92 @@ def find_nearest_store(address, lat, lon):
|
|
174 |
f"{dist_m:.1f} m",
|
175 |
"即期品 0 項",
|
176 |
"0",
|
177 |
-
dist_m
|
178 |
]
|
179 |
result_rows.append(row)
|
180 |
except Exception as e:
|
181 |
print(f"❌ 取得全家 即期品時發生錯誤: {e}")
|
182 |
|
183 |
if not result_rows:
|
184 |
-
return [["❌
|
185 |
|
186 |
-
#
|
187 |
-
# result_rows 的結構是 [門市, 距離(字串), 商品, 數量, float_distance]
|
188 |
-
# 我們要依照最後一欄 float_distance 做由小到大排序
|
189 |
result_rows.sort(key=lambda x: x[4])
|
190 |
-
|
191 |
-
# 排序完之後,再把最後一欄刪掉 (不顯示給使用者)
|
192 |
for row in result_rows:
|
193 |
-
row.pop()
|
194 |
|
195 |
return result_rows
|
196 |
|
197 |
-
#
|
198 |
-
|
199 |
-
# -----------------------------------------------------------
|
200 |
import gradio as gr
|
201 |
|
202 |
-
|
203 |
-
gr.
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
243 |
resolve([0, 0]);
|
|
|
244 |
}
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
253 |
|
254 |
if __name__ == "__main__":
|
255 |
main()
|
|
|
7 |
|
8 |
# =============== 7-11 所需常數 ===============
|
9 |
# 請確認此處的 MID_V 是否有效,若過期請更新
|
10 |
+
MID_V = "W0_DiF4DlgU5OeQoRswrRcaaNHMWOL7K3ra3385ocZcv-bBOWySZvoUtH6j-7pjiccl0C5h30uRUNbJXsABCKMqiekSb7tdiBNdVq8Ro5jgk6sgvhZla5iV0H3-8dZfASc7AhEm85679LIK3hxN7Sam6D0LAnYK9Lb0DZhn7xeTeksB4IsBx4Msr_VI"
|
11 |
USER_AGENT_7_11 = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
|
12 |
API_7_11_BASE = "https://lovefood.openpoint.com.tw/LoveFood/api"
|
13 |
|
|
|
15 |
FAMILY_PROJECT_CODE = "202106302" # 若有需要請自行調整
|
16 |
API_FAMILY = "https://stamp.family.com.tw/api/maps/MapProductInfo"
|
17 |
|
|
|
|
|
18 |
|
|
|
|
|
|
|
19 |
def get_7_11_token():
|
20 |
url = f"{API_7_11_BASE}/Auth/FrontendAuth/AccessToken?mid_v={MID_V}"
|
21 |
+
headers = {"user-agent": USER_AGENT_7_11}
|
|
|
|
|
22 |
resp = requests.post(url, headers=headers, data="")
|
23 |
resp.raise_for_status()
|
24 |
js = resp.json()
|
25 |
if not js.get("isSuccess"):
|
26 |
raise RuntimeError(f"取得 7-11 token 失敗: {js}")
|
27 |
+
return js["element"]
|
|
|
28 |
|
|
|
|
|
|
|
29 |
def get_7_11_nearby_stores(token, lat, lon):
|
30 |
url = f"{API_7_11_BASE}/Search/FrontendStoreItemStock/GetNearbyStoreList?token={token}"
|
31 |
headers = {
|
|
|
43 |
raise RuntimeError(f"取得 7-11 附近門市失敗: {js}")
|
44 |
return js["element"].get("StoreStockItemList", [])
|
45 |
|
|
|
|
|
|
|
46 |
def get_7_11_store_detail(token, lat, lon, store_no):
|
47 |
url = f"{API_7_11_BASE}/Search/FrontendStoreItemStock/GetStoreDetail?token={token}"
|
48 |
headers = {
|
|
|
60 |
raise RuntimeError(f"取得 7-11 門市({store_no})資料失敗: {js}")
|
61 |
return js["element"].get("StoreStockItem", {})
|
62 |
|
|
|
|
|
|
|
63 |
def get_family_nearby_stores(lat, lon):
|
64 |
+
headers = {"Content-Type": "application/json;charset=utf-8"}
|
|
|
|
|
65 |
body = {
|
66 |
"ProjectCode": FAMILY_PROJECT_CODE,
|
67 |
"latitude": lat,
|
|
|
70 |
resp = requests.post(API_FAMILY, headers=headers, json=body)
|
71 |
resp.raise_for_status()
|
72 |
js = resp.json()
|
|
|
73 |
if js.get("code") != 1:
|
74 |
raise RuntimeError(f"取得全家門市資料失敗: {js}")
|
75 |
return js["data"]
|
76 |
|
77 |
+
def find_nearest_store(address, lat, lon, distance_km):
|
78 |
+
"""
|
79 |
+
distance_km: 從下拉選單取得的「公里」(字串),例如 '3' or '5' ...
|
80 |
+
"""
|
81 |
+
print(f"🔍 收到查詢請求: address={address}, lat={lat}, lon={lon}, distance_km={distance_km}")
|
82 |
if lat == 0 or lon == 0:
|
83 |
return [["❌ 請輸入地址或提供 GPS 座標", "", "", "", ""]]
|
84 |
|
85 |
+
# 將 km 轉成公尺
|
86 |
+
max_distance = float(distance_km) * 1000
|
87 |
+
|
88 |
result_rows = []
|
89 |
|
90 |
# ------------------ 7-11 ------------------
|
|
|
93 |
nearby_stores_711 = get_7_11_nearby_stores(token_711, lat, lon)
|
94 |
for store in nearby_stores_711:
|
95 |
dist_m = store.get("Distance", 999999)
|
96 |
+
if dist_m <= max_distance:
|
97 |
store_no = store.get("StoreNo")
|
98 |
store_name = store.get("StoreName", "7-11 未提供店名")
|
99 |
remaining_qty = store.get("RemainingQty", 0)
|
|
|
104 |
for item in cat.get("ItemList", []):
|
105 |
item_name = item.get("ItemName", "")
|
106 |
item_qty = item.get("RemainingQty", 0)
|
|
|
107 |
row = [
|
108 |
f"7-11 {store_name}",
|
109 |
f"{dist_m:.1f} m",
|
110 |
f"{cat_name} - {item_name}",
|
111 |
str(item_qty),
|
112 |
+
dist_m # 用來排序
|
113 |
]
|
114 |
result_rows.append(row)
|
115 |
else:
|
|
|
118 |
f"{dist_m:.1f} m",
|
119 |
"即期品 0 項",
|
120 |
"0",
|
121 |
+
dist_m
|
122 |
]
|
123 |
result_rows.append(row)
|
124 |
except Exception as e:
|
|
|
129 |
nearby_stores_family = get_family_nearby_stores(lat, lon)
|
130 |
for store in nearby_stores_family:
|
131 |
dist_m = store.get("distance", 999999)
|
132 |
+
if dist_m <= max_distance:
|
133 |
store_name = store.get("name", "全家 未提供店名")
|
134 |
info_list = store.get("info", [])
|
135 |
has_item = False
|
|
|
147 |
f"{dist_m:.1f} m",
|
148 |
f"{big_cat_name} - {subcat_name} - {product_name}",
|
149 |
str(qty),
|
150 |
+
dist_m
|
151 |
]
|
152 |
result_rows.append(row)
|
153 |
if not has_item:
|
|
|
156 |
f"{dist_m:.1f} m",
|
157 |
"即期品 0 項",
|
158 |
"0",
|
159 |
+
dist_m
|
160 |
]
|
161 |
result_rows.append(row)
|
162 |
except Exception as e:
|
163 |
print(f"❌ 取得全家 即期品時發生錯誤: {e}")
|
164 |
|
165 |
if not result_rows:
|
166 |
+
return [["❌ 附近沒有即期食品 (在所選公里範圍內)", "", "", "", ""]]
|
167 |
|
168 |
+
# 排序:依照最後一欄 (float 距離) 做由小到大排序
|
|
|
|
|
169 |
result_rows.sort(key=lambda x: x[4])
|
170 |
+
# 移除最後一欄 (不顯示給前端)
|
|
|
171 |
for row in result_rows:
|
172 |
+
row.pop()
|
173 |
|
174 |
return result_rows
|
175 |
|
176 |
+
# ========== Gradio 介面 ==========
|
177 |
+
|
|
|
178 |
import gradio as gr
|
179 |
|
180 |
+
def main():
|
181 |
+
with gr.Blocks() as demo:
|
182 |
+
gr.Markdown("## 台灣7-11 和 family全家���利商店「即期食品」 乞丐時光搜尋")
|
183 |
+
gr.Markdown("""
|
184 |
+
1. 按下「使用目前位置」或自行輸入緯度/經度
|
185 |
+
2. 選擇「搜尋範圍 (公里)」
|
186 |
+
3. 點選「搜尋」查詢 7-11 / 全家family 的即期品
|
187 |
+
4. 意見反應 telegram @a7a8a9abc
|
188 |
+
""")
|
189 |
+
|
190 |
+
address = gr.Textbox(label="輸入地址(可留空)")
|
191 |
+
lat = gr.Number(label="GPS 緯度", value=0, elem_id="lat")
|
192 |
+
lon = gr.Number(label="GPS 經度", value=0, elem_id="lon")
|
193 |
+
|
194 |
+
# 下拉選單,提供可選距離 (公里)
|
195 |
+
distance_dropdown = gr.Dropdown(
|
196 |
+
label="搜尋範圍 (公里)",
|
197 |
+
choices=["3", "5", "7", "13", "21"],
|
198 |
+
value="3", # 預設 3 公里
|
199 |
+
interactive=True
|
200 |
+
)
|
201 |
+
|
202 |
+
with gr.Row():
|
203 |
+
gps_button = gr.Button("📍 ❶ 使用目前位置-先按這個 並等待3秒 ", elem_id="gps-btn")
|
204 |
+
search_button = gr.Button("🔍 ❷ 搜尋 ")
|
205 |
+
|
206 |
+
output_table = gr.Dataframe(
|
207 |
+
headers=["門市", "距離 (m)", "商品/即期食品", "數量"],
|
208 |
+
interactive=False
|
209 |
+
)
|
210 |
+
|
211 |
+
# 將 distance_dropdown 傳入函式
|
212 |
+
search_button.click(
|
213 |
+
fn=find_nearest_store,
|
214 |
+
inputs=[address, lat, lon, distance_dropdown],
|
215 |
+
outputs=output_table
|
216 |
+
)
|
217 |
+
|
218 |
+
gps_button.click(
|
219 |
+
None,
|
220 |
+
None,
|
221 |
+
[lat, lon],
|
222 |
+
js="""
|
223 |
+
() => {
|
224 |
+
return new Promise((resolve) => {
|
225 |
+
if (!navigator.geolocation) {
|
226 |
+
alert("您的瀏覽器不支援地理位置功能");
|
227 |
resolve([0, 0]);
|
228 |
+
return;
|
229 |
}
|
230 |
+
navigator.geolocation.getCurrentPosition(
|
231 |
+
(position) => {
|
232 |
+
resolve([position.coords.latitude, position.coords.longitude]);
|
233 |
+
},
|
234 |
+
(error) => {
|
235 |
+
alert("無法取得位置:" + error.message);
|
236 |
+
resolve([0, 0]);
|
237 |
+
}
|
238 |
+
);
|
239 |
+
});
|
240 |
+
}
|
241 |
+
"""
|
242 |
+
)
|
243 |
+
|
244 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, debug=True)
|
245 |
|
246 |
if __name__ == "__main__":
|
247 |
main()
|
app3.py
ADDED
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import requests
|
3 |
+
import json
|
4 |
+
import os
|
5 |
+
import pandas as pd
|
6 |
+
from geopy.distance import geodesic
|
7 |
+
|
8 |
+
# =============== 7-11 所需常數 ===============
|
9 |
+
# 請確認此處的 MID_V 是否有效,若過期請更新
|
10 |
+
MID_V = "W0_DiF4DlgU5OeQoRswrRcaaNHMWOL7K3ra3385ocZcv-bBOWySZvoUtH6j-7pjiccl0C5h30uRUNbJXsABCKMqiekSb7tdiBNdVq8Ro5jgk6sgvhZla5iV0H3-8dZfASc7AhEm85679LIK3hxN7Sam6D0LAnYK9Lb0DZhn7xeTeksB4IsBx4Msr_VI" # 請填入有效的 mid_v
|
11 |
+
USER_AGENT_7_11 = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
|
12 |
+
API_7_11_BASE = "https://lovefood.openpoint.com.tw/LoveFood/api"
|
13 |
+
|
14 |
+
# =============== FamilyMart 所需常數 ===============
|
15 |
+
FAMILY_PROJECT_CODE = "202106302" # 若有需要請自行調整
|
16 |
+
API_FAMILY = "https://stamp.family.com.tw/api/maps/MapProductInfo"
|
17 |
+
|
18 |
+
# 3 公里範圍
|
19 |
+
MAX_DISTANCE = 3000
|
20 |
+
|
21 |
+
# -----------------------------------------------------------
|
22 |
+
# 7-11: 取得 AccessToken
|
23 |
+
# -----------------------------------------------------------
|
24 |
+
def get_7_11_token():
|
25 |
+
url = f"{API_7_11_BASE}/Auth/FrontendAuth/AccessToken?mid_v={MID_V}"
|
26 |
+
headers = {
|
27 |
+
"user-agent": USER_AGENT_7_11
|
28 |
+
}
|
29 |
+
resp = requests.post(url, headers=headers, data="")
|
30 |
+
resp.raise_for_status()
|
31 |
+
js = resp.json()
|
32 |
+
if not js.get("isSuccess"):
|
33 |
+
raise RuntimeError(f"取得 7-11 token 失敗: {js}")
|
34 |
+
token = js["element"]
|
35 |
+
return token
|
36 |
+
|
37 |
+
# -----------------------------------------------------------
|
38 |
+
# 7-11: 取得附近門市清單 (含剩餘即期品總數量)
|
39 |
+
# -----------------------------------------------------------
|
40 |
+
def get_7_11_nearby_stores(token, lat, lon):
|
41 |
+
url = f"{API_7_11_BASE}/Search/FrontendStoreItemStock/GetNearbyStoreList?token={token}"
|
42 |
+
headers = {
|
43 |
+
"user-agent": USER_AGENT_7_11,
|
44 |
+
"content-type": "application/json",
|
45 |
+
}
|
46 |
+
body = {
|
47 |
+
"CurrentLocation": {"Latitude": lat, "Longitude": lon},
|
48 |
+
"SearchLocation": {"Latitude": lat, "Longitude": lon}
|
49 |
+
}
|
50 |
+
resp = requests.post(url, headers=headers, json=body)
|
51 |
+
resp.raise_for_status()
|
52 |
+
js = resp.json()
|
53 |
+
if not js.get("isSuccess"):
|
54 |
+
raise RuntimeError(f"取得 7-11 附近門市失敗: {js}")
|
55 |
+
return js["element"].get("StoreStockItemList", [])
|
56 |
+
|
57 |
+
# -----------------------------------------------------------
|
58 |
+
# 7-11: 取得單一門市的即期品清單
|
59 |
+
# -----------------------------------------------------------
|
60 |
+
def get_7_11_store_detail(token, lat, lon, store_no):
|
61 |
+
url = f"{API_7_11_BASE}/Search/FrontendStoreItemStock/GetStoreDetail?token={token}"
|
62 |
+
headers = {
|
63 |
+
"user-agent": USER_AGENT_7_11,
|
64 |
+
"content-type": "application/json",
|
65 |
+
}
|
66 |
+
body = {
|
67 |
+
"CurrentLocation": {"Latitude": lat, "Longitude": lon},
|
68 |
+
"StoreNo": store_no
|
69 |
+
}
|
70 |
+
resp = requests.post(url, headers=headers, json=body)
|
71 |
+
resp.raise_for_status()
|
72 |
+
js = resp.json()
|
73 |
+
if not js.get("isSuccess"):
|
74 |
+
raise RuntimeError(f"取得 7-11 門市({store_no})資料失敗: {js}")
|
75 |
+
return js["element"].get("StoreStockItem", {})
|
76 |
+
|
77 |
+
# -----------------------------------------------------------
|
78 |
+
# FamilyMart: 取得附近門市即期品清單 (單次呼叫可拿到所有商品細項)
|
79 |
+
# -----------------------------------------------------------
|
80 |
+
def get_family_nearby_stores(lat, lon):
|
81 |
+
headers = {
|
82 |
+
"Content-Type": "application/json;charset=utf-8",
|
83 |
+
}
|
84 |
+
body = {
|
85 |
+
"ProjectCode": FAMILY_PROJECT_CODE,
|
86 |
+
"latitude": lat,
|
87 |
+
"longitude": lon
|
88 |
+
}
|
89 |
+
resp = requests.post(API_FAMILY, headers=headers, json=body)
|
90 |
+
resp.raise_for_status()
|
91 |
+
js = resp.json()
|
92 |
+
# 根據回傳範例,成功時 code 為 1
|
93 |
+
if js.get("code") != 1:
|
94 |
+
raise RuntimeError(f"取得全家門市資料失敗: {js}")
|
95 |
+
return js["data"]
|
96 |
+
|
97 |
+
# -----------------------------------------------------------
|
98 |
+
# Gradio 查詢邏輯
|
99 |
+
# -----------------------------------------------------------
|
100 |
+
def find_nearest_store(address, lat, lon):
|
101 |
+
print(f"🔍 收到查詢請求: address={address}, lat={lat}, lon={lon}")
|
102 |
+
if lat == 0 or lon == 0:
|
103 |
+
return [["❌ 請輸入地址或提供 GPS 座標", "", "", "", ""]]
|
104 |
+
|
105 |
+
result_rows = []
|
106 |
+
|
107 |
+
# ------------------ 7-11 ------------------
|
108 |
+
try:
|
109 |
+
token_711 = get_7_11_token()
|
110 |
+
nearby_stores_711 = get_7_11_nearby_stores(token_711, lat, lon)
|
111 |
+
for store in nearby_stores_711:
|
112 |
+
dist_m = store.get("Distance", 999999)
|
113 |
+
if dist_m <= MAX_DISTANCE:
|
114 |
+
store_no = store.get("StoreNo")
|
115 |
+
store_name = store.get("StoreName", "7-11 未提供店名")
|
116 |
+
remaining_qty = store.get("RemainingQty", 0)
|
117 |
+
if remaining_qty > 0:
|
118 |
+
detail = get_7_11_store_detail(token_711, lat, lon, store_no)
|
119 |
+
for cat in detail.get("CategoryStockItems", []):
|
120 |
+
cat_name = cat.get("Name", "")
|
121 |
+
for item in cat.get("ItemList", []):
|
122 |
+
item_name = item.get("ItemName", "")
|
123 |
+
item_qty = item.get("RemainingQty", 0)
|
124 |
+
# 在最後加一個 float 距離欄位以便排序
|
125 |
+
row = [
|
126 |
+
f"7-11 {store_name}",
|
127 |
+
f"{dist_m:.1f} m",
|
128 |
+
f"{cat_name} - {item_name}",
|
129 |
+
str(item_qty),
|
130 |
+
dist_m # 供排序用
|
131 |
+
]
|
132 |
+
result_rows.append(row)
|
133 |
+
else:
|
134 |
+
row = [
|
135 |
+
f"7-11 {store_name}",
|
136 |
+
f"{dist_m:.1f} m",
|
137 |
+
"即期品 0 項",
|
138 |
+
"0",
|
139 |
+
dist_m # 供排序用
|
140 |
+
]
|
141 |
+
result_rows.append(row)
|
142 |
+
except Exception as e:
|
143 |
+
print(f"❌ 取得 7-11 即期品時發生錯誤: {e}")
|
144 |
+
|
145 |
+
# ------------------ FamilyMart ------------------
|
146 |
+
try:
|
147 |
+
nearby_stores_family = get_family_nearby_stores(lat, lon)
|
148 |
+
for store in nearby_stores_family:
|
149 |
+
dist_m = store.get("distance", 999999)
|
150 |
+
if dist_m <= MAX_DISTANCE:
|
151 |
+
store_name = store.get("name", "全家 未提供店名")
|
152 |
+
info_list = store.get("info", [])
|
153 |
+
has_item = False
|
154 |
+
for big_cat in info_list:
|
155 |
+
big_cat_name = big_cat.get("name", "")
|
156 |
+
for subcat in big_cat.get("categories", []):
|
157 |
+
subcat_name = subcat.get("name", "")
|
158 |
+
for product in subcat.get("products", []):
|
159 |
+
product_name = product.get("name", "")
|
160 |
+
qty = product.get("qty", 0)
|
161 |
+
if qty > 0:
|
162 |
+
has_item = True
|
163 |
+
row = [
|
164 |
+
f"全家 {store_name}",
|
165 |
+
f"{dist_m:.1f} m",
|
166 |
+
f"{big_cat_name} - {subcat_name} - {product_name}",
|
167 |
+
str(qty),
|
168 |
+
dist_m # 供排序用
|
169 |
+
]
|
170 |
+
result_rows.append(row)
|
171 |
+
if not has_item:
|
172 |
+
row = [
|
173 |
+
f"全家 {store_name}",
|
174 |
+
f"{dist_m:.1f} m",
|
175 |
+
"即期品 0 項",
|
176 |
+
"0",
|
177 |
+
dist_m # 供排序用
|
178 |
+
]
|
179 |
+
result_rows.append(row)
|
180 |
+
except Exception as e:
|
181 |
+
print(f"❌ 取得全家 即期品時發生錯誤: {e}")
|
182 |
+
|
183 |
+
if not result_rows:
|
184 |
+
return [["❌ 附近 3 公里內沒有即期食品", "", "", "", ""]]
|
185 |
+
|
186 |
+
# ============= 在這裡進行排序 =============
|
187 |
+
# result_rows 的結構是 [門市, 距離(字串), 商品, 數量, float_distance]
|
188 |
+
# 我們要依照最後一欄 float_distance 做由小到大排序
|
189 |
+
result_rows.sort(key=lambda x: x[4])
|
190 |
+
|
191 |
+
# 排序完之後,再把最後一欄刪掉 (不顯示給使用者)
|
192 |
+
for row in result_rows:
|
193 |
+
row.pop() # 移除 index=4 (float_distance)
|
194 |
+
|
195 |
+
return result_rows
|
196 |
+
|
197 |
+
# -----------------------------------------------------------
|
198 |
+
# Gradio 介面
|
199 |
+
# -----------------------------------------------------------
|
200 |
+
import gradio as gr
|
201 |
+
|
202 |
+
with gr.Blocks() as demo:
|
203 |
+
gr.Markdown("## 便利商店「即期食品」搜尋示範")
|
204 |
+
gr.Markdown("""
|
205 |
+
1. 按下「使用目前位置」或自行輸入緯度/經度
|
206 |
+
2. 點選「搜尋」查詢 3 公里內 7-11 / 全家的即期品
|
207 |
+
3. 若要執行,需要有效的 mid_v (7-11 愛食記憶官網)
|
208 |
+
4. 在 Logs 查看詳細錯誤或除錯資訊
|
209 |
+
""")
|
210 |
+
address = gr.Textbox(label="輸入地址(可留空)")
|
211 |
+
lat = gr.Number(label="GPS 緯度", value=0, elem_id="lat")
|
212 |
+
lon = gr.Number(label="GPS 經度", value=0, elem_id="lon")
|
213 |
+
|
214 |
+
with gr.Row():
|
215 |
+
gps_button = gr.Button("📍 ❶ 使用目前位置-先按這個 並等待3秒 ", elem_id="gps-btn")
|
216 |
+
search_button = gr.Button("🔍 ❷ 搜尋 ")
|
217 |
+
|
218 |
+
output_table = gr.Dataframe(
|
219 |
+
headers=["門市", "距離 (m)", "商品/即期食品", "數量"],
|
220 |
+
interactive=False
|
221 |
+
)
|
222 |
+
|
223 |
+
search_button.click(fn=find_nearest_store, inputs=[address, lat, lon], outputs=output_table)
|
224 |
+
|
225 |
+
gps_button.click(
|
226 |
+
None,
|
227 |
+
None,
|
228 |
+
[lat, lon],
|
229 |
+
js="""
|
230 |
+
() => {
|
231 |
+
return new Promise((resolve) => {
|
232 |
+
if (!navigator.geolocation) {
|
233 |
+
alert("您的瀏覽器不支援地理位置功能");
|
234 |
+
resolve([0, 0]);
|
235 |
+
return;
|
236 |
+
}
|
237 |
+
navigator.geolocation.getCurrentPosition(
|
238 |
+
(position) => {
|
239 |
+
resolve([position.coords.latitude, position.coords.longitude]);
|
240 |
+
},
|
241 |
+
(error) => {
|
242 |
+
alert("無法取得位置:" + error.message);
|
243 |
+
resolve([0, 0]);
|
244 |
+
}
|
245 |
+
);
|
246 |
+
});
|
247 |
+
}
|
248 |
+
"""
|
249 |
+
)
|
250 |
+
|
251 |
+
def main():
|
252 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, debug=True)
|
253 |
+
|
254 |
+
if __name__ == "__main__":
|
255 |
+
main()
|