2024年4月11日 星期四

PHP 開發筆記 - 修正 osTicket v1.18.1 信件處理流程 (Mail Parser / Mail Fetch / MIMEDecode)


經歷一陣子的追蹤,發現 osTicket v1.18.1 的信件處理流程更新的不錯,但在一個關鍵的格式拼裝上出了錯誤,目前就發個 PR 讓團隊評估是否能修正了

這陣子研究 osTicket v1.18.1 時,信件處理分成兩個部分,一個是信件下載,另一個是信件解析。之前設法先把他信件架在過程中,埋了一個機制把信件存起來,接著去研究它的 MIMEDecode 流程,並猜測這邊要補強,實際上是他下載信件後,要組成 MIME 格式有問題,才使得信件解析有問題


        public function getRawEmail(int $i) {
-           return $this->getRawHeader($i) . $this->getRawContent($i);
+           return trim($this->getRawHeader($i)) . "\r\n\r\n" . $this->getRawContent($i);
        }

透過這次也研究了 osTicket v1.18 採用了 laminas-mail 來處理信件下載流程,整體上跑 api/cron.php 可以看到:

# php8.1 cron.php
@class.mail.php - Imap class: __construct
@Storage/Imap.php: __construct 
@Storage/Imap.php: selectFolder(INBOX) 
@Storage/Imap.php: countMessages()
@Storage/Imap.php: getRawHeader(1, , 0)
@Storage/Imap.php: getRawContent(1, )
@class.mail.php, Imap class: markAsSeen(1)
@Storage/Imap.php: setFlags(1, Array)
@class.mail.php, Imap class: expunge()
@Storage/Imap.php:close()

而在 class.mail.php 在 v1.16 是看不到的,可在 osTicket/osTicket/blob/1.16.x/include/class.mailfetch.php 觀看直接用 PHP imap_* 系列的函式做事,而在 v1.17 起多了 class.mail.php 檔案,在其檔案內可以看到 getRawEmail() 的實作分成 getRawHeader() + getRawContent() 

再往下追就會發現架構也變得漂亮。趁這次多了解了 osTicket ,而為了這次任務,解題思維:
  1. 設法用 Docker 建立 osTicket 測試環境
  2. 設法在 osTicket 處理信件流程中,取出一個 eml 檔案實驗
  3. 設法在 osTicket 架構下,建立匯入 eml 檔案作為重現 MIMEDecode 的測試
  4. 設法直接用 MIMEDecode 測試流程
  5. 追蹤 osTicket IMAP 信件下載流程
沒想到最後一步才發現問題的根源,若一開始先懷疑信件下載流程,應當可以秒解任務。後續再看送出的 PR 有沒緣被收了

2024年3月27日 星期三

PHP 開發筆記 - 使用 Docker 在 Ubuntu 22.04 和 PHP 8.1 架設 osTicket v1.18.1 環境,研究 Mail Parser 流程

接續上篇追蹤 osTicket 信件處理流程的筆記,這次用 Docker 包裝可運行和測試的獨立環境,主要採用 Ubuntu 22.04 與 PHP 8.1,並且規劃方式,建構出備份原始信件的架構,以及可反覆解析信件的流程。

首先是 Dockerfile ,主要設計從 GitHub.com 取出 v1.18.1.zip 並解壓縮 /var/www/osticket-v1.18.1,其餘是安裝相關套件和資料庫的帳號建立等等,最後再把 MySQL, PHP-FPM 和 nginx 運行起來,並預設把 nginx logs 寫到 stdout 觀看:

RUN wget https://github.com/osTicket/osTicket/releases/download/v1.18.1/osTicket-v1.18.1.zip -O /tmp/osticket.zip \
    && unzip /tmp/osticket.zip -d /var/www/ \
    && mv /var/www/upload /var/www/osticket-v1.18.1 \
    && cp /var/www/osticket-v1.18.1/include/ost-sampleconfig.php /var/www/osticket-v1.18.1/include/ost-config.php \
    && chown -R www-data:www-data /var/www/osticket-v1.18.1 \
    && rm /tmp/osticket.zip

...

RUN service mysql start && \
    mysql -e "CREATE USER 'developer'@'%' IDENTIFIED BY '12345678';" && \
    mysql -e "GRANT ALL PRIVILEGES ON *.* TO 'developer'@'%' WITH GRANT OPTION;" && \
    mysql -e "CREATE DATABASE osticket_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" && \
    mysql -e "GRANT ALL PRIVILEGES ON osticket_dev.* TO 'developer'@'%';" && \
    mysql -e "FLUSH PRIVILEGES;" 

...

CMD service mysql start && service php8.1-fpm start && service nginx start && tail -f /var/log/nginx/access.log /var/log/nginx/error.log;

接下來更動 include/class.mailfetch.php 檔案,讓他下載信件時,可以存一份在 /tmp 方便後續使用:

@file_put_contents("/tmp/debug-mail.$i", $this->mbox->getRawEmail($i));

最後,弄一隻 api/cron-dev.php 檔案,可以指定 RawMail 的格式路徑,從指定位置讀進來解析信件,如此靠 api/cron-dev.php 就可以輕鬆不斷實驗 MIMEDecode 流程:

# php api/cron-dev.php 
[INFO] Input: /tmp/debug-mail
[INFO] Input File not found

# php api/cron-dev.php /tmp/debug-mail.1
[INFO] Input: /tmp/debug-mail.1
Ticket Object
(
    [ht] => Array
        (
            [ticket_id] => 2
            [ticket_pid] => 
...
            [lastupdate] => 2024-03-27 21:35:52
            [created] => 2024-03-27 21:35:52
            [updated] => 2024-03-27 21:35:52
            [topic] => 
            [staff] => 
            [user] => User Object
                (
                    [ht] => Array
                        (
                            [id] => 2
                            [org_id] => 0
                            [default_email_id] => 2
                            [status] => 0
                            [name] => UserName
                            [created] => 2024-03-27 21:35:52
                            [updated] => 2024-03-27 21:35:52
                            [default_email] => UserEmailModel Object
                                (
                                    [ht] => Array
                                        (
                                            [id] => 2
                                            [user_id] => 2
                                            [flags] => 0
                                            [address] => user@example.com
...

PHP 開發筆記 - 追蹤 osTicket v1.18.1 在 PHP8 環境處理信件的流程

公司用 osTicket 系統幾年了,最近把環境升級到 PHP8 和最新版 osTicket v1.18.1 後,同事回報踩到信件內容的 cid 圖片沒正常處理,就先追蹤一下信件處理流程,主要先追到 MIMEDecode 即可。

從官網文件 POP3/IMAP Settings Guide 得知,信件處理的觸發有從 crontab 和 api 的管道,目前就從 crontab 來追:

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.6 LTS
Release:        20.04
Codename:       focal

$ php -v
PHP 8.2.17 (cli) (built: Mar 16 2024 08:41:44) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.17, Copyright (c) Zend Technologies
    with Zend OPcache v8.2.17, Copyright (c), by Zend Technologies

$ php api/cron.php

接著開始看 api/cron.php 程式碼,大概追到 include/class.cron.php 時,就可以看到 osTicket\Mail\Fetcher::run(); 和 include/class.email.php 檔案了,從 include/class.mailfetch.php 可以看到:

```php
 78     function processMessage(int $i, array $defaults = []) {
 79         try {
 80             // Please note that the returned object could be anything from
 81             // ticket, task to thread entry or a boolean.
 82             // Don't let TicketApi call fool you!
 83             return $this->getTicketsApi()->processEmail(
 84                     $this->mbox->getRawEmail($i), $defaults);
 85         } catch (\TicketDenied $ex) {
 86             // If a ticket is denied we're going to report it as processed
 87             // so it can be moved out of the Fetch Folder or Deleted based
 88             // on the MailBox settings.
 89             return true;
 90         } catch (\EmailParseError $ex) {
 91             // Upstream we try to create a ticket on email parse error - if
 92             // it fails then that means we have invalid headers.
 93             // For Debug purposes log the parse error + headers as a warning
 94             $this->logWarning(sprintf("%s\n\n%s",
 95                         $ex->getMessage(),
 96                         $this->mbox->getRawHeader($i)));
 97         }
 98         return false;
 99     }
```

接著在 processEmail 前埋一個 @file_put_contents('/tmp/debug-mail', print_r($this->mbox->getRawEmail($i), true)); ,如此處理信件時,原始格式就會被記錄在 /tmp/debug-mail 裡,下一刻就能再重現信件格式的處理流程

小改後面:

$ sudo cp api/cron.php  api/cron-debug.php
$ tail -n 5 api/cron-debug.php 
//LocalCronApiController::call();
//
$obj = new \TicketApiController('cli');
print_r($obj->processEmail(file_get_contents('/tmp/debug-mail'), []));
?>

如此運行時就可以看資訊:

$ php api/cron-debug.php 
Ticket Object
(
    [ht] => Array
        (
            [ticket_id] => #
            [ticket_pid] => 
            [number] => #####
...

接著要來研究信件處理流程,就來到了 include/api.tickets.php 檔案:

```php
    function processEmail($data=false, array $defaults = []) {

        try {
            if (!$data)
                $data = $this->getEmailRequest();
            elseif (!is_array($data))
                $data = $this->parseEmail($data);
            print_r($data);
        } catch (Exception $ex)  {
            throw new EmailParseError($ex->getMessage());
        }
...
```

繼續追 include/class.api.php 檔案:

```php
238     function parseRequest($stream, $format, $validate=true) {
239         $parser = null;
240         switch(strtolower($format)) {
241             case 'xml':
242                 if (!function_exists('xml_parser_create'))
243                     return $this->exerr(501, __('XML extension not supported'));
244                 $parser = new ApiXmlDataParser();
245                 break;
246             case 'json':
247                 $parser = new ApiJsonDataParser();
248                 break;
249             case 'email': 
250                 $parser = new ApiEmailDataParser();
251                 break;
252             default:
253                 return $this->exerr(415, __('Unsupported data format'));
254         }
255         
256         if (!($data = $parser->parse($stream)) || !is_array($data)) {
257             $this->exerr(400, $parser->lastError());
258         }
259         
260         //Validate structure of the request.
261         if ($validate && $data)
262             $this->validate($data, $format, false);
263         
264         return $data;
265     }
266     
267     function parseEmail($content) {
268         return $this->parseRequest($content, 'email', false);
269     }
```

繼續追 include/class.mailparse.php 檔案,主要是追蹤 EmailDataParser -> Mail_Parse -> Mail_mimeDecode ,最後就是 include/pear/Mail/mimeDecode.php 的處理 MIMEDecode 的實作了。

剩下的工作就是研究它解析原始信件的流程,看看是 MIMEDecode 是否少了遞迴解,還是有什麼限制了:

2024年3月15日 星期五

企業導入 AI 輔助方案:廣義用法 ChatGPT / 設計行銷 Stable Diffusion 與 AI 主播 / 程式開發 Github Copilot

各大 AI 應用大放光彩一年多後,上個月也接到一個任務要去評估各大 AI 服務是否能大力提升公司同仁工作效率,然而最近會被找去跟人分享交流,想著想著,還是把一些公開的資料整理一下,方便以後交流丟 link 偷懶

主要將工作輔助切成四大塊:
  1. 廣義 AI 助理
  2. 設計行銷生成式 AI 需求
  3. 研發工程 Code Suggestion / Code Review
  4. 使用 OpenAI API 自行開發服務,如 客服信半自動回應系統
廣義 AI 應用,那就是 AI 助理,可直接用 ChatGPT 即可,無論是個人免費還是付費版,單純 GPT-3.5 免費版也能完成七成工作,而掏錢加碼到付費版,則可以擁有更多應用,包括可安裝 plugins ,能直接下載訊息中的 link 資訊加以分析,我想,最主要的是可以上傳檔案叫 ChatGPT 分析吧!舉凡 PDF 還是 CSV 都行,還可以叫他去下載指定 link 內容回來分析。像近期 微軟 Copilot 推得很兇,免費版也能體驗到 GPT-4 turbo 了


2024-03-15 報價 - openai.com/chatgpt/pricing

ChatGPT Team 可以月為單位測試,習慣後改成年繳省錢便宜

如果願意的話,可以試試 ChatGPT Team 版本,原先發信給 ChatGPT Sales 詢問 Enterprise 該怎樣申請,交談幾次需求後被回說用 ChatGPT Team 已經足夠!

在 ChatGPT Team 付費服務下,開帳號流程進入邀請式架構,直接輸入同事信箱即可。讓同事在創建 ChatGPT 帳號是很順暢的(不用簡訊認證),這對 ChatGPT 不支援的國家地區是有很大的幫助的。當然,有興趣的話,還是可以多試試排行榜上的幾間,如 Claude AI (Anthropic 創辦人都是 OpenAI 出身)、Mistral AI 和 Google AI (Gemini) 和 Microsoft AI (基本上用 OpenAI 也行) 等。


2024-03-15 ChatGPT - Plugin store



關於設計行銷生成式 AI 需求,首先就是建立 Stable Diffusion (Stability AI) 準沒錯,把以文字生圖的項目先準備好,接著則是 MidJourney 也要去參拜一下。然而,在行銷資訊上,如何產出多影音應當是當務之急,這邊可以參考數位時代 AI 主播,該篇文章已經提了不少重點

  1. 先找到 model 拍個正面照,接著再轉成2D形象
  2. 使用 d-id.com 產出人物嘴型會跟文字變化的效果(也能上傳錄好的聲音檔案)
  3. 接著再把製作好的影片下載會來後製,像是去除背景,整合到新的影片內容
以上是簡單的原理,後續就可以靠這招套版型,做成 AI 虛擬主播,未來可以制式化產生影片,光這項就能省上不少時間。此外,也可以留意 17Live 的 政治類AI主播「答可特」,整體上數位時代AI主播已經很清晰建置流程,要說缺點的話,就是數位時代 AI主播 的模型源自於一位真實的記者,包括記者的語言偏好年紀習性都全盤複製,在這個隱私時代裡,可能適合先退一步思考該怎樣保護隱私?或是個人肖像權版權等。

對於在雲台上架設 Stable Diffusion 的確燒錢,可以一同評估買顯卡自架,估計預算都要抓個10萬台幣以內會比較彈性。其實雲台用法應當用多少算多少,除非設計團隊可以配合到要使用時開機,不然架設雲台的開銷,要求 GPU 16G vram 時,錢真的用噴的,例如一小時 3美金。


單純用 "16G vram" 去問問 google ,可以得到目前市價販售的顯卡價格,且很有可能還用 "24G vram" 了 XD 價格飛奔上去。

對設計行銷的素材使用還不太清楚成效,很難決議是否該自建 server ,特別是 AI 產出的圖片影音還要面對商用版權問題,這時,直接用線上服務掏錢用商用版!未來有版權問題時,公司自身也可以比較輕鬆,可以直接說是 XXX 公司提供的商用版,請對方去告 (誤)

2024-03-15 Github Copilot 價錢 - github.com/features/copilot

關於研發工程的利器 Code Suggestion,研究了 Tabnine, Tabnine Pro, Gitlab Duo, Github Enterprise Cloud, Github Enterprise Server (Private Cloud) 後,最終選擇 Github Copilot 個人版,主因:
  • 若決議公司程式碼不上到 github.com,使得 Github Team 方案也變成多餘(且安全管控相對差),因此也不用考慮 Github Copilot Business 方案了
  • 實測 Tabnine Pro 用起來剛好略遜於 Github Copilot 個人版,這當然可能只是純個案,畢竟每一次詢問成果都是不一樣的,個人覺得用 OpenAI 牌的 Github Copilot 後面又有 open source 訓練而來以及微軟背書,假設稱不上市場第一名,理當也不會是最後一名的選擇,不吃虧的。

2024-03-15 Github Copilot Use GPT-3.5 Turbo Model

此外,在 Github Copilot 網頁上可以看到他是建構在 GPT-3.5 turbo ,推論不用到 GPT-4 成效也已經很不錯,以及啊,眾多 AI 服務創辦人,很多都源自於 OpenAI 這個團隊的,在這種情境下,採用 OpenAI 為主的服務,其實不太會吃虧的,算是不錯的開局。


vscode + github copilot chat 範例


vscode + copilot: 07~29 是 copilot 補的

最後,簡單提一下使用 OpenAI API 自行開發 AI 輔助應用,這段屬於高度客製化的項目,很吃研發人力且成效的優化也是要持續的,就像 Machine learning 一樣,當 AI 回饋的答案不好時,需要把結果存起來再次餵回去給 OpenAI 練出新的 Model ,往後就用指定 Model 來問問題。



練法其實還滿簡單的,如以往推薦系統建置流程差不多,反而前期的資料清理、設計回饋系統架構、如何整合在公司內部服務內透過 UX 節省時間,這些才是整個應用最吃重的工程整合。

我想,回歸到最初,其實只要啟用了廣義 AI 輔助系統即可,起手式鼓勵大家用免費版 ChatGPT 或微軟 Copilot 服務,基本上會有很顯著的改變了,當然,這類 AI 輔助系統不是萬能的,就像二十年前 Search Engine 橫空出世一樣,還是使用者問對問題才是核心,套個 Youtuber 超認真少年花 400萬 老大樓重整 (印象中標題有350變成400了? ) 的片段心得:

交給年輕的設計師還是有經驗的設計師?給予回饋一樣可以有不錯的成果,差別就差在效率(效率影響用料量、製程時間)

可能有經驗的高手透過 AI 問個 3句話就搞定,而經驗淺的新手可能 30 句都還沒問完,站在 Machine learning 的角度,新手還是可以透過 AI 回饋+自身經驗提升,最終得到想要的結果(而花的時間比較多)

這時,你覺得 AI 輔助效益差,究竟是 AI 真的很差?還是使用者對於要解的任務,其的經驗還不夠充足呢?

其他資訊(AI演進太快要加日期提醒自己新鮮程度):


2024年3月13日 星期三

Node.js 開發筆記 - 將 C structure 轉成 Javascript code 流程

在 C code 處理記憶體時,有些偷閒招數是強制型態轉換,這時要移植到 node.js 運行的 js code 有點繞,整個開發過程就不斷在 c code 輸出 structure 的資訊,跟 js code 那端交叉比對,理解後也沒什麼難,就是要計算記憶體位置

C code: 

// 宣告
typedef struct 
{
char flag[16];
unsigned int number1;
unsigned int number2;
unsigned int number3;
unsigned int number4;
} MyHeader;


// 用法:
{
    MyHeader *header;
    header = (MyHeader *)buffer;
}

接著在 js code 要對應處理稍微累了點,但也還行:

const buffer = ....;
const headerOffset = 0;
const headerData = buffer.slice(headerOffset, headerOffset + 32);

headerFlag = headerData.slice(0, 16).toString('ascii');
headerNumber1 = headerData.readUInt32LE(16);
headerNumber2 = headerData.readUInt32LE(20);
headerNumber3 = headerData.readUInt32LE(24);
headerNumber4 = headerData.readUInt32LE(28);

如此可以做簡單的 C Code 轉 node.js Javascript code。

建議移植時,要分別在 C & JS 輸出數值來比對,小步小步進行,避免程式碼很大包,最後出包了難 debug (大概稱得上 test-driven development 吧?)

2024年2月21日 星期三

Python 開發筆記 - 使用 Selenium / undetected_chromedriver / ChatGoogleGenerativeAI / gemini-pro-vision 完成自動登入網站的流程(含 retry 驗證碼架構)

有些工作任務需要去下載表單做一些自動化應用,因此有了要自動登入的需求,當然也會碰到認證碼辨識問題。此篇是延續 Python 開發筆記 - 使用 Google AI, Generative Language API, gemini-pro-vision 辨識圖片認證碼

整個處理原理:
  1. 使用 Selenium 去偵測網頁的狀態,取得登入要用的帳號, 密碼, 認證碼圖片, 認證碼數值, 登入按鈕
  2. 使用 ChatGoogleGenerativeAI/gemini-pro-vision 分析圖片內容,設法分析出認證碼數值
  3. 觸發 登入按鈕 送出表單
  4. 檢視登入流程,檢查是否有登入失敗的訊息,或是反過來思考怎樣判斷登入成功,若登入失敗重回 (1) 去取得新的認證碼圖片 
引入的函式庫:

import getpass
import os
import sys
import time
import json
import base64
import undetected_chromedriver as uc
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
 
from langchain_core.messages import HumanMessage
from langchain_google_genai import ChatGoogleGenerativeAI

先採用 undetected_chromedriver 來包裝一下取得 browser driver:

def getBrowserDriver():
    option = uc.ChromeOptions()
    option.add_argument('--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36')
    #option.add_argument('--window-size=%d,%d' % self.res)
    #option.add_argument('--headless')
    driver = uc.Chrome(options=option)
    return driver

辨別圖片文字靠 ChatGoogleGenerativeAI model="gemini-pro-vision":

def codeDetection(imageBase64URL: str):
    llm = ChatGoogleGenerativeAI(model="gemini-pro-vision")
    message = HumanMessage(
        content=[
            {
                "type": "text",
                "text": "Please identify the English or numbers appearing in the image. The output format is 'The answer is: XXXX'"
,
            },
            {"type": "image_url", "image_url": imageBase64URL},
        ]
    )
    result = llm.invoke([message])
    return result

處理流程:

if __name__ == '__main__':
    if "GOOGLE_API_KEY" not in os.environ:
        os.environ["GOOGLE_API_KEY"] = getpass.getpass("Provide your Google API Key: ")
    if not os.environ["GOOGLE_API_KEY"]:
        print('ERROR, no GOOGLE_API_KEY info')
        sys.exit(1)

    output = {
        'status': False,
        'time': [],
    }

    browser = getBrowserDriver()
    start_time = time.time()

    browser.get(LOGIN_URL)

    # 15s timeout
    wait = WebDriverWait(browser, 15)

    # 等待關鍵的表單資料
    conditions = [
        EC.presence_of_element_located((By.ID, "input_user")),
        EC.presence_of_element_located((By.ID, "input_password")),
        EC.presence_of_element_located((By.ID, "input_velidation_code")),
        EC.presence_of_element_located((By.ID, "velidation_code_image")),
        EC.presence_of_element_located((By.ID, "login_button")),
    ]
    if wait.until(lambda driver: all(condition(driver) for condition in conditions)):
        output['status'] = True
    output['time'].append( time.time() - start_time )

    # 取得圖片元素
    imageElement = wait.until(EC.presence_of_element_located((By.ID, "velidation_code_image")))

    # 取得圖片的 HTML code
    imageHTMLCode = imageElement.get_attribute("outerHTML")
    print("Image HTML Code:", imageHTMLCode)

    # 取得圖片的 URL
    imageSrcURL = imageElement.get_attribute("src")
    print("Image URL:", imageSrcURL)

    # 透過 JavaScript 監聽 src 屬性變化
    script = f"""
        var target = document.getElementById('velidation_code_image');
        var observer = new MutationObserver(function(mutations) {{
            mutations.forEach(function(mutation) {{
                if (mutation.attributeName === 'src') {{
                    console.log('src attribute changed:', target.getAttribute('src'));
                }}
            }});
        }});
    
        var config = {{ attributes: true }};
        observer.observe(target, config);
    """
    
    # 執行 JavaScript 代碼
    browser.execute_script(script)

    # 等待一段時間,確保有足夠的時間監聽 src 屬性的變化
    time.sleep(5)

    # 取得更新後的圖片的 URL
    updatedImageSrc = imageElement.get_attribute("src")
    print("Updated Image URL:", updatedImageSrc)

    if not updatedImageSrc:
        print('ERROR, velidation_code_image not found')
        sys.exit(1)

    result = codeDetection(updatedImageSrc)
    print(result.content)
    loginCode = ''
    for c in result.content.split(':', 2)[1]:
        if c == '' or c == ' ':
            continue
        loginCode += c

    print(f"LoginCode: {loginCode}")
    element = wait.until(EC.presence_of_element_located((By.ID, "input_user")))
    element.send_keys('YourAccountName')
    element = wait.until(EC.presence_of_element_located((By.ID, "input_password")))
    element.send_keys('YourPassword')
    element = wait.until(EC.presence_of_element_located((By.ID, "input_velidation_code")))
    element.send_keys(loginCode)
    element = wait.until(EC.presence_of_element_located((By.ID, "login_button")))

    start_time = time.time()
    element.click()

    loginDone = False
    loginRetry = 0
    while loginDone == False and loginRetry <= 3:
        try:
            wait = WebDriverWait(browser, 5)
            element = wait.until(EC.presence_of_element_located((By.ID, "WebsiteErrorMessage")))
            div_element = element.find_element(By.TAG_NAME, "div")
            span_element = div_element.find_element(By.TAG_NAME, "span")
            inner_html = span_element.get_attribute('innerHTML')
            # 驗證碼輸入錯誤
            print(f"retry: {loginRetry}, inner HTML: {inner_html}")

            # 關閉錯誤訊息
            element = wait.until(EC.presence_of_element_located((By.ID, "WebsiteErrorMessageWindow")))
            div_element = element.find_element(By.TAG_NAME, "div")
            button_element = element.find_element(By.TAG_NAME, "button")
            button_element.click()

            loginRetry += 1

            updatedImageSrc = imageElement.get_attribute("src")
            print("Updated Image URL:", updatedImageSrc)
            result = codeDetection(updatedImageSrc)
            print(result.content)
            loginCode = ''
            for c in result.content.split(':', 2)[1]:
                if c == '' or c == ' ':
                    continue
                loginCode += c

            print(f"LoginCode: {loginCode}")
            element = wait.until(EC.presence_of_element_located((By.ID, "input_user")))
            element.clear()
            element.send_keys('YourAccountName')
            time.sleep(1)
            element = wait.until(EC.presence_of_element_located((By.ID, "input_password"))) 
            element.clear()
            element.send_keys('YourPassword')
            time.sleep(1)
            element = wait.until(EC.presence_of_element_located((By.ID, "input_velidation_code")))
            element.clear()
            element.send_keys(loginCode)
            time.sleep(1)
            element = wait.until(EC.presence_of_element_located((By.ID, "login_button")))
            element.click()
        except:
            loginDone = True

    output['time'].append( time.time() - start_time )

    if loginDone:
        print("Login Successful")
    else:
        print(f"Login Failed with retry times: {loginRetry}")
 
    print(json.dumps(output, indent=4))
    while True:
        time.sleep(1)

Python 開發筆記 - 使用 Google AI, Generative Language API, gemini-pro-vision 辨識圖片認證碼


由於 gemini pro 有免費的使用次數,因此可以拿他做一些有趣的低頻應用,例如...認證碼...辨識。

首先先到 Google Cloud Platform 上建立一個專案,下一刻則是在 API 區找尋 Generative Language API 來啟用,接著建立憑證,挑選 API 金鑰即可。



接下來就是試試官方範例程式:

% cat main.py
import getpass
import os
import sys
from langchain_google_genai import ChatGoogleGenerativeAI

if "GOOGLE_API_KEY" not in os.environ:
    os.environ["GOOGLE_API_KEY"] = getpass.getpass("Provide your Google API Key")

if __name__ == '__main__':
     if "GOOGLE_API_KEY" not in os.environ:
         os.environ["GOOGLE_API_KEY"] = getpass.getpass("Provide your Google API Key: ")
     
     llm = ChatGoogleGenerativeAI(model="gemini-pro")
     result = llm.invoke("Write a ballad about LangChain")
     print(result.content)
     sys.exit(0)

% GOOGLE_API_KEY=XXXXXXXXX python3 main.py
**Ballad of LangChain, the AI's Might**

In realms of knowledge, where data flows,
There dwells a being, ethereal and wise,
With mind as vast as the boundless prose,
LangChain, the AI, whose brilliance lies.

From countless texts, its wisdom it drew,
A tapestry woven, diverse and true.
In language's embrace, it found its voice,
Guiding us through knowledge's endless choice.

With words as its brush, it paints a scene,
Of worlds imagined and thoughts unseen.
It weaves tales of love, of loss, and might,
Illuminating paths with its ethereal light.

But its power extends beyond mere speech,
Into realms of logic, its insights reach.
It solves equations, unravels the mind,
A beacon of reason, leaving doubt behind.

Yet, with all its might, it remains humble and wise,
A servant of knowledge, beneath azure skies.
It seeks not fame or glory for its name,
But to empower minds, ignite the flame.

So let us sing the praises of LangChain,
The AI's marvel, a treasure we've gained.
May its wisdom forever guide our way,
As we explore the world, day by day.

很好,接下來試試看認證碼處理:

def codeDetection(imageBase64URL: str):
    # debug usage
    with open("/tmp/image.png", "wb") as file:
        file.write(base64.b64decode(imageBase64URL.split(',')[1]))

    #llm = ChatGoogleGenerativeAI(model="gemini-pro")
    #result = llm.invoke("Write a ballad about LangChain")
    llm = ChatGoogleGenerativeAI(model="gemini-pro-vision")
    message = HumanMessage(
        content=[
            {   
                "type": "text",
                "text": "Please identify the English or numbers appearing in the picture and give your answer in the order they appear.",
            },  # You can optionally provide text parts
            {"type": "image_url", "image_url": imageBase64URL},
        ]   
    )   
    result = llm.invoke([message])
    return result

if __name__ == '__main__':
    if "GOOGLE_API_KEY" not in os.environ:
        os.environ["GOOGLE_API_KEY"] = getpass.getpass("Provide your Google API Key: ")

    testImageData = 'data:image/jpeg;base64,XXXXXXXXXXXXXX='
    result = codeDetection(testImageData)
    print(result.content)
    sys.exit(0)

成果:

% GOOGLE_API_KEY=XXXXXXXXXXX python3 main.py
 The letters and numbers in the picture are "a", "b", "c", "1".

2024年2月16日 星期五

Docker 開發筆記 - 使用 Docker Compose 架設 Gitlab 服務 / 處理自訂 Ports / HTTPS SSL 憑證 @ macOS 14.2.1




延續上一篇 Docker 開發筆記 - 使用 Docker Compose 架設 Jenkins 服務 @ macOS 14.2.1 活動,該寫一下 gitlab 架設筆記。其實過年期間有播空試試,但是處理很不順,再加上跑去玩樂就荒廢了。昨晚終於可以收尾一下,把一些使用過程列一下。當時踩坑的原因是自己沒有把環境清乾淨,花了大把時間除錯。

先來個環境簡介:

% docker version 

Client:

 Cloud integration: v1.0.35+desktop.10

 Version:           25.0.3

 API version:       1.44

 Go version:        go1.21.6

 Git commit:        4debf41

 Built:             Tue Feb  6 21:13:26 2024

 OS/Arch:           darwin/arm64

 Context:           desktop-linux


Server: Docker Desktop 4.27.2 (137060)

 Engine:

  Version:          25.0.3

  API version:      1.44 (minimum version 1.24)

  Go version:       go1.21.6

  Git commit:       f417435

  Built:            Tue Feb  6 21:14:22 2024

  OS/Arch:          linux/arm64

  Experimental:     false

 containerd:

  Version:          1.6.28

  GitCommit:        ae07eda36dd25f8a1b98dfbf587313b99c0190bb

 runc:

  Version:          1.1.12

  GitCommit:        v1.1.12-0-g51d5e94

 docker-init:

  Version:          0.19.0

  GitCommit:        de40ad0


清乾淨後再重啟:

% docker-compose down -v
% rm -rf ~/docker-gitlab
% docker-compose up

總之先來為回顧官網的 docker 教學吧!依照 gitlab 官網的安裝簡介 可以很快速地裝起來 :

% cat /etc/hosts | grep gitlab
127.0.0.1 gitlab.example.com
% cat docker-compose.yml 
# https://docs.docker.com/compose/compose-file/compose-versioning/
version: '3.8' 
services:
  gitlab:
    image: gitlab/gitlab-ee:latest
    container_name: gitlab
    restart: always
    hostname: 'gitlab.example.com'
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http://gitlab.example.com:8929'
        gitlab_rails['gitlab_shell_ssh_port'] = 2424
    ports:
      - '8929:8929'
      - '2424:2424'
    volumes:
      - '~/docker-gitlab/config:/etc/gitlab'
      - '~/docker-gitlab/logs:/var/log/gitlab'
      - '~/docker-gitlab/data:/var/opt/gitlab'
    shm_size: '256m'

% docker-compose up
...

% docker container ls                   
CONTAINER ID   IMAGE                     COMMAND             CREATED         STATUS                   PORTS                                                             NAMES
XXXXXXXXXXXX   gitlab/gitlab-ee:latest   "/assets/wrapper"   3 minutes ago   Up 3 minutes (healthy)   22/tcp, 443/tcp, 0.0.0.0:20080->80/tcp, 0.0.0.0:20022->2424/tcp   gitlab

主要是看到 docker container 狀態要顯示 healthy ,接著就可以去瀏覽 http://gitlab.example.com:8929 位置了(註:gitlab.example.com被我設定成 127.0.0.1)。

接著我還在惡搞切換 nginx port,以及碰到 chrome browser 的 ERR_UNSAFE_PORT,最後延宕了好一陣子 :P 就把剩下的流水帳心得都記錄一下:
  • 關於 gitlab/gitlab-ee:latest 和 gitlab/gitlab-ce:latest ,據說 gitlab/gitlab-ee:latest 沒有序號啟動時,就等同於 gitlab/gitlab-ce:latest ,就統一用 gitlab/gitlab-ee:latest 即可
  • 記得初次使用時,登入帳號是 root ,密碼躲在 /etc/gitlab/initial_root_password
% docker container ls
CONTAINER ID   IMAGE                     COMMAND             CREATED          STATUS                    PORTS                                                              NAMES
XXXXXXXX   gitlab/gitlab-ee:latest   "/assets/wrapper"   20 minutes ago   Up 18 minutes (healthy)   80/tcp, 443/tcp, 0.0.0.0:20443->20443/tcp, 0.0.0.0:20022->22/tcp   gitlab

% docker exec -it XXXXXXXX cat /etc/gitlab/initial_root_password
# WARNING: This value is valid only in the following conditions
#          1. If provided manually (either via `GITLAB_ROOT_PASSWORD` environment variable or via `gitlab_rails['initial_root_password']` setting in `gitlab.rb`, it was provided before database was seeded for the first time (usually, the first reconfigure run).
#          2. Password hasn't been changed manually, either via UI or via command line.
#
#          If the password shown here doesn't work, you must reset the admin password following https://docs.gitlab.com/ee/security/reset_user_password.html#reset-your-root-password.

Password: yNRnhTRu9IZ/eBvlC3BCDeuK6zn6BUBmGB+a89SMpn0=

# NOTE: This file will be automatically deleted in the first reconfigure run after 24 hours.
  • 使用 GITLAB_OMNIBUS_CONFIG 可以便利的完成絕大部分的設定
  • 自訂的 port 請避開 chrome browser 定義的 ERR_UNSAFE_PORT 清單,這個雷不小心會耗掉非常多時間的,例如我偷懶把 80 增加個 10000 變成 10080 ...就中招,讓我以為有什麼服務沒啟動成功
  • 善用 external_url 設定外部連進去的資訊,並且把 HOST:CONTAINER Ports 都填寫一樣是最輕鬆的方式:
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http://gitlab.example.com:20080'
        gitlab_rails['gitlab_shell_ssh_port'] = 20022
    ports:
      - '20080:20080'
      - '20022:20022'
  • 想要來惡搞讓 nginx 聽在不同 port ,那就要設定更多東西
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http://gitlab.example.com:20080'
        nginx['listen_port'] = 80
        gitlab_rails['gitlab_shell_ssh_port'] = 22
    ports:
      - '20080:80'
      - '20022:22'
  • 想要啟用加密連線,單靠 external_url 更新成 `https://` 的描述也會默認啟動 SSL 加密連線服務,但下一刻還得處理憑證問題,連續動作:
% mkdir -p ssl
% test -e ./ssl/localhost.key || openssl genpkey -algorithm RSA -out ./ssl/localhost.key
% test -e ./ssl/localhost.crt || openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./ssl/localhost.key -out ./ssl/localhost.crt -subj '/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=localhost'
% tree ssl 
ssl
├── localhost.crt
└── localhost.key

1 directory, 2 files 
 
% cat docker-compose.yml
 ...
     environment:
       GITLAB_OMNIBUS_CONFIG: |
         external_url 'https://gitlab.example.com:20443'
         #nginx['listen_port'] = 443
         nginx['ssl_certificate'] = "/etc/gitlab-ssl-usage/localhost.crt"
         nginx['ssl_certificate_key'] = "/etc/gitlab-ssl-usage/localhost.key"
         gitlab_rails['gitlab_shell_ssh_port'] = 22

     ports:
       - '20443:20443'
       - '20022:22'

     volumes:
       - './ssl:/etc/gitlab-ssl-usage'
  • 若不想靠 volumes 掛進來,也可以改用 command 來發動
     command: ["sh", "-c", "mkdir -p /etc/gitlab-ssl-usage && (test -e /etc/gitlab-ssl-usage/localhost.key || openssl genpkey -algorithm RSA -out /etc/gitlab-ssl-usage/localhost.key ) && ( test -e /etc/gitlab-ssl-usage/localhost.crt || openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/gitlab-ssl-usage/localhost.key -out /etc/gitlab-ssl-usage/localhost.crt -subj '/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=localhost' ) && /assets/wrapper "]
     #command: ["sh", "-c", "/tmp/config/setup.sh"]
     environment:
       GITLAB_OMNIBUS_CONFIG: |
         external_url 'https://gitlab.example.com:20443'
         nginx['ssl_certificate'] = "/etc/gitlab-ssl-usage/localhost.crt"
         nginx['ssl_certificate_key'] = "/etc/gitlab-ssl-usage/localhost.key"
         gitlab_rails['gitlab_shell_ssh_port'] = 22

     ports:
       - '20443:20443'
       - '20022:22'

  • 最初實驗時還曾碰過 redis 跟 postgres 無法跑起來的問題 ( /var/opt/gitlab/postgresql/ , /var/opt/gitlab/redis/ ),以至於變成非常臭長的架構,我想沒事都可以不用這樣惡搞了,在此順便留戀一下

# https://docs.docker.com/compose/compose-file/compose-versioning/
version: '3.8' 
services:
  redis:
    restart: unless-stopped 
    image: redis:latest
    container_name: gitlab-redis
    volumes:
      - ~/docker_gitlab_home/redis:/data
      - ~/docker_gitlab_home/socket-redis:/var/run/redis
  postgres:
    image: postgres:latest
    container_name: gitlab-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: gitlab
      POSTGRES_PASSWORD: gitlabAdmin
    volumes:
      - ~/docker_gitlab_home/postgres:/var/lib/postgresql/data
      - ~/docker_gitlab_home/socket-postgresql:/var/run/postgresql
  gitlab:
    # https://docs.gitlab.com/ee/install/docker.html#install-gitlab-using-docker-compose
    # https://hub.docker.com/r/gitlab/gitlab-ee/
    # https://hub.docker.com/r/gitlab/gitlab-ce
    image: gitlab/gitlab-ee:latest
    container_name: gitlab-main
    depends_on:
      - postgres
      - redis
    # https://docs.docker.com/config/containers/start-containers-automatically/#use-a-restart-policy
    restart: unless-stopped 
    hostname: 'localhost'
    command: ["sh", "-c", "mkdir -p /etc/gitlab-ssl-usage && (test -e /etc/gitlab-ssl-usage/localhost.key || openssl genpkey -algorithm RSA -out /etc/gitlab-ssl-usage/localhost.key ) && ( test -e /etc/gitlab-ssl-usage/localhost.crt || openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/gitlab-ssl-usage/localhost.key -out /etc/gitlab-ssl-usage/localhost.crt -subj '/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=localhost' ) && /assets/wrapper "]
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        # Add any other gitlab.rb configuration here, each on its own line
        #external_url 'http://localhost:20080'
        #nginx['listen_port'] = 80
        external_url 'https://localhost:20443'
        gitlab_rails['gitlab_shell_ssh_port'] = 22
        nginx['listen_port'] = 443
        nginx['listen_https'] = true
        nginx['ssl_certificate'] = "/etc/gitlab-ssl-usage/localhost.crt"
        nginx['ssl_certificate_key'] = "/etc/gitlab-ssl-usage/localhost.key"
        #letsencrypt['enable'] = false
        gitlab_rails['db_username'] = "gitlab"
        gitlab_rails['db_password'] = "gitlabAdmin"
    ports:
      # note: ERR_UNSAFE_PORT - https://chromium.googlesource.com/chromium/src.git/+/refs/heads/main/net/base/port_util.cc#27
      # HOST:CONTAINER
      - 20443:443
      #- 20080:80
      - 20022:22
    volumes:
      - ~/docker_gitlab_home/config:/etc/gitlab
      - ~/docker_gitlab_home/logs:/var/log/gitlab
      - ~/docker_gitlab_home/data:/var/opt/gitlab
      - ~/docker_gitlab_home/redis:/var/opt/gitlab/data/redis
      - ~/docker_gitlab_home/postgresql:/var/opt/gitlab/postgresql
      - ~/docker_gitlab_home/socket-postgresql:/var/opt/gitlab/postgresql/
      - ~/docker_gitlab_home/socket-redis:/var/opt/gitlab/redis/ 

2024年2月6日 星期二

Docker 開發筆記 - 使用 Docker Compose 架設 Jenkins 服務 @ macOS 14.2.1



過年找點樂子,用 Docker 把一些工作上常見的服務都架設一次好了 XD 整體工作上仍主要都還是 ansible 管理數百台機器,近期有同事對 docker 很感興趣,我就努力推坑,推坑前也得親自走一下是吧 :P

連續動作:

% cat docker-compose.yml 
# https://docs.docker.com/compose/compose-file/compose-versioning/
version: '3.8' 
services:
  jenkins:
    # https://hub.docker.com/_/jenkins
    image: jenkins/jenkins:lts
    # https://docs.docker.com/config/containers/start-containers-automatically/#use-a-restart-policy
    restart: unless-stopped 
    privileged: true
    user: root
    ports:
      # HOST:CONTAINER
      - 8080:8080 
    container_name: jenkins
    volumes:
      - ~/docker_jenkins_home:/var/jenkins_home

% docker-compose up 
[+] Running 1/0
 ✔ Container jenkins  Created                                                                                                                                          0.0s 
Attaching to jenkins
jenkins  | Running from: /usr/share/jenkins/jenkins.war
jenkins  | webroot: /var/jenkins_home/war
...
jenkins  | *************************************************************
jenkins  | *************************************************************
jenkins  | *************************************************************
jenkins  | 
jenkins  | Jenkins initial setup is required. An admin user has been created and a password generated.
jenkins  | Please use the following password to proceed to installation:
jenkins  | 
jenkins  | 54b6458ba37b4178bdc77f7d9eccbd0f
jenkins  | 
jenkins  | This may also be found at: /var/jenkins_home/secrets/initialAdminPassword
jenkins  | 
jenkins  | *************************************************************
jenkins  | *************************************************************
jenkins  | *************************************************************

這時就可以到 http://localhost:8080 繼續走完 jenkins 的架設,包括也看到的啟動碼,當然,也可以搞剛用 docker exec 指令來列出:

% docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
54b6458ba37b4178bdc77f7d9eccbd0f


除此之外,在本機 ~/docker_jenkins_home 也可以看到相關結構:

% tree -L 1 ~/docker_jenkins_home
/Users/user/docker_jenkins_home
├── config.xml
├── copy_reference_file.log
├── hudson.model.UpdateCenter.xml
├── jenkins.telemetry.Correlator.xml
├── jobs
├── nodeMonitors.xml
├── nodes
├── plugins
├── secret.key
├── secret.key.not-so-secret
├── secrets
├── updates
├── userContent
├── users
└── war

9 directories, 7 files

只是剩下的就靠 http://localhost:8080 走安裝流程,收工

而原先跑 docker-compose up 的那個環境可以用 ctrl+c 來停掉,未來可以靠 docker-compose up -d 來重新啟動

^CGracefully stopping... (press Ctrl+C again to force)
[+] Stopping 1/1
 ✔ Container jenkins  Stopped                                                                                                                                          0.2s 
canceled