2024年9月8日 星期日

Dart 開發筆記 - 製作 Big5 to UTF8 工具以及發布到 pub.dev


最近想整理以前 C++ 寫過的東西來尋找熱情,先試試把很片段的小東西轉出來使用,這次純靠 claude.ai 做了滿多事,包括 README.md, CHANGELOG.md, example 和 test 都是他寫的 XD 剛好把最懶散的部分都給搞定了,因此 example 和 test 裡頭都會有簡體中文,甚至 README 一開始也給簡中版,只好請他給予英文版即可

整個過程大概不用兩個小時,非常舒服,花比較多的時間是請他把工具規劃成 library 的過程,claude ai 提了不少建議,但我也打槍他,最終有了現況的產出,這時真的感受到 AI 輔助的有趣的地方,還會提供一些觀點,像是 Flutter 他有多種平台,有些平台不適合 io 操作,這時會收到一些 AI 給予的建議,調整一些實作方向。


其中 pub.dev 發布過程還不太熟悉,參考官網 dart.dev.org.tw/tools/pub/publishing 先用指令:

```
big5_utf8_converter_dart % dart pub publish --dry-run                 
Resolving dependencies... 
Downloading packages... 
  _fe_analyzer_shared 73.0.0 (74.0.0 available)
  analyzer 6.8.0 (6.9.0 available)
  macros 0.1.2-main.4 (0.1.3-main.0 available)
Got dependencies!
3 packages have newer versions incompatible with dependency constraints.
Try `dart pub outdated` for more information.
Publishing big5_utf8_converter 1.0.0 to https://pub.dev:
├── CHANGELOG.md (<1 KB)
├── LICENSE (1 KB)
├── README.md (2 KB)
├── assets
│   └── big5_to_utf8_lookup.bin (64 KB)
├── bin2dart.dart (<1 KB)
├── example
│   ├── big5_utf8_converter_example.dart (<1 KB)
│   └── big5_utf8_converter_load_table_example.dart (1 KB)
├── lib
│   ├── big5_utf8_converter.dart (<1 KB)
│   └── src
│       ├── big5_to_utf8_lookup_data.dart (260 KB)
│       └── big5_utf8_converter.dart (1 KB)
├── pubspec.yaml (<1 KB)
└── test
    └── big5_decoder_test.dart (2 KB)

Total compressed archive size: 98 KB.
The server may enforce additional checks.

Package has 0 warnings.
```

接著才使用 Github Actions 來做事,僅需使用 Github Actions 預設的 Dart 就會幫跑 test case:


```
jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603

      - name: Install dependencies
        run: dart pub get

      - name: Analyze project source
        run: dart analyze

      - name: Run tests
        run: dart test
```

最後,更新 README.md 增加圖標令牌:

[![pub package](https://img.shields.io/pub/v/big5_utf8_converter.svg)](https://pub.dev/packages/big5_utf8_converter)
[![Build Status](https://github.com/changyy/big5_utf8_converter_dart/workflows/Dart/badge.svg)](https://github.com/changyy/big5_utf8_converter_dart/actions)

收工!

2024年8月28日 星期三

Xiaomi 小米智慧直流變頻電風扇 斷頭 維修


小米智慧直流電頻電風扇 斷

買了六年的電風扇,之前已經有點感覺有異狀,但在某一天早上,終於因為不當的使用方式移動他而斷掉了,查了一下討論文,沒想到還滿多人講這件事,包括沒有設計提拉他的把手 XD 這大概就是極簡設計吧,正確的移動他是要握住下方的桿子。

研究了一會兒,在 BiliBili 和抖音有看到“拆解”影片,再細看一下淘寶有賣零件!不錯,立馬研究一下,但想說零件來再說,結果...

拿到零件後,要進行最後的處理,才發現最困難的地方就是拆除斷掉的項目



因為這段是把塑膠套管透過強力膠黏住的,解方只有兩種,一種是加熱處理,另一種是設法先鋸成切片,各個片段取出時比較容易。

原本想說懶得買鋸子,就學影片火烤,但烤了老半天都看到塑膠管形變,就是拔不出來,最後在暴力處理的過程,正式把斷掉的部分拔掉,但管子內管還是沒有清乾淨,新買的零件也裝不下去,最終回去買鋸子鋸個兩半,在用一字跟夾子強力清除,不得不說,清除完有種爽感跟成就感,那是擠壓多時的恨意 XD







最後,安裝很順利,畢竟拆解時都有拍照,對著回顧一下就裝完了,裝完當下還覺得有點怪怪的,結果裝反了 XD 又拆一次再裝一次,熟到可以進工廠組裝了


這張是裝錯的,裝反了

終於,結束了這場鬧劇。

這邊善意提醒,購買零件時,請務必把電風扇底部的型號給予賣家,因為小米電風扇有很多款,每款格式不同,主要產品型號可以讓賣家快速確認,此外,可以的話,那還是放棄修復了 XD 年限到了,這類商品就自然地汰舊換新吧!



2024年8月23日 星期五

Python 開發筆記 - 不透過 Google API Key 下載公開 Google Sheets 資料,將每個 Sheet 匯出 csv 格式

有個工作任務要做 Google Sheets 資料比對,最簡單的方式就把他們匯出後用 git diff 來比對即可,想試著用 AI 產生一隻 python 小工具,只要輸入 Google Sheets URL 或是 Google Sheets URL 內關鍵的辨識 ID (在此稱作 spreadsheet id),就能夠下載該 Google Spreadsheet 內所有 sheet 資料

然後,要下載指定 sheet 必須得知每個 sheet gid ,這個問 ChatGPT-4o 或 Claude.ai 老半天還是沒法解,包過上傳 html static code,最後自己還是跳下來收尾人工刻一下,原理:

  • 先設法下載到 HTML Code
  • 透過 docs-sheet-tab-caption 抓出 Sheet Name
  • 透過 var bootstrapData = {...}; 得知內有 Sheet Name 與 Gid 的資料
  • 再用 [0,0,\"gid\",[ 格式,找到 gid
連續動作:

% python3 main.py 

usage: main.py [-h] (--google-spreadsheet-url GOOGLE_SPREADSHEET_URL | --google-spreadsheet-id GOOGLE_SPREADSHEET_ID) [--output OUTPUT]

main.py: error: one of the arguments --google-spreadsheet-url --google-spreadsheet-id is required


% python3 main.py --google-spreadsheet-id 'XXXXXXXXXXXXXXXXXXXXXXX'

[INFO] Downloaded sheet: sheet01 to sheets_csv/sheet01.csv

[INFO] Downloaded sheet: sheet02 to sheets_csv/sheet02.csv

[INFO] Downloaded sheet: sheet03 to sheets_csv/sheet03.csv

[INFO] Downloaded sheet: sheet04 to sheets_csv/sheet04.csv

[INFO] Downloaded sheet: sheet05 to sheets_csv/sheet05.csv


程式碼:

```
% cat main.py 
import argparse
import re
import requests
import json
import os

def extract_spreadsheet_id(url):
    match = re.search(r'/d/([a-zA-Z0-9-_]+)', url)
    return match.group(1) if match else None

def get_spreadsheet_info(spreadsheet_id):
    url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/edit"
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
    }
    
    try:
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        html_content = response.text
        
        # Extract sheet names
        sheet_names = re.findall(r'docs-sheet-tab-caption[^>]+>([^<]+)</div>', html_content)
        #print(f"Found sheet names: {sheet_names}")

        # Extract mergedConfig
        config_match = re.search(r'var bootstrapData\s*=\s*({.*?});', html_content, re.DOTALL)
        if config_match:
            config_str = config_match.group(1)
            sheet_info = {}
            try:
                for index, sheet_name in enumerate(sheet_names):
                    #print(f"Processing sheet: {sheet_name}, index: {index}")
                    beginPattern = f'[{index},0,\\"'
                    endPattern = f'\\",['
                    beginIndex = config_str.find(beginPattern)
                    endIndex = config_str.find(endPattern, beginIndex)
                    gidValue = config_str[beginIndex + len(beginPattern):endIndex]
                    sheet_info[sheet_name] = gidValue
                return sheet_info
            except Exception as e:
                print(f"[INFO] Error extracting sheet information: {e}")
                return None
        else:
            print("[INFO] Could not find bootstrapData in the HTML content")
            return None
    except requests.RequestException as e:
        print(f"[INFO] Error fetching the spreadsheet: {e}")
        return None

def download_sheet_as_csv(spreadsheet_id, sheet_name, gid, output_folder):
    csv_url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/export?format=csv&gid={gid}"
    csv_response = requests.get(csv_url)
    
    if csv_response.status_code == 200:
        output_path = os.path.join(output_folder, f"{sheet_name}.csv")
        with open(output_path, 'wb') as f:
            f.write(csv_response.content)
        print(f"[INFO] Downloaded sheet: {sheet_name} to {output_path}")
    else:
        print(f"[INFO] Failed to download sheet: {sheet_name}. Status code: {csv_response.status_code}")

def main():
    parser = argparse.ArgumentParser(description="Extract Google Spreadsheet information")
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--google-spreadsheet-url", help="Google Spreadsheet URL")
    group.add_argument("--google-spreadsheet-id", help="Google Spreadsheet ID")
    parser.add_argument('--output', type=str, default='sheets_csv', help='The directory to save the CSV files')
    args = parser.parse_args()

    if args.google_spreadsheet_url:
        spreadsheet_id = extract_spreadsheet_id(args.google_spreadsheet_url)
    else:
        spreadsheet_id = args.google_spreadsheet_id

    if not spreadsheet_id:
        print("[INFO] Invalid Google Spreadsheet URL or ID")
        return

    sheet_info = get_spreadsheet_info(spreadsheet_id)
    if sheet_info:
        for name, gid in sheet_info.items():
            download_sheet_as_csv(spreadsheet_id, name, gid, args.output)
    else:
        print("[INFO] Failed to extract sheet information")

if __name__ == "__main__":
    main()
```

2024年8月16日 星期五

Python 開發筆記 - 引用 yt-dlp extractor 資源,呼叫特定函數功能

我想 yt-dlp 應當不用做太多簡介,他本身容納了各種影音網站的分析器,以前也片段片段的研究:
原本有個任務是讓同事處理,他在忙碌就幫忙試了一下,整體上就問問 ChatGPT 就得到想要的八成的範例程式,接著再小幅修改一下使用方式就收工。有了 ChatGPT 後,幾乎可以不用做什麼筆記,忘了就在問一下即可,當然,能不能一問就得到結果,就回歸到詢問者的功力了。

例如情境:

透過 yt-dlp extractor 內的 facebook.py ,幫我列出 facebook video 內的格式,以協助進行產品多媒體格式的偵錯

如此 ChatGPT 就給我不錯的程式碼,修改一下,搞定:

```
% git clone https://github.com/yt-dlp/yt-dlp.git

% cat test-facebook.py
import sys
import argparse

def get_video_formats(url):
    from yt_dlp.YoutubeDL import YoutubeDL
    from yt_dlp.extractor.facebook import FacebookIE

    # 创建一个 YoutubeDL 实例
    ydl_opts = {}
    with YoutubeDL(ydl_opts) as ydl:
        # 实例化 FacebookIE 类,并传递 ydl 对象作为 downloader
        facebook_ie = FacebookIE(ydl)
        
        # 使用 extract 方法提取视频信息
        video_info = facebook_ie.extract(url)
        
        # 获取可用的格式
        formats = video_info.get('formats', [])
        
        # 打印所有格式的id和分辨率
        for fmt in formats:
            # 获取格式的分辨率,如果没有就使用 'N/A'
            resolution = fmt.get('height', 'N/A')
            print(f"Format ID: {fmt['format_id']}, Resolution: {resolution}p")

if __name__ == "__main__":
    # 设置命令行参数解析
    parser = argparse.ArgumentParser(description='Extract video formats from a Facebook video URL using yt-dlp.')
    parser.add_argument('--path', required=True, help='Path to the yt-dlp directory')
    parser.add_argument('--facebook-url', required=True, help='Facebook video URL')

    args = parser.parse_args()

    # 将 yt-dlp 的路径插入 sys.path
    sys.path.insert(0, args.path)

    # 调用函数来获取视频格式
    get_video_formats(args.facebook_url)

% python3 test-facebook.py --path ./yt-dlp/ --facebook-url 'https://www.facebook.com/TED/videos/1464960234158173'
[facebook] Extracting URL: https://www.facebook.com/TED/videos/1464960234158173
[facebook] 1464960234158173: Downloading webpage
Format ID: hd, Resolution: N/Ap
Format ID: sd, Resolution: N/Ap
Format ID: 492667606832062v, Resolution: 720p
Format ID: 1654227388706906v, Resolution: 720p
Format ID: 849737406808391v, Resolution: 720p
Format ID: 1263117531679597v, Resolution: 1080p
Format ID: 1036187750774836a, Resolution: Nonep
```

收工!

這樣往後有哪些 extrator 內有一些有趣的 function ,就可以這樣呼叫出來使用,省去自己在重複刻輪子,且如果有需要更新維護,僅需把 yt-dlp code 更新到最新即可,保留異質系統整合彈性

此外 yt-dlp 本身指令有支援 JSON output ,若 yt-dlp JSON output 已夠用,也不需要向上述那樣用法,上述筆記純粹是一個範例,像是取得中間資料結構等等。

2024年8月7日 星期三

Python 開發筆記 - 使用 Nginx / WSGI / Gunicorn / Flask 進行 Python API 服務的上線整合 @ Ubuntu

近期工作上在 node.js 服務上,多開了一個 python api 服務做整合應用,目前先試著混合架構來擠擠機器資源而不是 micorservice 架構。

實作就是 Nginx 擋在前面,接著有些 requests 交給 node.js 運作,有些 requests 交給 python api 服務,整體上就是透過 Proxy Pass 架構:

$ cat /etc/nginx/conf.d/service.conf | grep location
    location / {

    location /node/ { rewrite ^/node/(.*)$ /$1 break; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_redirect off; proxy_http_version 1.1; proxy_read_timeout 60; proxy_pass http://localhost:3000; }

    location /python/ { rewrite ^/python/(.*)$ /$1 break; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_redirect off; proxy_http_version 1.1; proxy_read_timeout 60; proxy_pass http://unix:/var/run/service-py.sock; }

其中 node.js 是跑在 3000 port 服務,而 python api 跑在 unix:/var/run/service-py.sock ,上述 Nginx 設定檔剛好可以作為筆記,屬於兩種不同的設計方式,此外,這邊收到 requests 導向到 node.js 或 python api 時,都會刻意再去掉一些 prefix ,讓後面的服務開發比較多彈性。

回到 python api ,此次採用 Flask framework,他的運行很簡單:

```
% cat app.py
...
if __name__ == '__main__':
    app.run(host='localhost', port=3001)
```

直接執行法:

% python3 app.py 
 * Serving Flask app 'app' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://localhost:3001
Press CTRL+C to quit

使用 Flask 指令執行:

$ FLASK_APP=app.py flask run --host 0.0.0.0 --port 3001
 * Serving Flask app 'app.py' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:3001
 * Running on http://x.x.x.x:3001
Press CTRL+C to quit

最後則是為了穩定性,採用 Nginx + WSGI 整合方式,透過 Gunicorn 來工作:

$ cat wsgi.py 
from app import app

if __name__ == "__main__":
    app.run()

$ sudo gunicorn --workers 4 --bind unix:/var/run/service-py.sock -m 777 wsgi:app
[2024-08-07] [1701223] [INFO] Starting gunicorn 20.0.4
[2024-08-07] [1701223] [INFO] Listening at: unix:/var/run/service-py.sock (1701223)
[2024-08-07] [1701223] [INFO] Using worker: sync
[2024-08-07] [1701225] [INFO] Booting worker with pid: 1701225
[2024-08-07] [1701226] [INFO] Booting worker with pid: 1701226
[2024-08-07] [1701227] [INFO] Booting worker with pid: 1701227
[2024-08-07] [1701228] [INFO] Booting worker with pid: 1701228

如果要把運行也包裝系統指令方便處理,就只需:

$ cat /etc/systemd/system/my-py.service 
[Unit]
Description=my-py-service
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/path/project
#ExecStart=/usr/bin/python3 app.py
#Environment=FLASK_APP=app.py
#ExecStart=/usr/bin/python3 -m flask run --port 3001 --host 0.0.0.0
ExecStart=/usr/bin/gunicorn --workers 4 --bind unix:/var/run/service-py.sock -m 777 wsgi:app
Restart=on-failure

[Install]
WantedBy=multi-user.target

後續就可以透過以下方式管理:

$ sudo systemctl status my-py.service
$ sudo systemctl stop my-py.service
$ sudo systemctl start my-py.service
$ sudo systemctl restart my-py.service