# TST Workflow Script — Tài liệu viết Script Import (cho AI)

> Mục đích: tài liệu này để AI (hoặc người) dựa vào mà **viết một Workflow Script JSON** đúng format,
> import được vào TSTProfileManager qua chức năng **Import** ở trang Automation.
> Mọi thông tin dưới đây đã verify trực tiếp từ source trong `TSTAutomation.Core/`.

---

## 0. Quy tắc vàng (đọc trước khi viết)

1. **Script = 1 file JSON** (Newtonsoft.Json), KHÔNG phải DSL. Root là object `Workflow`.
2. File hợp lệ tối thiểu phải có `"Steps"` và/hoặc `"Functions"`. Thiếu cả hai → bị từ chối.
3. **KHÔNG** đặt `"Kind": "TstFunction"` ở root (đó là format export 1 function riêng, sẽ bị từ chối khi import workflow).
4. **Key trong `Params` là TÊN PROPERTY C#** (vd `Url`, `Selector`, `SelectorType`, `Text`, `Output`),
   KHÔNG phải label hiển thị trên UI. Match **không phân biệt hoa thường**.
5. Param nào không khai báo → dùng default của action. Cứ bỏ trống cho gọn.
6. Mọi giá trị string trong param kiểu *Expression* đều resolve được biến qua `{{...}}`.
7. Action rẽ nhánh (`if`, `try_catch`) dùng field **`Branches`** (dict). Action chứa vòng lặp
   (`for_each`, `while`, `repeat`) dùng field **`Body`** (list phẳng).
8. Serializer bỏ qua null & default value khi export → file export thường "thưa". Khi viết tay
   bạn được phép ghi đầy đủ.

---

## 1. Cấu trúc Workflow (root)

```json
{
  "Name": "Tên workflow",
  "Version": "1.0.0",
  "Author": "",
  "Category": "",
  "Inputs": [ /* WorkflowInput[] — tham số campaign */ ],
  "Functions": [ /* WorkflowFunction[] — hàm tái sử dụng (tùy chọn) */ ],
  "Steps": [ /* WorkflowStep[] — luồng chính */ ]
}
```

| Field | Bắt buộc | Ghi chú |
|---|---|---|
| `Name` | không | default `"Untitled"` |
| `Version` | không | chuỗi, default `"1.0.0"` |
| `Author`, `Category` | không | để trống được |
| `Inputs` | không | tham số người dùng nhập khi tạo Campaign |
| `Functions` | không* | hàm; *phải có `Steps` hoặc `Functions` |
| `Steps` | không* | luồng chính |

> KHÔNG tự set `IsLocked` / `EncryptedBody` — đó là cơ chế cho workflow mua từ Store.

---

## 2. WorkflowStep — 1 bước

```json
{
  "Id": "tùy chọn, tự sinh nếu thiếu",
  "Type": "browser_navigate",
  "Params": { "Url": "https://...", "WaitForLoad": "complete" },
  "Body": [],
  "Branches": null,
  "Disabled": false,
  "Comment": null,
  "DisplayName": null
}
```

- `Type` (**bắt buộc**): mã định danh action (xem catalog mục 6).
- `Params`: dict, key = tên property C#.
- `Body`: list step con — chỉ dùng cho action container (loop).
- `Branches`: dict `{ "tên-nhánh": [step...] }` — chỉ dùng cho action rẽ nhánh.
- `Disabled`, `Comment`, `DisplayName`: tùy chọn (DisplayName = đổi tên hiển thị trên cây).

---

## 3. Inputs — tham số đầu vào (vd `keyword`)

Khai báo trong `Inputs`, tham chiếu trong step bằng `{{inputs.<Name>}}`.

```json
{
  "Name": "keyword",
  "Label": "Từ khóa",
  "Description": "Mô tả hiển thị dưới field",
  "Kind": "Text",
  "Required": true,
  "DefaultValue": "",
  "Options": null,
  "FileFilter": null
}
```

- `Name`: **phải là identifier hợp lệ** (bắt đầu bằng chữ/`_`) — vì dùng trong `{{inputs.Name}}`.
- `Kind` (string enum): `Text`, `MultilineText`, `Number`, `Bool`, `Select`, `MultiSelect`, `File`.
- `Options`: list lựa chọn — chỉ cho `Select` / `MultiSelect`.
- `FileFilter`: pattern ngăn cách bằng `;` (vd `*.txt;*.csv`) — chỉ cho `File`.
- Giá trị `MultiSelect` resolve ra **List<string>**; `File` resolve ra đường dẫn tuyệt đối.

---

## 4. Biến & resolver `{{...}}`

Chỉ param **Expression** / **MultilineString** hỗ trợ placeholder `{{...}}`.

> ⚠️ **BẪY QUAN TRỌNG**: param kiểu **Number** / **Bool** / **Dropdown** **KHÔNG** resolve `{{...}}`.
> Giá trị được bind thẳng vào property `int`/`bool`/`string` → truyền `"{{px}}"` vào một Number
> param sẽ lỗi `The input string '{{px}}' was not in a correct format`. Number params (`TimeoutMs`,
> `CharDelayMs`, `Min`, `Max`, `Milliseconds`, `Index`…) phải là **số literal**
> (`"700"` hoặc `700`). Muốn dùng giá trị động ở chỗ đòi số → đành tính sẵn bằng literal, hoặc
> chọn action có param tương ứng kiểu Expression.
>
> **Ngoại lệ (nhận `{{var}}`)**:
> - `repeat` → `Times` kiểu **Expression**: `{"Times": "{{n}}"}` (resolve + parse lúc chạy; sai/rỗng → 0 vòng).
> - `browser_scroll_down` / `browser_scroll_up` → `Pixels` kiểu **Expression**: `{"Pixels": "{{px}}"}`.
>   Các param scroll khác (`MinStepPx`, `MaxStepPx`, `MinDelayMs`, `MaxDelayMs`, `OvershootPercent`)
>   vẫn là Number literal.
>
> Param **Expression** (nhận `{{var}}`): `Selector`, `Text`, `Url`, `Value`, `Source`, `Message`,
> `Left`/`Right` của `if`/`while`, `List`, `FilePath`, `Path`… và **MultilineString**: `Script`,
> `Arguments`, `Headers`, `Cookies`.

| Placeholder | Ý nghĩa |
|---|---|
| `{{inputs.<Name>}}` | giá trị input của campaign |
| `{{profile.*}}`, `{{account.*}}`, `{{campaign.*}}` | object profile/account/campaign đang chạy |
| `{{profile.Config.Browser}}` | loại trình duyệt của profile: `"firefox"` / `"chrome"` (lowercase) — branch bằng `if` khi ExecuteJS khác nhau giữa 2 loại |
| `{{profile_dir}}` | thư mục data của profile trên đĩa |
| `{{globals.*}}` | dict chia sẻ (ghi bằng `set_global`) |
| `{{last_error}}` | thông điệp lỗi khi 1 action "mềm" fail (type/click/selector) mà workflow vẫn chạy tiếp |
| `{{<var>}}` | biến do `set_variable` / param `Output` của action tạo ra |

**Cú pháp path / index:**
- `{{obj.prop}}` — truy cập thuộc tính
- `{{list[0]}}`, `{{list[-1]}}` — index (âm = đếm từ cuối, kiểu Python)
- `{{list[i]}}` — index theo biến `i`
- Nếu cả chuỗi chỉ là **một** `{{x}}` → trả về **object gốc** (giữ nguyên list/dict/number).
  Nếu lẫn text → stringify.

---

## 5. So sánh (dùng trong `if` / `while`)

Operator hợp lệ (field `Operator`):
`==`, `!=`, `>`, `>=`, `<`, `<=`, `contains`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`.

- `==`/`!=`/`contains`/`starts_with`/`ends_with`: so sánh **chuỗi**, phân biệt hoa thường (Ordinal).
- `>`,`>=`,`<`,`<=`: parse số (double, InvariantCulture); parse fail → trả `false`.
- `is_empty`/`is_not_empty`: chỉ cần `Left` (bỏ `Right`).

> ⚠️ **BẪY bool viết hoa**: action trả bool (vd `browser_check_selector`, `list_op` mode `contains`)
> lưu **bool C#**; khi resolve thành chuỗi nó là **`"True"` / `"False"`** (PascalCase, do
> `bool.ToString()` của .NET). Vì `==` so Ordinal (phân biệt hoa thường), phải so với **`"True"`**:
> ```json
> { "Type": "if", "Params": { "Left": "{{hasBox}}", "Operator": "==", "Right": "True" } }
> ```
> `"{{hasBox}}" == "true"` → luôn **False** (bug thầm lặng). Ngược lại, biến do `set_variable`
> tạo (`Value:"false"`) là chuỗi thường → so với `"false"` mới đúng.

---

## 6. Catalog Action đầy đủ

> `Type` → params. Key JSON = **tên property C#**. `*` = bắt buộc. `SelectorType` mặc định `css`.
> Param kiểu Expression nhận `{{var}}`.

### 6.1 Browser

| Type | Params (default / [options]) |
|---|---|
| `browser_navigate` | `Url`*, `As` [typed, bookmark, link]=bookmark, `WaitForLoad` [complete, interactive, none]=complete, `TimeoutMs`=30000 |
| `browser_click` | `Selector`*, `SelectorType` [css, xpath], `Button` [left, right], `ClickCount`=1 |
| `browser_type` | `Text`*, `Selector` (trống→activeElement), `SelectorType`, `Append`=false, `Instant`=false, `CharDelayMs`=0, `TypoRate`=0, `PauseRate`=0, `Output` (nhận `{typed,typos}`) |
| `browser_paste` | `Text`*, `Selector`, `SelectorType` |
| `browser_clear` | `Selector`*, `SelectorType` |
| `browser_get_text` | `Selector`*, `SelectorType`, `Output`* |
| `browser_get_value` | `Selector`*, `SelectorType`, `Output`* |
| `browser_get_attribute` | `Selector`*, `SelectorType`, `Name`*, `Output`* |
| `browser_get_bounding_box` | `Selector`*, `SelectorType`, `Output`* |
| `browser_check_selector` | `Selector`*, `SelectorType`, `Mode` [exists, visible]=exists, `Output`* (bool) |
| `browser_wait_selector` | `Selector`*, `SelectorType`, `TimeoutMs` |
| `browser_wait_page_loaded` | `ReadyState` [complete, interactive], `TimeoutMs`, `PollMs` |
| `browser_scroll_down` / `browser_scroll_up` | `Pixels`* (Expression, nhận `{{var}}`), `Smooth`, `MinStepPx`, `MaxStepPx`, `MinDelayMs`, `MaxDelayMs`, `OvershootPercent` |
| `browser_scroll_to_element` | `Selector`*, `SelectorType`, `Block` [center, start, end, nearest], `Smooth` |
| `browser_mouse_scroll` | `DeltaY`*, `DeltaX`, `Selector`, `SelectorType`, `X`, `Y`, `Steps`, `TickSize`, `StepMs`, `MoveSteps`, `MoveMs`, `TabId` |
| `browser_mouse_move_element` | `Selector`*, `SelectorType`, `MoveSteps`, `MoveMs` |
| `browser_mouse_move_position` | `X`*, `Y`*, `MoveSteps`, `MoveMs` |
| `browser_mouse_click_position` | `UseCurrent`, `X`, `Y`, `Button` [left, right], `ClickCount`, `MoveSteps`, `MoveMs` |
| `browser_human_idle` | `DurationSec`*, `MinIntervalMs`, `MaxIntervalMs`, `EnableMove`, `EnableScroll`, `EnableClick` |
| `browser_evaluate` | `Script`* (JS, hỗ trợ `return`), `Output` |
| `browser_screenshot` | `Path`* |
| `browser_current_url` | `Output`* |
| `browser_set_cookie` | `Cookies`* (multiline), `DefaultExpiryDays` |
| `browser_get_cookie` | `Url`, `Output`* |
| `browser_clear_cookie` | `Url` |
| `browser_upload_file` | `Selector`*, `SelectorType`, `FilePath`*, `TimeoutMs` |
| `browser_drag_drop_file` | `Selector`*, `SelectorType`, `FilePath`*, `MimeType`, `HoldMs` |
| `browser_switch_to_frame` | `Selector` (css), `Index`, `Output` |
| `browser_switch_to_parent_frame` | `Output` |
| `browser_switch_to_top_frame` | `Output` |

**Lưu ý browser:**
- `browser_type` và `browser_click` khi fail **không** giết workflow — chỉ log Warn + set `{{last_error}}`.
- `browser_evaluate`: script là expression thường được auto-`return`; nếu viết nhiều câu lệnh hãy
  thêm `return` tường minh để lấy giá trị về `Output`.

### 6.2 Data

| Type | Params |
|---|---|
| `extract_text` | `Source`*, `Pattern`* (regex), `Group`, `IgnoreCase`, `Multiline`, `AllMatches`, `Output`* |
| `split_text` | `Source`*, `Delimiter`*, `Trim`, `RemoveEmpty`, `Limit`, `Index`, `Output`* |
| `string_transform` | `Source`*, `Mode`, `Find`, `Replacement`, `IgnoreCase`, `Start`, `Length`, `PadChar`, `Output`* |
| `json_path` | `Source`*, `Path`*, `Output`* |
| `to_json` | `Source`*, `Pretty`, `AsString`, `Output`* |
| `list_op` | `ListVar`*, `Mode`, `Value`, `Index`, `Delimiter`, `Descending`, `Numeric`, `Output` |
| `math` | `Left`*, `Operator` [+, -, *, /, %, ^, min, max], `Right`*, `Output`* |
| `random_number` | `Min`*, `Max`*, `Decimal`, `DecimalPlaces`, `Output`* |
| `random_text` | `Length`*, `Charset` [alphanumeric, alpha, lower, upper, digits, hex, custom], `CustomChars`, `Output`* |
| `get_machine_info` | `Output`* |

**`string_transform.Mode`** (option): `replace`, `replace_regex`, `lowercase`, `uppercase`, `trim`,
`trim_start`, `trim_end`, `substring`, `length`, `pad_left`, `pad_right`, `reverse`, `repeat`.
- `replace`/`replace_regex`: dùng `Find` (+ `Replacement`, `IgnoreCase`). regex hỗ trợ `$1,$2`.
- `substring`: `Start` (âm = từ cuối), `Length` (-1 = đến hết).
- `pad_left`/`pad_right`: `Length` = tổng độ rộng, `PadChar` = ký tự đệm.
- `repeat`: `Length` = số lần.

**`list_op.Mode`** (option): `add`, `insert`, `remove_at`, `remove_value`, `set`, `get`, `count`,
`contains`, `index_of`, `clear`, `shuffle`, `sort`, `reverse`, `unique`, `join`.
- Op truy vấn (`get`,`count`,`contains`,`index_of`,`join`) **bắt buộc** `Output`.
- `Index` âm = đếm từ cuối (`-1` = phần tử cuối).
- `join.Delimiter` hỗ trợ escape `\t \n \r \\`.
- `sort`: `Numeric` = sort số, `Descending` = giảm dần.

### 6.3 Control (điều khiển luồng)

| Type | Params / cấu trúc |
|---|---|
| `set_variable` | `Name`*, `Value` |
| `set_global` | `Name`*, `Value` |
| `log` | `Level` [Debug, Info, Warn, Error], `Message`* |
| `sleep` | `Milliseconds`*, `MaxMilliseconds` (có → random trong khoảng) |
| `if` | `Left`*, `Operator`, `Right` — **`Branches`**: `then` / `else` |
| `while` | `Left`*, `Operator`, `Right`, `IndexName`, `MaxIterations` — **`Body`** |
| `repeat` | `Times`* (Expression, nhận `{{var}}`), `Start` (Expression, mặc định `0`), `IndexName` — **`Body`** |
| `for_each` | `List`*, `ItemName`*, `IndexName` — **`Body`** |
| `break` | (không param) |
| `continue` | (không param) |
| `try_catch` | **`Branches`**: `try` / `catch` / `finally` |
| `label` | `Name`* |
| `goto` | `Label`* |
| `return` | `Status` [success, failed], `Value` |
| `call_function` | `FunctionName`*, `Arguments` (multiline `key=value`), `Output` |
| `stopwatch` | `Name`*, `Operation` [start, stop, read, reset], `Output` |
| `set_profile_status` | `Status`*, `StatusText` |
| `set_account_status` | `Status` [active, locked, expired, unknown, verify, disabled], `StatusText` |
| `set_proxy` | `Mode` [fields, raw]*, `Raw`, `Type` [http, socks5, none]*, `Host`, `Port`, `User`, `Pass` |
| `add_account` | `Username`*, `Password`, `RecoveryEmail`, `TwoFa`, `Profile`, `Note`, `LotId`, `AccountType`, `AccountDetails`, `Status`, `StatusText`, `Cookies`, `Output`, `AssignToContext` |
| `remove_account` | `Mode` [current, username, id]*, `Username`, `Id`, `Output` |

**`try_catch`** (đã verify): `Continue` / `Break` / `Return` / `Goto` / `Cancel` **KHÔNG** bị catch —
chúng bay xuyên qua sau khi nhánh `finally` chạy xong. Lỗi thường được đặt vào `{{last_error}}`.

### 6.4 File

| Type | Params |
|---|---|
| `read_file` | `Path`*, `Mode` [text, lines, random_line, first_line, last_line], `Encoding` [utf-8, utf-16, ascii, latin1], `TrimEnd`, `SkipEmpty`, `ConsumeLine`, `Output`* |
| `write_file` | `Path`*, `Content`, `Mode` [overwrite, append, append_line], `Encoding` [utf-8, utf-8-bom, utf-16, ascii, latin1], `CreateDirectory` |
| `delete_path` | `Path`*, `Recursive`, `IgnoreMissing` |
| `list_folders` | `Path`*, `Pattern`, `Recursive`, `Format` [full_path, name, relative], `IgnoreMissing`, `Output`* |
| `list_files` | `Path`*, `Pattern`, `Recursive`, `Format` [full_path, name, relative], `IgnoreMissing`, `Output`* |
| `excel_read` | `Path`*, `Sheet`, `Mode` [cell, row, column, range], `Column`, `Row`, `Range`, `Output`* |
| `excel_write` | `Path`*, `Sheet`, `Mode` [cell, row, append_row], `Column`, `Row`, `Value`*, `Delimiter`, `ValueInput` [AUTO, TEXT], `CreateIfMissing`, `Output` |

> `read_file` với `ConsumeLine=true` + `Mode=first_line`/`random_line` = lấy 1 dòng rồi xóa khỏi file
> (pattern dùng list keyword/account tiêu hao dần).

> `excel_read` đọc `.xlsx` / `.xls` trực tiếp. `Sheet` = số thứ tự sheet (0-based) **hoặc** tên
> sheet; `Column` = chữ (A) hoặc số 1-based; `Row` đếm từ 1 và tính cả dòng tiêu đề. Output là
> chuỗi (`cell`), `List<string>` (`row` / `column`), hoặc list của list (`range`) — đưa thẳng vào
> `for_each.List` được. Ngày ra `yyyy-MM-dd`, số không bao giờ ra dạng mũ.

> `excel_write` ghi `.xlsx` (không ghi được `.xls`). `CreateIfMissing=true` (mặc định) tự tạo file
> và sheet. `ValueInput=AUTO` chỉ ghi thành số khi chuỗi biểu diễn đúng bằng số đó, nên `0123` và
> id dài vẫn là text. Action khoá theo từng file → nhiều profile `append_row` song song không mất
> dòng (chỉ trong cùng một tiến trình app).

> `list_folders` / `list_files` ghi ra `List<string>` — đưa thẳng vào `for_each.List` được, hoặc dùng
> `list_op` (`count` / `get` / `shuffle`…) để xử lý tiếp. `Recursive=false` (mặc định) chỉ lấy 1 cấp;
> `Recursive=true` quét cả cây con. `IgnoreMissing=false` (mặc định) báo lỗi khi thư mục không tồn tại.

### 6.5 HTTP / Network

| Type | Params |
|---|---|
| `http_request` | `Url`*, `Method` [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS], `BodyType` [none, raw, form-urlencoded, multipart], `Body`, `ContentType`, `Headers` (multiline), `Cookies`, `Proxy`, `Timeout`, `Output`* |
| `check_proxy` | `Source` [profile, custom]*, `Proxy`, `Type` [auto, http, socks5], `TestUrl`, `Timeout`, `Output`* |

### 6.6 Google

| Type | Params |
|---|---|
| `google_sheet_read` | `CredentialFile`*, `FileId`*, `SheetId`, `Mode` [cell, row, column, range], `Column`, `Row`, `Range`, `Output`* |
| `google_sheet_write` | `CredentialFile`*, `FileId`*, `SheetId`, `Mode` [cell, row, append_row], `Column`, `Row`, `Value`*, `Delimiter`, `ValueInput` [USER_ENTERED, RAW], `Output` |

> Xác thực bằng **Credential file** = key JSON của Google Service Account (`"type": "service_account"`).
> Sheet phải được share cho `client_email` trong file đó — Viewer để đọc, Editor để ghi.
> `FileId` nhận cả URL sheet lẫn id thuần; `SheetId` là số thứ tự tab (0-based) **hoặc** tên tab.

> `google_sheet_read` ghi ra chuỗi (`cell`), `List<string>` (`row` / `column`), hoặc list của list
> (`range`) — đưa thẳng vào `for_each.List` được. `google_sheet_write` mode `append_row` để Google
> tự tìm dòng trống cuối bảng nên nhiều profile chạy song song không đè lên nhau; `Value` nhận
> `{{list}}` hoặc chuỗi cắt theo `Delimiter`.

---

## 7. Functions (tùy chọn)

```json
"Functions": [
  {
    "Name": "doSearch",
    "Params": ["keyword"],
    "Note": null,
    "Phase": "None",
    "Body": [ /* WorkflowStep[] */ ]
  }
]
```

- Gọi bằng action `call_function` (`FunctionName: "doSearch"`, `Arguments: "keyword={{inputs.keyword}}"`).
- Tham số truyền vào thành biến cùng tên trong scope function; lấy về bằng action `return` → `Output`.
- `Phase`:
  - `None` — hàm thường, gọi qua `call_function`.
  - `PreLaunch` — chạy **trước** khi mở browser (`ctx.Browser == null` → KHÔNG được dùng action browser).
  - `PostLaunch` — chạy **sau** khi đóng browser (cũng không có browser).

### 7.1 Import 1 function riêng lẻ ("Import function")

Để import **một function độc lập** (không kèm workflow), dùng envelope có `Kind` = `TstFunction`.
File này import qua nút **"Import function"**, KHÔNG phải "Import workflow" (đưa vào import workflow
sẽ bị từ chối, và ngược lại).

```json
{
  "Kind": "TstFunction",
  "Version": 1,
  "Function": {
    "Name": "GoogleAlert",
    "Params": ["keyword"],
    "Note": "Mô tả ngắn",
    "Phase": "None",
    "Body": [ /* WorkflowStep[] */ ]
  }
}
```

**Khác biệt then chốt so với workflow:**
- Function **KHÔNG có `Inputs`**. Tham số là `Params` (list tên), tham chiếu trong body bằng
  `{{<tên-param>}}` — vd `{{keyword}}`, **không** phải `{{inputs.keyword}}`.
- `Kind` so khớp không phân biệt hoa thường; `Version` là int (`1`).
- Sau khi import, gọi bằng `call_function` và truyền tham số qua `Arguments` (mỗi dòng `key=value`):
  ```json
  { "Type": "call_function", "Params": { "FunctionName": "GoogleAlert", "Arguments": "keyword={{inputs.keyword}}" } }
  ```
- Body của function được normalize y như Steps (param alias, JToken→CLR) khi load.

> Nguồn: `WorkflowSerializer.SerializeFunction` / `DeserializeFunction` (`FunctionEnvelope`),
> `Deserialize` từ chối file có `Kind=TstFunction`.

---

## 8. Mẫu thường dùng (copy & sửa)

### 8.1 Điều hướng + tìm kiếm bằng input `keyword`

```json
{
  "Name": "Google search",
  "Version": "1.0.0",
  "Inputs": [
    { "Name": "keyword", "Label": "Keyword", "Kind": "Text", "Required": true, "DefaultValue": "claude ai" }
  ],
  "Steps": [
    { "Type": "browser_navigate", "Params": { "Url": "https://www.google.com", "WaitForLoad": "complete" } },
    { "Type": "browser_wait_selector", "Params": { "Selector": "textarea[name=q]", "TimeoutMs": 10000 } },
    { "Type": "browser_click", "Params": { "Selector": "textarea[name=q]" } },
    { "Type": "browser_type", "Params": { "Selector": "textarea[name=q]", "Text": "{{inputs.keyword}}", "CharDelayMs": 120 } },
    { "Type": "browser_evaluate", "Params": { "Script": "document.querySelector('textarea[name=q]').form.submit(); return 'ok';", "Output": "submit" } },
    { "Type": "browser_wait_page_loaded", "Params": { "ReadyState": "complete", "TimeoutMs": 30000 } },
    { "Type": "browser_get_text", "Params": { "Selector": "h3", "Output": "firstTitle" } },
    { "Type": "log", "Params": { "Level": "Info", "Message": "Kết quả đầu: {{firstTitle}}" } }
  ]
}
```

### 8.2 Retry + dedup (nền của Google Alert)

```json
{ "Type": "repeat", "Params": { "Times": "3", "IndexName": "attempt" }, "Body": [
  { "Type": "try_catch", "Branches": {
    "try": [
      { "Type": "browser_navigate", "Params": { "Url": "https://www.google.com/alerts", "WaitForLoad": "complete" } },
      { "Type": "browser_wait_selector", "Params": { "Selector": "//div[@id='search_box']//input", "SelectorType": "xpath", "TimeoutMs": 20000 } },
      { "Type": "browser_check_selector", "Params": { "Selector": "//span[.='{{inputs.keyword}}']", "SelectorType": "xpath", "Mode": "exists", "Output": "exists" } },
      { "Type": "if", "Params": { "Left": "{{exists}}", "Operator": "==", "Right": "True" }, "Branches": {
        "then": [ { "Type": "return", "Params": { "Status": "success", "Value": "exists" } } ],
        "else": [ /* các bước tạo mới... */ ]
      } }
    ],
    "catch": [ { "Type": "log", "Params": { "Level": "Warn", "Message": "Lần {{attempt}} lỗi: {{last_error}}" } } ]
  } }
] }
```

### 8.3 Lặp qua danh sách từ split

```json
{ "Type": "split_text", "Params": { "Source": "{{inputs.keywords}}", "Delimiter": "\n", "Trim": true, "RemoveEmpty": true, "Output": "kwList" } },
{ "Type": "for_each", "Params": { "List": "{{kwList}}", "ItemName": "kw", "IndexName": "i" }, "Body": [
  { "Type": "log", "Params": { "Level": "Info", "Message": "[{{i}}] {{kw}}" } }
] }
```

### 8.4 Đọc & tiêu hao 1 dòng từ file

```json
{ "Type": "read_file", "Params": { "Path": "{{profile_dir}}\\keywords.txt", "Mode": "first_line", "ConsumeLine": true, "SkipEmpty": true, "Output": "kw" } }
```

### 8.5 Import dưới dạng Function (envelope `TstFunction`)

Cùng logic 8.2 nhưng đóng gói thành **function độc lập** (import qua "Import function").
Lưu ý dùng `{{keyword}}` (param của function), KHÔNG phải `{{inputs.keyword}}`.

```json
{
  "Kind": "TstFunction",
  "Version": 1,
  "Function": {
    "Name": "GoogleAlert",
    "Params": ["keyword"],
    "Note": "Tạo Google Alert cho keyword. Retry 3 lần, bỏ qua nếu alert đã tồn tại.",
    "Phase": "None",
    "Body": [
      { "Type": "repeat", "Params": { "Times": "3", "IndexName": "attempt" }, "Body": [
        { "Type": "try_catch", "Branches": {
          "try": [
            { "Type": "browser_navigate", "Params": { "Url": "https://www.google.com/alerts", "WaitForLoad": "complete" } },
            { "Type": "browser_wait_selector", "Params": { "Selector": "//div[@id='search_box']//input", "SelectorType": "xpath", "TimeoutMs": 20000 } },
            { "Type": "browser_check_selector", "Params": { "Selector": "//span[.='{{keyword}}']", "SelectorType": "xpath", "Mode": "exists", "Output": "exists" } },
            { "Type": "if", "Params": { "Left": "{{exists}}", "Operator": "==", "Right": "True" }, "Branches": {
              "then": [ { "Type": "return", "Params": { "Status": "success", "Value": "exists" } } ],
              "else": [
                { "Type": "browser_click", "Params": { "Selector": "//div[@id='search_box']//input", "SelectorType": "xpath" } },
                { "Type": "browser_type", "Params": { "Selector": "//div[@id='search_box']//input", "SelectorType": "xpath", "Text": "{{keyword}}", "CharDelayMs": 200 } },
                { "Type": "browser_click", "Params": { "Selector": "//*[contains(@class,'show_options')]", "SelectorType": "xpath" } },
                { "Type": "browser_click", "Params": { "Selector": "//*[contains(@class,'language_select')]", "SelectorType": "xpath" } },
                { "Type": "browser_click", "Params": { "Selector": "//*[contains(@class,'goog-menuitem-content') and .='Any Language']", "SelectorType": "xpath" } },
                { "Type": "browser_click", "Params": { "Selector": "//*[@id='create_alert']", "SelectorType": "xpath" } },
                { "Type": "return", "Params": { "Status": "success", "Value": "created" } }
              ]
            } }
          ],
          "catch": [ { "Type": "log", "Params": { "Level": "Warn", "Message": "Lần {{attempt}} lỗi: {{last_error}}" } } ]
        } }
      ] }
    ]
  }
}
```

Gọi sau khi import:
```json
{ "Type": "call_function", "Params": { "FunctionName": "GoogleAlert", "Arguments": "keyword={{inputs.keyword}}" } }
```

---

## 9. Checklist trước khi giao script

- [ ] **Import workflow**: root có `Steps` hoặc `Functions`, KHÔNG có `"Kind":"TstFunction"`.
- [ ] **Import function**: root là envelope `{ "Kind":"TstFunction", "Version":1, "Function":{...} }`;
      tham số là `Params`, body dùng `{{<param>}}` (KHÔNG `{{inputs.*}}`).
- [ ] Mọi `Type` nằm trong catalog mục 6 (đúng chính tả mã định danh).
- [ ] Key trong `Params` là **tên property** (vd `Url`, không phải "URL"/"Address").
- [ ] Selector XPath → có set `"SelectorType": "xpath"` (mặc định là css).
- [ ] `{{var}}` CHỈ đặt ở param Expression/MultilineString; param Number/Bool/Dropdown phải là literal.
- [ ] `if`/`try_catch` dùng `Branches`; `for_each`/`while`/`repeat` dùng `Body`.
- [ ] Input tham chiếu đúng `{{inputs.<Name>}}`, `<Name>` là identifier hợp lệ.
- [ ] Op truy vấn của `list_op` và mọi action có `Output*` đều khai báo `Output`.
- [ ] JSON hợp lệ (escape `\\` cho đường dẫn Windows, `\n` cho xuống dòng).

---

## 10. Nguồn (file để verify lại khi nghi ngờ)

- Format & validate: `TSTAutomation.Core/Model/WorkflowSerializer.cs`
- Model: `TSTAutomation.Core/Model/WorkflowStep.cs`, `WorkflowInput.cs`
- Resolver biến: `TSTAutomation.Core/Engine/ExecutionContext.cs`
- Schema param (Name = property name): `TSTAutomation.Core/Engine/ActionRegistry.cs`
- Attribute: `TSTAutomation.Core/Abstractions/Attributes.cs`
- Action: `TSTAutomation.Core/Actions/{Browser,Data,Control,File,Http,Network,Google}/*.cs`
- Import/Export UI: `TSTProfileManager/ViewModels/AutomationPageViewModel.cs` (`ImportJson`/`ExportJson`),
  `TSTProfileManager/Views/Automation/AutomationPage.axaml.cs`
