2024年1月2日 星期二

Python 學習筆記 : 取得本機外網 IP 傳送 Line 訊息的方法

之前我曾在鄉下老家的 Pi 3 上寫了一個 Python 程式, 它會每小時傳一封 email 給我, 通知目前 Pi 3 的外網 IP 是多少, 因為我在 Pi 3 所連線的信舟無線基地台上打了一個洞 (其實就是開啟 port forwarding 與 virtual server, 打開 80 與 22 埠給綁定固定區網 IP 的 Pi 3), 只要知道光世代所分配到的外網浮動 IP, 我就可以用 PuTTY 從遠端存取 Pi 3, 版本從 reportip.py, reportip2.py 到目前的 report3.py, 參考 : 


最近從圖書館借到下面這本書 :

# 一本精通 Python 範例應用大全 (深智 2023, 張宗彥)


Source : 博客來


作者 OXXO 張宗彥是 Webduino 設計總監, 我之前買過一片 Webduino 來玩, 非常適合教育用途. 此書介紹了非常多的 Python 實用範例, 包含影像, 影片, 聲音, 爬蟲, 網頁服務等等應有盡有, 非常適合 Python 學習者進階練功. 

我在這本書的第 12 章末尾讀到更簡潔的做法, 今天就來實測一下唄. 


一. 取得內網 IP :   

此任務需要用到 Python 內建的低階網路存取模組 socket, 我之前在玩 MicroPython 時常用 (之前叫 usocket, 但新版 MicroPython 已去掉 u 了), 但從未在 Cython 環境下使用過 socket, 其實它們的用法是一樣的, 參考 :


因為是內建模組, 故直接 import 即可使用 : 

import socket  

透過呼叫 socket 模組的 socket() 函式可以建立一個 socket (網路插槽) 物件, 此物件為主機的應用層與傳輸層的介面, 應用程式利用此 Socket 介面就能像操作檔案 IO 那樣 (即讀取與寫入串流) 透過 IP 網路與遠端主機上的某個程序進行通訊. 

Socket 依據連線方式的不同提供兩種網路插槽 : 
  • TCP socket : 需要三向握手確認的連接式通道, 較費時但保證送達 (雙掛號信).
  • UDP socket : 毋須確認的非連接式通道, 省時但不保證送達 (平信).
以郵局送信方式來比喻, TCP 好比是必須臨櫃辦理的雙掛號信 (須建立郵件追蹤條碼與回執故較費時); 而 UDP 則像是平信直接丟郵筒即可 (沒有追蹤條碼). 

網路通訊採用 Server-Client 模式, 故 Socket 也分成 server socket 與 client socket, 作為伺服器的主機在建立 server socket 後必須監聽 (listen) 特定網路位址 (IP + Port) 以等候客戶端的連線; 而作為客戶端的主機在建立 Client socket 後則須呼叫 connect() 方法連線遠端主機進行通訊. 

Socket 作為一種通訊界面, 當本機利用 socket 與遠端主機上的某個程序連線時是利用 IP 與 Port 的組合來辨認通訊對象的, 在此層面上也可以說 IP + Port 就代表了一個 socket 物件. 

建立 Socket 物件的語法如下 : 

socket.socket([family[, type[, proto]]])    

其中 family 參數為位址家族, 預設是 socket.AF_INET (=2, 即 IPv4), 另一個可用值為 socket.AF_INET6 (=23, 即 IPv6). type 參數用來指定 socket 的連線類型, 預設 socket.SOCK_STREAM (=1, 即 TCP 連線), 另一個可用值為 socket.SOCK_DGRAM (=2, 即 UDP 連線). proto 參數為協定編號, 預設是 socket.IPPROTO_IP (=0), 可用 socket.IPPROTO_UDP (=6) 或 socket.IPPROTO_TCP (=17), 但通常不須填使用預設值 0 即可. 

若要取得本機的 IP, 只要建立一個 Client socket 物件, 然後呼叫其 connect() 方法連線一個遠端主機, 例如 Google Public DNS 服務主機 (網址為 8.8.8.8 或 8.8.4.4), 再呼叫其 getsockname() 方法即可, 它會傳回一個包含本機內網網址的 tuple. 但我實測發現書中使用的谷歌的 DNS 服務主機目前無法連線 (不管是 8.8.8.8 或 8.8.4.4 皆無法連線), 必須連線 : 

>>> import socket   
>>> cs=socket.socket()             # 建立 client socket (預設為 TCP socket)
>>> type(cs)     
<class 'socket.socket'>               # 這是一個 socket 物件
>>> cs.connect(('8.8.8.8', 80))     
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TimeoutError: [WinError 10060] 連線嘗試失敗,因為連線對象有一段時間並未正確回應,或是連線建立失敗,因為連線的主機無法回應。
>>> cs.connect(('8.8.4.4', 80))    
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TimeoutError: [WinError 10060] 連線嘗試失敗,因為連線對象有一段時間並未正確回應,或是連線建立失敗,因為連線的主機無法回應。

解決辦法是不要連線谷歌 DNS 主機, 改為連線谷歌首頁網址 "google.com" 即可. 先關閉上面建立的 socket 物建, 重新建立一個新的 socket : 

>>> cs.close()                    # 關閉 socket 物件
>>> cs=socket.socket()     # 重新建立 socket 物件

呼叫 connect() 直接連線 "google.com", 它會傳回一個 tuple,  其中第一個元素即本機內網 IP : 

>>> cs.connect(('google.tw', 80))    
>>> cs.getsockname()    
('192.168.43.64', 57095)

可見此程式是使用 192.168.43.64 與 57095 埠與 Google 首頁連線, 連線成功後 Google 主機就會把回應內容傳回來這個埠所綁定的程式, 也就是這個 Python 命令列執行環境 (我使用 Thonny). 這樣就取得本機目前從區網的路由器取得的內網 IP 了, 完整程式如下 : 

# get_private_ip.py
import socket

def private_ip(host_name='google.org'):
    remote_ip=socket.gethostbyname(host_name)
    cs=socket.socket()
    cs.connect((remote_ip, 80))
    return cs.getsockname()[0]

if __name__ == '__main__':
    print(private_ip())

執行結果如下 :

>>> from get_private_ip import private_ip     
>>> private_ip()    
'192.168.43.64'

此 private_ip.py 模組使用隱藏變數 __name__ 來判斷模組是被其他程式 import 後執行還是在命令列用 python 指令直接執行, 如果是被 import, 則 __name__ 的值為模組名稱 'private_ip', 就不會直接呼叫 local_ip(). 若是在命令列執行 get_private_ip.py), 則 __name__ 的值為 '__main__' 就會執行 print(private_ip()), 此乃 Python 模組的標準用法, 參考 :


以上我們透過連線谷歌首頁反查我們本機的區網 IP, 那與我們通訊的遠端 Google 主機的 IP 要怎麼查呢? 這可用 socket 模組的 gethostbyname() 函式來查詢, 雖然這與本次測試無直接關係, 但也順帶測試一下 : 

>>> socket.gethostbyname("google.com")   
'172.217.160.78'      # 谷歌首頁主機 IP


二. 取得公網 IP  :    

取得公網 (public) 需要拜訪特定網站, 它們會回應我方的公網 IP, 我之前的回報 IP 程式是使用台灣的網站 https://myip.com.tw/  來取得本機的公網 IP, 但其回應內容較多, 須利用內建模組 re 以正規式擷取其中的 IP 訊息 : 



只要利用第三方的爬蟲模組 requests 對此網站提出 GET 請求即可取得回應網頁之 Response 物件, 然後用內建的 re 模組以正規式擷取其中的 IP 網址. 如果尚未安裝 requests 先用 pip install 指令安裝 : 

pip install requests  

然後呼叫此模組的 get() 函式向 https://myip.com.tw 提出 GET 請求, 它會傳回一個 Response 物件, 回應的網頁內容即放在其 text 屬性中 :  

例如 : 

>>> import re    
>>> pattern=r'\b(?:\d{1,3}\.){3}\d{1,3}\b'                  # 擷取 IP 的正規式
>>> res=requests.get('https://myip.com.tw/').text    # 傳回 Response 物件
>>> type(res)      
<class 'requests.models.Response'>       
>>> res  
' \n\n\n\n<html>\n<head>\n<title>我上網的 IP 是? - 簡單又快速來取得目前使用的 IP 位置 - MyIP.com.tw</title>\n<meta name="keywords" content="whatismyip,查IP,我的ip,ip查詢" />\n<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>\n<style type="text/css">\n<!--\n.style2 {color: #003399}\na:link {\n\tfont-size: 16px;\n\ttext-decoration: none;\n}\n.style1 {\n\tcolor: #808000;\n}\n-->\n</style>\n</head>\n<body>\n<br><br><br><br><br><br><br><br>\n<h1 align="center" class="style2" style="font-size:300%;">我的 IP 是 <font color=green>42.75.39.178</font></h1>\n<br><br>\n<p align="center" style="font-size:150%;">簡單又快速來取得目前使用的 IP 位置 - MyIP.com.tw</p>\n\n\n<script>\n  (function(i,s,o,g,r,a,m){i[\'GoogleAnalyticsObject\']=r;i[r]=i[r]||function(){\n  (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),\n  m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)\n  })(window,document,\'script\',\'//www.google-analytics.com/analytics.js\',\'ga\');\n\n  ga(\'create\', \'UA-299688-4\', \'myip.com.tw\');\n  ga(\'send\', \'pageview\');\n\n</script> \n<script>(function(){var js = "window[\'__CF$cv$params\']={r:\'83eef1f0f9ec08f0\',t:\'MTcwNDE1NTk2OC41NjEwMDA=\'};_cpo=document.createElement(\'script\');_cpo.nonce=\'\',_cpo.src=\'/cdn-cgi/challenge-platform/scripts/jsd/main.js\',document.getElementsByTagName(\'head\')[0].appendChild(_cpo);";var _0xh = document.createElement(\'iframe\');_0xh.height = 1;_0xh.width = 1;_0xh.style.position = \'absolute\';_0xh.style.top = 0;_0xh.style.left = 0;_0xh.style.border = \'none\';_0xh.style.visibility = \'hidden\';document.body.appendChild(_0xh);function handler() {var _0xi = _0xh.contentDocument || _0xh.contentWindow.document;if (_0xi) {var _0xj = _0xi.createElement(\'script\');_0xj.innerHTML = js;_0xi.getElementsByTagName(\'head\')[0].appendChild(_0xj);}}if (document.readyState !== \'loading\') {handler();} else if (window.addEventListener) {document.addEventListener(\'DOMContentLoaded\', handler);} else {var prev = document.onreadystatechange || function () {};document.onreadystatechange = function (e) {prev(e);if (document.readyState !== \'loading\') {document.onreadystatechange = prev;handler();}};}})();</script></body>\n</html>\n'

只要用 re.findall() 即可擷取出網頁內容中所有符合正規式的 IP, 它們會被放在一個串列中傳回 :

>>> re.findall(pattern, res)    
['42.75.39.178']   

因為只有一個, 所以用索引 0 即可取出此 IP :

>>> re.findall(pattern, res)[0]     
'42.75.39.178'

但這本書中介紹的 https://api.ipify.org 則較單純, 它只回應 IP 回來 : 




所以用這個 API 處理起來更簡單, 不必用正規式從回應網頁內容中擷取 IP, 例如 :  

>>> import requests        
>>> res=requests.get('https://api.ipify.org')   # 傳回 Response 物件
>>> res.text   
'42.75.39.178'    

只要三行指令就搞定, 完整程式如下 : 

# get_public_ip.py
import requests

def public_ip():
    hostname='https://api.ipify.org'
    return requests.get(hostname).text

if __name__ == '__main__':
    print(public_ip())

執行結果如下 :

>>> from get_public_ip import public_ip   
>>> public_ip()    
'42.75.39.178'


三. 用 Line Notify 傳送 IP 資訊  :      

透過免費的 Line Notify 服務可將上面取得的內網與外網 IP 傳送到綁定的 Line 帳戶或群組, Line Notify 可傳送文字, 貼圖, 與圖片等, 具體作法參考 :


首先參考第一篇做法去 Line Notify 網站申請一個新的權杖 :





先按複製鈕將權杖貼到記事本儲存後關閉鈕, 然後整合上面取得內外網與發送 Line Notify 的函式為如下的 reportip4.py 程式 : 

# reportip4.py
import socket
import requests

def line_notify(msg, token):
    url="https://notify-api.line.me/api/notify"
    headers={"Authorization": "Bearer " + token,
             "Content-Type": "application/x-www-form-urlencoded"
             }
    payload={"message": msg}
    r=requests.post(url, headers=headers, params=payload)
    return r.status_code

def private_ip(host_name='google.org'):
    remote_ip=socket.gethostbyname(host_name)
    cs=socket.socket()
    cs.connect((remote_ip, 80))
    return cs.getsockname()[0]

def public_ip():
    hostname='https://api.ipify.org'
    return requests.get(hostname).text

if __name__ == '__main__':
    msg=f'\n外網: {public_ip()}\n內網: {private_ip()}'
    token='iDXLFGiUkpuGqji1MzcsF9gimSNT6aMB6ltDVoZTony'  # 範例權杖
    code=line_notify(msg, token)
    if code==200:
        print('Line 訊息發送成功!')
    else:
        print('Line 訊息發送失敗!')
    
執行結果 OK :




四. 佈署到樹莓派 Pi 3  :      

上面都是在 Win 11 上進行測試, 完成後用 VNC Viewer 遠端連線到鄉下家的樹莓派 Pi 3 (利用目前運行中的 reportip3.py 透過 Gmail 郵件主機傳送的外網 IP), 將上面的程式貼到 Thonny 或 Nano 編輯器中存檔為 reportip4.py, 直接在 Thonny 執行 OK :





但上面是透過 Thonny 執行的, 如果要在終端機中用 python3 指令執行必須將此 reportip4.py 用 chmod 指令加上可執行 (x) 權限 :

pi@raspberrypi:~ $ ls reportip4.py -ls    
4 -rw-r--r-- 1 pi pi 926  1月  2 20:03 reportip4.py

可見目前 reportip4.py 目前權限為唯讀, 使用 chmod +x 指令改為可執行 : 

pi@raspberrypi:~ $ sudo chmod +x /home/pi/reportip4.py   
pi@raspberrypi:~ $ ls reportip4.py -ls      
4 -rwxr-xr-x 1 pi pi 926  1月  2 20:03 reportip4.py   

用 Python3 指令即可順利執行 :

pi@raspberrypi:~ $ python3 reportip4.py   
Line 訊息發送成功!

最後是用 sudo crontab -e 指令修改 Cron table, 設定每小時整點時執行一次 reportip4.py : 

pi@raspberrypi:~ $ sudo crontab -e   
crontab: installing new crontab




用 sudo crontab -l 顯示 Crontab 內容 :

pi@raspberrypi:~ $ sudo crontab -l      
0 * * * * sudo /usr/local/bin/checkwifi.sh
30 * * * * /usr/bin/python3 /home/pi/reportip3.py
0 * * * * /usr/bin/python3 /home/pi/reportip4.py

第一個 0 表示 0 分, 意即在整點 0 分時執行一次 reportip4.py.

關於 Crontab 的設定方法參考 :


經過 3 小時觀察, 確實都在整點時都收到 Line 推播訊息 : 




完成收工. 


2024-06-02 補充 :

上面使用 1 對 1 推播將訊息傳送至個人聊天室的作法會讓使用此權杖的所有推播訊息都擠在一個聊天室, 比較好的做法是建立一個群組發行自己的權杖, 邀請 Line Notify 成為此群組的好友, 然後將訊息推播至此群組, 詳細作法參考下面這篇最底下的補充 : 


首先是在手機 Line 建立一個群組, 然後邀請 Line Notify 加入此群組為好友 : 








邀請 Line Notify 為好友 :






然後登入 Line Notify 網站 :


按右上角帳號名稱會出現下拉式選單, 點選 "個人頁面" 後將網頁啦到底點選 "發行權杖" 鈕 :






輸入權杖名稱, 點選欲傳送之群組後按 "發行" 鈕 : 




先按 "複製" 鈕將權杖保存至檔案後才可按 "關閉" 鈕 :




修改程式將權杖替換為群組權杖即可 :

pi@raspberrypi:~ $ nano reportip4.py   
pi@raspberrypi:~ $ python3 reportip4.py    
Line 訊息發送成功!




完工啦! 

沒有留言 :