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

2024年8月6日 星期二

PHP 開發筆記 - 嘗試使用 Laravel + Twill CMS Toolkit 開發 CMS 後台管理服務 @ macOS M1



近期公司內部服務都朝向 Laravel framework 來維護,其中常見需求是製作具有 CMS 管理的機制,提供不同部門的同事編輯資料及發佈出去。就來試試看 Twill 這個 CMS Toolkit 套件。從他的文件得知,引入他可以快速擁有後台登入機制,以及省去自己規劃資料庫的資料表,而相較 wordpress 則是有更大的彈性做事。

目前在 Macbook M1 和 MacPorts 環境下,操作一下,先弄個 Laravel project 出來:

```
% sudo port install php83 php83-iconv php83-intl php83-mbstring php83-openssl php83-curl php83-sqlite php83-zip php83-gd php83-exif
% alias php=php83
% wget https://getcomposer.org/download/latest-stable/composer.phar -O /tmp/composer.phar
% php /tmp/composer.phar self-update
% alias composer="php /tmp/composer.phar"
% composer create-project --prefer-dist laravel/laravel /tmp/laravel-workspace
Creating a "laravel/laravel" project at "/tmp/laravel-workspace"
Installing laravel/laravel (v11.1.4)
  - Installing laravel/laravel (v11.1.4): Extracting archive
Created project in /tmp/laravel-workspace
> @php -r "file_exists('.env') || copy('.env.example', '.env');"
Loading composer repositories with package information
Updating dependencies
...
```
這邊偷懶用 alias composer="php /tmp/composer.phar" ,後面會碰到類似的的錯誤訊息時,其實就是找不到 composer 指令:

```
   Symfony\Component\Process\Exception\ProcessStartFailedException 

  The command "'composer' 'dump-autoload'" failed.

Working directory: /private/tmp/laravel-workspace

Error: proc_open(): posix_spawn() failed: No such file or directory
```

因此可以把 /tmp/composer.phar 擺到 PATH 內會尋找指令的地方,或是人工再補個 composer dump-autoload 等等

```
% cp /tmp/composer.phar ~/.bin/composer
% chmod 755 ~/.bin/composer
```

接下來安裝 Twill Toolkit,整個流程其實參考 Twill 官網教學文即可:Building a simple page builder with Laravel Blade,在此僅記錄一下操作流程:

```
% cd /tmp/laravel-workspace
laravel-workspace % composer require area17/twill:"^3.0"
laravel-workspace % php artisan twill:install
...
Let's create a superadmin account!

 Enter an email:
 > user@example.com

 Enter a password:
 > 

 Confirm the password:
 > 

Your account has been created
All good!
```

接著安裝後台模組 Pages:

```
laravel-workspace % php artisan twill:make:module pages

 Do you need to use the block editor on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to translate content on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to generate slugs on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to attach images on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to attach files on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to manage the position of records on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to enable revisions on this module? [yes]:
  [0] no
  [1] yes
 > 

 Do you need to enable nesting on this module? [no]:
  [0] no
  [1] yes
 > 

 Do you also want to generate a model factory? [yes]:
  [0] no
  [1] yes
 > 

 Do you also want to generate a model seeder? [yes]:
  [0] no
  [1] yes
 > 

Migration created successfully! Add some fields!

   INFO  Factory [database/factories/PageFactory.php] created successfully.  

Models created successfully! Fill your fillables!
Repository created successfully! Control all the things!
Controller created successfully! Define your index/browser/form endpoints options!
Form request created successfully! Add some validation rules!

 Do you also want to generate the preview file? [yes]:
  [0] no
  [1] yes
 > 

   INFO  Seeder [database/seeders/PageSeeder.php] created successfully.  

The following snippet has been added to routes/twill.php:
-----
TwillRoutes::module('pages');
-----
To add a navigation entry add the following to your AppServiceProvider BOOT method.
-----
use A17\Twill\Facades\TwillNavigation;
use A17\Twill\View\Components\Navigation\NavigationLink;

public function boot()
{
    ...
    
    TwillNavigation::addLink(
        NavigationLink::make()->forModule('pages')
    );
}
-----
Do not forget to migrate your database after modifying the migrations.

Enjoy.
```

上述的意思是 routes/twill.php 已經增加好後台管理介面的 routing 規則:

```
% cat routes/twill.php 
<?php

use A17\Twill\Facades\TwillRoutes;

// Register Twill routes here eg.
// TwillRoutes::module('posts');

TwillRoutes::module('pages');
```

但是 app/Providers/AppServiceProvider.php 必須自己處理,調整成下方:

```
laravel-workspace % cat app/Providers/AppServiceProvider.php 
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use A17\Twill\Facades\TwillNavigation;
use A17\Twill\View\Components\Navigation\NavigationLink;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        TwillNavigation::addLink(
            NavigationLink::make()->forModule('pages')
        );
    }
}
```

這些更動是讓後台 /admin 時,上方導覽可以多一項 Pages 的功能,後續透過下方來使用:

laravel-workspace % php artisan migrate
laravel-workspace % php artisan serve --host 0.0.0.0 --port 8000

如此,就可以用 http://localhost:8000/admin 登入後台,點擊 Pages 切換到 http://localhost:8000/admin/pages 可以新增 Pages ,可點擊 Add new 按鈕一則,這時可以看到前台網址規則是 localhost/en/pages/hello-world ,但實際上在 PHP Laravel routing 規則中,前台網址規則都還沒實作處理,所以是看不到資料的。


上述僅做了簡易的後台搭建,後續要處理的有:
  1. 讓後來編輯界面更加豐富,例如有更多的元件(image, text)等,主要是擴增編輯使用的表單元素
  2. 處理後台編輯時,preview 缺少的 css 資源 (運行 npm install && npm run build)
  3. 建立新元素的樣板資料
  4. 建立前台網頁的網址規則跟處理的 Controller (PageDisplayController)
如此,在後台把頁面發布出去後,就可以在前台被瀏覽到。但光上述四點的操作項目是不少的。

接著來進行,也就是 Twill 官網導覽流程,並且把其他碰到的問題也解一解:Configuring the page module

由於預設的前台網頁網址規則是跟語言相關的,例如建立個 Hello World Page 後,可以看到他的前台網址是 localhost/en/pages/hello-world ,若要去掉語言,就是調整 app/Http/Controllers/Twill/PageController.php :

```
 18     protected function setUpController(): void
 19     {
 20         $this->setPermalinkBase('');          // 去掉 /pages/ 那層網址
 21         $this->withoutLanguageInPermalink();  // 去掉 /en/ 那層網址 
 22     }
```

接著來調整撰文時的表單功能,例如目前每一個 Page 都可以填寫 title 跟 description 可增加 SEO 的效果,而增加文章分享後的美觀,則是要增加圖片,這時直接修改 app/Http/Controllers/Twill/PageController.php ,添加發文時可以上傳圖片功能:

```
  6 use A17\Twill\Services\Forms\Fields\Medias;
...
 29     public function getForm(TwillModelContract $model): Form
 30     {   
 31         $form = parent::getForm($model);
 32         
 33         $form->add(
 34             Input::make()->name('description')->label('Description')->translatable()
 35         );
 36         
 37         $form->add(
 38             Medias::make()->name('cover')->label('Cover image')
 39         );
 40         
 41         return $form;
 42     }
...
```

主要是新增引入 `use A17\Twill\Services\Forms\Fields\Medias;` 跟 `$form->add( Medias::make()->name('cover')->label('Cover image') );`


這時我們的 laravel 是透過 artisan 跑在 8000 port,拖拉上傳圖片時,其實會顯示不出來


因為他的圖片網址規則並沒有帶 port ,且只要把網址複製出來加上 port number 就可以正常顯示,代表只需處理 Laravel framework 的 .env 中 APP_URL 規則:

```
% cat .env | grep APP_URL
APP_URL=http://localhost
```

這時建議測試時可以有兩套環境設置,運行時指定設定檔案:

```
% cp .env .env.local
% cat .env.local | grep APP_URL 
APP_URL=http://localhost:8000
% php artisan serve --host 0.0.0.0 --port 8000 --env local

   INFO  Server running on [http://0.0.0.0:8000].  

  Press Ctrl+C to stop the server
```

如此也解掉後台圖片顯示失敗的問題。


下一刻則是擴充文章編輯環境,引入 Block Editor 架構,也就是在編輯文章時,可以拖拉區塊到文章內,還可以自行開發,把常用的項目元件化。從 Twill 官網的範例資訊,預設有兩個 Block 了,一個是 image block ,另一個是 wysiwyg block,官網範例會試著新增一個小的區塊,例如名為 Text 的區塊。

首先,先啟用 Block Editor,啟用方式是在 app/Http/Controllers/Twill/PageController.php 裡引入 `use A17\Twill\Services\Forms\Fields\BlockEditor;` 和增加 `$form->add( BlockEditor::make() );`

接著則是試著建立一個 text block:

```
laravel-workspace % php artisan twill:make:block text

 Should we also generate a view file for rendering the block? (yes/no) [no]:
 > yes

Creating block...
File: /private/tmp/laravel-workspace/resources/views/twill/blocks/text.blade.php
Block text was created.
Block text blank render view was created.
Block is ready to use with the name 'text'

laravel-workspace % cat resources/views/twill/blocks/text.blade.php
@twillBlockTitle('Text')
@twillBlockIcon('text')
@twillBlockGroup('app')

<x-twill::input
    name="title"
    label="Title"
    :translated="true"
/>

<x-twill::wysiwyg
    name="text"
    label="Text"
    placeholder="Text"
    :toolbar-options="[
        'bold',
        'italic',
        ['list' => 'bullet'],
        ['list' => 'ordered'],
        [ 'script' => 'super' ],
        [ 'script' => 'sub' ],
        'link',
        'clean'
    ]"
    :translated="true"
/>
```

可以看到其樣板也長出來了


但是在後台 preview 時,仍會有待處理的訊息:

This is a basic preview. You can use dd($block) to view the data you have access to. <br />This preview file is located at: /private/tmp/laravel-workspace/resources/views/site/blocks/text.blade.php

將他修改一下:

```
% cat resources/views/site/blocks/text.blade.php
<div class="prose">
    <h2>{{$block->translatedInput('title')}}</h2>
    {!! $block->translatedInput('text') !!}
</div>
```    


接著再產生另一個 image block 並更新他的 block view 跟 preview:

```
laravel-workspace % php artisan twill:make:block image

 Should we also generate a view file for rendering the block? (yes/no) [no]:
 > yes

Creating block...
File: /private/tmp/laravel-workspace/resources/views/twill/blocks/image.blade.php
Block image was created.
Block image blank render view was created.
Block is ready to use with the name 'image'

laravel-workspace % cat resources/views/twill/blocks/image.blade.php
@twillBlockTitle('Image')
@twillBlockIcon('text')
@twillBlockGroup('app')
 
<x-twill::medias
    name="highlight"
    label="Highlight"
/>

laravel-workspace % cat resources/views/site/blocks/image.blade.php 
<div class="py-8 mx-auto max-w-2xl flex items-center">
    <img src="{{$block->image('highlight', 'desktop')}}"/>
</div>
```

此外,image block 要生效還需要調整 config/twill.php

```
% cat config/twill.php 
<?php

return [
    'block_editor' => [
        'crops' => [ 
            'highlight' => [
                'desktop' => [
                    [
                        'name' => 'desktop',
                        'ratio' => 16 / 9,
                    ],
                ],
                'mobile' => [
                    [
                        'name' => 'mobile',
                        'ratio' => 1,
                    ],
                ],
            ],
        ],
    ],
];
```

接著,還要幫前後台產的文章添加 CSS 效果 `@vite('resources/css/app.css')` ,添加方式是修改 `resources/views/site/layouts/block.blade.php` 跟 `resources/views/site/page.blade.php`

```
laravel-workspace % cat resources/views/site/layouts/block.blade.php 
<!doctype html>
<html lang="en">
<head>
    <title>#madewithtwill website</title>
    @vite('resources/css/app.css') 
</head>
<body>
<div>
    @yield('content')
</div>
</body>
</html>

laravel-workspace % cat resources/views/site/page.blade.php
<!doctype html>
<html lang="en">
<head>
    <title>{{ $item->title }}</title>
    @vite('resources/css/app.css') 
</head>
<body>
<div class="mx-auto max-w-2xl">
    {!! $item->renderBlocks() !!}
</div>
</body>
</html>
```

此外,還要用 npm 工具編譯出 resources/css/app.css:

```
% nvm use v20
Now using node v20.9.0 (npm v10.3.0)

% npm install        

added 23 packages, and audited 24 packages in 577ms

5 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

laravel-workspace % tree resources/css/       
resources/css/
└── app.css

1 directory, 1 file
```

如此,在 Twill 後台編輯文章時,就可以在 Block editor 操作下增加文字區塊、圖片等等的功能。

不過,直到現在前台機制還沒打通,尚未提供前台 routing rule 等顯示前台網頁,先建立個 PageDisplayController 來處理:

```
% php artisan make:controller PageDisplayController

   INFO  Controller [app/Http/Controllers/PageDisplayController.php] created successfully.  
```

把 PageDisplayController 更新為可以接一個參數,並且立刻把它印出 debug 訊息:

```
% cat app/Http/Controllers/PageDisplayController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Contracts\View\View;

class PageDisplayController extends Controller
{
    public function show(string $slug): View
    {
        dd($slug);
    }
}
```

接著,再把 routing 設置好:

```
% cat routes/web.php 
<?php

use Illuminate\Support\Facades\Route;

//Route::get('/', function () {
//    return view('welcome');
//});

Route::get('{slug}', [\App\Http\Controllers\PageDisplayController::class, 'show'])->name('frontend.page'); 
```

如此在前台瀏覽網頁就會看到:

最後,再把 PageDisplayController 調整成顯示正確的資料:

```
laravel-workspace % cat app/Http/Controllers/PageDisplayController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Contracts\View\View;
use App\Repositories\PageRepository;

class PageDisplayController extends Controller
{
    //public function show(string $slug): View
    //{
    //    dd($slug);
    //}

    public function show(string $slug, PageRepository $pageRepository): View
    {
        $page = $pageRepository->forSlug($slug);
 
        if (!$page) {
            abort(404);
        }
 
        return view('site.page', ['item' => $page]);
    }
}
```

如此瀏覽前台時,例如 http://localhost:8000/hello-world 就可以顯示網頁內容了。

最後,如果碰到前台圖片沒有顯示出來的部分,則是留意拖拉建立圖片時,需要依照不同裝置版型做設定,例如 PC 瀏覽時看不到圖片,那應當是少設定的 desktop 的設置:

```
laravel-workspace % cat resources/views/site/blocks/image.blade.php
<div class="py-8 mx-auto max-w-2xl flex items-center">
    <img src="{{$block->image('highlight', 'desktop')}}"/>
</div>
```

需留意在 block editor 時,其 Image 拖拉進去時,有沒有 `desktop crop` 的描述

2024年8月2日 星期五

Node.js 開發筆記 - 分別透過 Pyodide, Brython, WebAssembly 在 node.js 呼叫 Python Code @ node.js v20, python3.11

一時興起研究一下 node.js 呼叫 python code 的方式,當然,都在 linux server 可以直接用 child_process 直接呼叫 python 去運行,例如 nodejs.org/api/child_process.html 的範例

```
const { spawn } = require('node:child_process');
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.error(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
}); 
```

然而,有沒有可能在 node.js 內,直接做 python 直譯的過程等方式?當個樂子,找了一下,還真的有,這樣搞的優勢是降低環境部署的變因,當然,效率上不見得是好辦法,但可以讓不同語言的開發者進行融合(誤),目前看到兩種整合方式:
其中 Brython 屬於設計在 Web Browser 下運行(需要 DOM 資源),而 Pyodide 則不需要。分別筆記一下用法。

首先是要執行的 python code 內有 python 的 re 跟 json 模組的使用:

```
% cat script.py 
import re
import json

def runTest(inputData):
    output = {}
    flags = 0
    pattern = r'''(?x)
      (?:
        \.get\("n"\)\)&&\(b=|
        (?:
          b=String\.fromCharCode\(110\)|
          (?P<str_idx>[a-zA-Z0-9_$.]+)&&\(b="nn"\[\+(?P=str_idx)\]
        ),c=a\.get\(b\)\)&&\(c=|
        \b(?P<var>[a-zA-Z0-9_$]+)=
      )(?P<nfunc>[a-zA-Z0-9_$]+)(?:\[(?P<idx>\d+)\])?\([a-zA-Z]\)
      (?(var),[a-zA-Z0-9_$]+\.set\("n"\,(?P=var)\),(?P=nfunc)\.length)'''

    try:
        result = re.search(pattern, inputData, flags)
        if result:
            output["status"] = True
            output["data"] = result.groupdict()
    except Exception as e:
        output["error"] = str(e)
    return json.dumps(output, indent=4)

runTest(data)
```

Pyodide 用法:

```
% nvm use v20
Now using node v20.10.0 (npm v10.2.3)
% npm install pyodide
% cat package.json 
{
  "dependencies": {
    "pyodide": "^0.26.2"
  }
}

% cat run.js
const fs = require('fs').promises;
const { loadPyodide } = require("pyodide");

async function main() {
  const fileContent = await fs.readFile('mydata.bin', 'utf8');
  let pyodide = await loadPyodide();
  pyodide.globals.set("data", pyodide.toPy(fileContent));
  const pythonCode = await fs.readFile('script.py', 'utf8');
  let result = pyodide.runPython(pythonCode);
  console.log(result);
}

main();

% echo "Hello World" > mydata.bin

% node run.js
{}
```

上述使用過程算直觀,但偷懶把要傳遞的資料設定在全域變數,在用 node.js 環境接住 python 運算的結果,看來這個效果是很 OK ,有正常運行得到期待的結果。

接著研究 Brython 用法,他設計上需要 Browser 環境做事:

% head -n 9 brython.js
// brython.js brython.info
// version [3, 11, 0, 'final', 0]
// implementation [3, 11, 3, 'dev', 0]
// version compiled from commented, indented source files at
// github.com/brython-dev/brython
var __BRYTHON__=__BRYTHON__ ||{}
try{
eval("async function* f(){}")}catch(err){console.warn("Your browser is not fully supported. If you are using "+
"Microsoft Edge, please upgrade to the latest version")}

在 node.js 需要 jsdom 模擬一些環境,而下載 brython.js 和 brython_stdlib.js 則參考官網文件,透過 pip install brython 工具出來使用,所以這邊的流程會多了 python 工具的安裝,且現況用 python 3.12 會顯示有些問題,就先定在 3.11 版。此外 brython.js 運行環境,也是可以用最新版 node.js v22 ,但是會看到 [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead. 訊息,所以先退到 node.js v20 避免額外的訊息

連續動作:

```
% python3.11 -m venv venv
% source venv/bin/activate
(venv) % pip install brython
Collecting brython
  Using cached brython-3.11.3-py3-none-any.whl.metadata (1.0 kB)
Using cached brython-3.11.3-py3-none-any.whl (1.6 MB)
Installing collected packages: brython
Successfully installed brython
(venv) % brython-cli install
Installing Brython 3.11.3
done
(venv) % ls
README.txt brython_stdlib.js index.html venv
brython.js demo.html unicode.txt
```

接著回到 node.js 主場:

```
% nvm use v20
Now using node v20.10.0 (npm v10.2.3)
% cat package.json 
{
  "dependencies": {
    "jsdom": "^24.1.1"
  }
}
% cat run-via-dom.js 
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');

const dom = new JSDOM(`<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <script></script>
    </body>
</html>`, {
    runScripts: "dangerously", 
    resources: "usable"
});

const brythonJsPath = path.join(__dirname, 'brython.js');
const brythonStdlibJsPath = path.join(__dirname, 'brython_stdlib.js');

const brythonJs = fs.readFileSync(brythonJsPath, 'utf8');
const brythonStdlibJs = fs.readFileSync(brythonStdlibJsPath, 'utf8');

try {
    dom.window.eval(brythonJs);
    dom.window.eval(brythonStdlibJs);
} catch (error) {
    console.error('Error executing brython.js:', error);
}

const scriptPath = path.join(__dirname, 'script.py');
const pythonScript = fs.readFileSync(scriptPath, 'utf8');

const dataPath = path.join(__dirname, 'mydata.bin');
const binaryData = fs.readFileSync(dataPath, 'utf8');
const base64Data = Buffer.from(binaryData).toString('base64');

const scriptElement = dom.window.document.createElement('script');
scriptElement.type = 'text/python';
scriptElement.textContent = `
import base64
data = base64.b64decode("""${base64Data}""")

${pythonScript}

from browser import document
document.output = runTest(data)
`
dom.window.document.body.appendChild(scriptElement);
try {
    dom.window.brython({debug: 1, pythonpath: ['.']})
    console.log(dom.window.document.output);
} catch (error) {
    console.error('Error executing dom.window.brython:', error);
}
console.log('Python script execution completed.');

% echo "Hello World" > mydata.bin

% node run-via-dom.js 
{"status": false, "data": {}, "error": "not the same type for string and pattern"}
Python script execution completed.
```

很可惜的,剛好要實驗複雜的 python regular expression,在 brython.js + node.js v20 + jsdom 環境上失敗了,甚至小改 index.html 搭配 python3 -m http.server 用 Chrome browser 執行(給予他完整的 Chrome 瀏覽器環境)還是有一樣的錯誤訊息,這邊就暫時推論失敗了,而上述的範例已經包括從 node.js 傳資料到 python code ,以及運行完如何把回傳資料傳到 node.js 使用,眼尖的人,應該會發現在 brython 用法內,使用了 `document.output = runTest(data)` ,其實是多呼叫了一次 runTest(data),因為原先 `${pythonScript}` 也有做,但沒在細追怎樣接運算結果,剛好不合預期就放棄研究。

最後,就是 WebAssembly 領域(一開始寫這篇筆記就是要研究 WebAssembly ,不小心走偏),把某一種 python code 轉成 wasm 格式,接著用 wasmer 運行,或是在其他語言(如 node.js)運行 wasm code。

先透過 MacPorts 安裝 wasmer:

% port search wasmer
wasmer @4.3.5 (lang, devel)
    The leading WebAssembly Runtime supporting WASI and Emscripten
% sudo port install wasmer

接著試著用 py2wasm 把 script-main.py 轉成 script-main.wasm,其中 py2wasm 官網有提到目前僅支援 python3.11:

% python3.11 -m venv venv
% source venv/bin/activate
(venv) % pip install py2wasm
(venv) % py2wasm script-main.py -o script-main.wasm

程式碼:

```
% cat script-main.py
import re
import json

def runTest(inputData):
    output = { "status": False, "data": {}, "error": None}
    flags = 0
    pattern = r'''(?x)
      (?:
        \.get\("n"\)\)&&\(b=|
        (?:
          b=String\.fromCharCode\(110\)|
          (?P<str_idx>[a-zA-Z0-9_$.]+)&&\(b="nn"\[\+(?P=str_idx)\]
        ),c=a\.get\(b\)\)&&\(c=|
        \b(?P<var>[a-zA-Z0-9_$]+)=
      )(?P<nfunc>[a-zA-Z0-9_$]+)(?:\[(?P<idx>\d+)\])?\([a-zA-Z]\)
      (?(var),[a-zA-Z0-9_$]+\.set\("n"\,(?P=var)\),(?P=nfunc)\.length)'''

    try:
        result = re.search(pattern, inputData, flags)
        if result:
            output["status"] = True
            output["data"] = result.groupdict()
    except Exception as e:
        output["error"] = str(e)
    return json.dumps(output, indent=4)

if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("Usage: python script-main.py <inputData>")
    else:
        print(runTest(sys.argv[1]))

% python3 script-main.py 
Usage: python script-main.py <inputData>

% python3 script-main.py "Hello World"
{
    "status": false,
    "data": {},
    "error": null
}
```

wasmer 實測:

```
% wasmer run script-main.wasm 
Usage: python script-main.py <inputData>

% wasmer run script-main.wasm "Hello World"
{
    "status": false,
    "data": {},
    "error": null
}
```

接著讓 Node.JS 來運行,這邊就來煩 ChatGPT 並小改一下,有了一個比較堪用的版本:

```
% cat run.js 
const fs = require('fs');
const { WASI } = require('wasi');
const path = require('path');
const { TextDecoder } = require('util');

const runWasm = async (inputData) => {
    const wasmPath = path.resolve('./script-main.wasm');
    const wasmBinary = fs.readFileSync(wasmPath);

    // Setup a WASI instance
    const wasi = new WASI({
        args: inputData ? ['script-main.wasm', inputData] : ['script-main.wasm'],
        env: {},
        version: 'preview1'
    });

    // Create a memory buffer for the stdout
    const memory = new WebAssembly.Memory({ initial: 1 });

    // Compile and instantiate the WebAssembly module
    const { instance } = await WebAssembly.instantiate(wasmBinary, {
        wasi_snapshot_preview1: wasi.wasiImport,
        env: { memory }
    });

    // Start the WASI instance
    wasi.start(instance);

    // Read and decode the stdout data
    const stdout = new Uint8Array(memory.buffer);
    const decoder = new TextDecoder('utf8');
    const output = decoder.decode(stdout);
    console.log(output.trim());
};

// Get the inputData from the command line arguments
runWasm(process.argv[2] || null).catch(console.error);

% nvm use v20
Now using node v20.10.0 (npm v10.2.3)

% node run.js 
(node:22046) ExperimentalWarning: WASI is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Usage: python script-main.py <inputData>

% node run.js "Hello World"
(node:22050) ExperimentalWarning: WASI is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{
    "status": false,
    "data": {},
    "error": null
}
```

回過頭來,故事起源是想善用一些 open source 甚至不同程式語言的整合架構,因此稍微研究一些跨語言的整合,很可惜的,最佳的路線應當還是各自跑在各自的 runtime 環境,以上就當趣味筆記一下。