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 推播訊息 : 




完成收工. 

沒有留言 :