Skill: ojbkxiongdei/maibei-upload-audit (version 1.0.0)

--- name: maibei-upload-audit description: Upload local/inbound media to Maibei/MaybeAI, copy the audit spreadsheet, analyze uploaded files into the sheet, run the audit stage, generate a visual HTML report, and share it via trycloudflare tunnel. Use when a user asks to upload product images/videos to https://maibei.maybe.ai/e-commerce/upload-audit-file, continue an existing audit batch, fetch the generated spreadsheet link, run the audit pipeline end-to-end, or replay any step with a valid MaybeAI token and user-id. Triggers on: "上传文件并审核", "maybe audit", "maibei audit", "run audit pipeline", "上传审核", "生成审核报告", "分享审核链接". --- # Maibei Upload Audit Full end-to-end upload + analyze + audit + HTML report generation + share pipeline. ## Quick start ```bash python3 scripts/upload_audit.py \ --token "<bearer-token>" \ --user-id "<user-id>" \ --mode full \ --file "/path/to/image.jpg" ``` With automatic HTML report generation + trycloudflare sharing: ```bash python3 scripts/upload_audit.py \ --token "<bearer-token>" \ --user-id "<user-id>" \ --mode full \ --file "/path/to/image.jpg" \ --html \ --share ``` ## Correct end-to-end order 1. Confirm inputs: bearer token, user-id, local files or inbound media 2. Upload each file with the MaybeAI tRPC upload API 3. Run copy-sheet workflow to create a working spreadsheet 4. Run analyze workflow to classify files, OCR text, and write rows into `审核文件信息` 5. Run the audit stage (audit + font-audit in parallel) 6. Run the summary workflow after audit stage completes 7. **Generate HTML report** grouping results by media URL, sorted by risk 8. **Share via trycloudflare** to produce a public URL 9. Return the working sheet URL + public HTML report URL ## Audit stage execution model Treat the audit stage as one logical stage after analyze finishes. Current known workflows inside that stage: - audit workflow artifact: `69c6582612aa26396bd2ace7` - font-audit workflow artifact: `69ccb9d584cf626fe68ff7ea` Inside that stage, `audit` and `font-audit` run in parallel as internal workflows, then `summary` runs after both complete. ## Current known workflow ids For `/e-commerce/upload-audit-file`: - copy workflow artifact: `69ca226377efb8a1de743d34` - analyze workflow artifact: `69c245bca5e657510a37a6a2` - audit workflow artifact: `69c6582612aa26396bd2ace7` - font-audit workflow artifact: `69ccb9d584cf626fe68ff7ea` - summary workflow artifact: `69ca16d067a09db8deb145c2` - source Excel URL: `https://www.maybe.ai/docs/spreadsheets/d/69c2400ea25ba20198828d73?gid=0` ## Required auth context Need all of: - bearer token - `user-id` - local files or inbound media, or already-uploaded file URLs ## Script usage ```text upload_audit.py --mode <mode> [options] Modes: upload Upload files only copy Copy source Excel only analyze Run analyze step (requires --sheet-url or prior upload) audit Run audit + font-audit summary Run final summary full Upload → copy → analyze → audit → summary (all in one) Full-mode options: --file <path> Local file to upload (repeatable) --html Generate HTML report after workflows complete --share Share HTML report via trycloudflare tunnel --html-only Skip workflows, generate HTML from prior results --skip-analyze Skip analyze step in full mode --skip-audit Skip audit step in full mode --skip-font Skip font-audit step in full mode --skip-summary Skip final summary step Other options: --token <token> MaybeAI bearer token (required) --user-id <id> MaybeAI user-id (required) --sheet-url <url> Existing working sheet URL --source-excel-url <url> Source Excel URL (default: see above) --print-json Output results as JSON ``` ## HTML report generation When `--html` is passed (or `--mode html`), the script generates a self-contained HTML report at `$WORKDIR/audit_report.html`. The report includes: - Top-level summary metrics (total / high / medium / low risk counts) - Overall audit result badge - Tabbed view by risk level (高风险 / 中风险 / 低风险) - Per-item cards ordered by risk (high → medium → low), each containing: - Media preview thumbnail - Media URL (clickable) - Overall risk level badge - 第三方审核 / 内部规则审核 / 行业规则审核 / 字体审核 sub-results - 风险点 (risk hits) - BLOCK reason in plain language - 建议修改点 and concrete rewrite direction - Link back to the original working spreadsheet The HTML is fully self-contained (no external JS/CSS dependencies) and responsive. ## Sharing via trycloudflare When `--share` is passed, after HTML generation the script: 1. Starts a local HTTP server serving the workspace directory 2. Starts a cloudflared tunnel to expose it publicly 3. Waits for the trycloudflare URL to become reachable 4. Verifies the URL returns HTTP 200 5. Returns the verified public URL ```bash python3 scripts/share_url.py /path/to/file.html # outputs: https://xxxx.trycloudflare.com/file.html ``` ## Upload step Use the MaybeAI tRPC endpoint: ``` POST https://maibei.maybe.ai/api/trpc/imageGetUploadUrl ``` Request shape: ```json { "key": "superapp/uploads/<timestamp>-<filename>" } ``` Response fields: - `result.data.uploadUrl` - `result.data.url` Upload pattern: 1. Call `imageGetUploadUrl` with a unique `key` 2. PUT raw file bytes to `uploadUrl` 3. Use `url` as the uploaded public URI in later workflows ## Analyze payload construction Build `variable:dataframe:product_images` as rows: ```json [ { "url": "https://statics.maybe.ai/.../file.webp", "file_type": "image", "file_name": "file.webp" } ] ``` Use `file_type: "image"` for png/jpg/jpeg/webp/gif. ## Audit and summary payload Send all three values: ```json [ { "name": "variable:scalar:copy_excel_url", "default_value": "<sheet>" }, { "name": "variable:scalar:excel_url", "default_value": "<sheet>" }, { "name": "variable:dataframe:copy_excel_url_data", "default_value": [{ "copy_excel_url": "<sheet>" }] } ] ``` ## Font-audit payload Send: ```json [ { "name": "variable:scalar:copy_excel_url", "default_value": "<sheet>" } ] ``` ## Execution communication rules **Report progress at every step.** Required progress messages: ``` 📤 正在上传文件 1/5... ✅ 文件1 上传完成 📋 正在复制源Excel... ✅ 复制完成 🔍 正在分析文件... ✅ 分析完成 🔴 正在审核（内部规则 + 行业规则）... ✅ 审核完成 🔤 正在字体审核... ✅ 字体审核完成 📊 正在汇总... ✅ 汇总完成 🌐 正在生成HTML报告... ✅ 报告已生成 🔗 正在分享到公网... ✅ 报告已发布: https://xxx.trycloudflare.com/audit_report.html ``` ## Response format After successful end-to-end run with `--html --share`, return: - Working sheet URL - Uploaded file URLs (if applicable) - Copy step result - Analyze step result - Audit step result - Summary workflow result - HTML report local path - Public share URL (trycloudflare) ## Debugging If upload fails: 1. Verify token and user-id 2. Inspect `imageGetUploadUrl` response shape 3. Verify the PUT upload returns 200 If workflow fails: 1. Verify the artifact id by calling `workflow/detail/public` 2. Verify `Authorization: Bearer <token>` and `user-id` on `play-be.omnimcp.ai` 3. Inspect streamed events, especially `action_output`, `dataflow_output`, and `workflow_output` ## Resources ``` maibei-upload-audit/ ├── SKILL.md ├── scripts/ │ ├── upload_audit.py # Main CLI for full pipeline │ └── share_url.py # Trycloudflare sharing utility └── references/ ├── protocol-notes.md # API request/response shapes └── live-run-notes.md # Validated execution notes ```

path: references/live-run-notes.md
content: # Live Run Notes

Validated with real user tokens and real workflow executions.

## End-to-end validated sequence

The following sequence is now confirmed working in production:

1. Upload inbound/local files with `POST https://maibei.maybe.ai/api/trpc/imageGetUploadUrl`
2. PUT raw file bytes to returned `uploadUrl`
3. Run copy workflow
4. Run analyze workflow
5. Run audit workflow
6. Run font-audit workflow

## Upload step

Observed working request shape:

```json
{ "key": "superapp/uploads/<timestamp>-<filename>" }
```

Observed working response fields:

```json
{
  "result": {
    "data": {
      "uploadUrl": "https://maybe-static-...cos...",
      "url": "https://statics.maybe.ai/superapp/uploads/..."
    }
  }
}
```

Observed result:

- `PUT uploadUrl` returned `200`
- returned `url` worked as the public file URL for later workflows

## Copy workflow

Request succeeded with:

- artifact_id: `69ca226377efb8a1de743d34`
- workflow_id: `69ca238877efb8a1de743e3b`

Observed outputs:

- `copy_excel_flow` returned a scalar new sheet URL
- `copy_excel_url` returned `dataframe:copy_excel_url_data`

Recent observed result:

- new copied sheet: `https://www.maybe.ai/docs/spreadsheets/d/69ce1c80d30c7504d2e4d3ad`

## Analyze workflow

Request succeeded with:

- artifact_id: `69c245bca5e657510a37a6a2`
- workflow_id: `69ca2ce467a09db8deb16dc5`
- uploaded file rows shaped as `{ url, file_type, file_name }`
- copied sheet url in `variable:scalar:copy_excel_url`

Observed behavior:

- classified file types
- OCR/extracted text from images
- wrote results into worksheet `审核文件信息`
- appended new rows when `file_url` values were new

Recent observed success payload:

```json
{
  "success": true,
  "spreadsheet_url": "https://www.maybe.ai/docs/spreadsheets/d/69ce1c80d30c7504d2e4d3ad?gid=0",
  "spreadsheet_id": "69ce1c80d30c7504d2e4d3ad",
  "worksheet": "审核文件信息",
  "message": "Updated 0 cells in 0 rows (matched 0 lookup keys, 2 unmatched), 2 new rows appended. Lookup columns: [file_url]"
}
```

## Audit workflow

Request succeeded with:

- artifact_id: `69c6582612aa26396bd2ace7`
- workflow_id: `69ca2e1d45c46dbe33ffcd49`
- variables:
  - `variable:scalar:copy_excel_url`
  - `variable:scalar:excel_url`
  - `variable:dataframe:copy_excel_url_data`

Observed behavior:

- extracted `copy_excel_url`
- ran image/context/audio/video/internal/industry audit branches
- returned `no result data` for audio/video when no matching data existed
- surfaced high-risk findings in internal and industry rules

Recent observed findings:

- screenshot/login-style content flagged as context mismatch / possible sensitive information leakage
- image with no OCR text flagged as unable to audit / missing mandatory information
- internal and industry rules both returned `BLOCK` on problematic rows

## Font-audit workflow

Request succeeded with:

- artifact_id: `69ccb9d584cf626fe68ff7ea`
- workflow detail returned workflow_id: `69ce02be964d081c20ea6c94`
- variable:
  - `variable:scalar:copy_excel_url`

Observed behavior:

- read data from the working Excel file
- processed image rows with extracted text
- skipped actual font auditing for rows with no text
- wrote results into worksheet `第三方字体鉴权`

Recent observed success payload:

```json
{
  "success": true,
  "spreadsheet_url": "https://www.maybe.ai/docs/spreadsheets/d/69ce1c80d30c7504d2e4d3ad?gid=2",
  "spreadsheet_id": "69ce1c80d30c7504d2e4d3ad",
  "worksheet": "第三方字体鉴权",
  "message": "Updated 0 cells in 0 rows (matched 0 lookup keys, 7 unmatched), 7 new rows appended. Lookup columns: [file_url]"
}
```

Observed font results on a live screenshot:

- `San Francisco (SF Pro Text Semi-Bold)`
- `San Francisco (SF Pro Text Regular)`
- `auth_tag`: `商用或系统字体`
- `audit_result`: `PASS`
- `has_non_free_fonts`: `true`
- `non_free_fonts`: `San Francisco (Apple System Font)`

## Practical conclusion

The full API-first pipeline is now validated end-to-end, including upload, copy, analyze, audit, and font-audit.
If future runs fail, first check:

- expired token or wrong `user-id`
- changed upload API response shape
- changed workflow artifact ids
- changed workflow detail ids returned by `workflow/detail/public`


path: references/protocol-notes.md
content: # Protocol Notes

## Page

Target page:
- `https://maibei.maybe.ai/e-commerce/upload-audit-file`

Embedded source sheet:
- `https://www.maybe.ai/docs/spreadsheets/d/69c2400ea25ba20198828d73?gid=0`

## Front-end extracted config

```json
{
  "copyExcelArtifactId": "69ca226377efb8a1de743d34",
  "analyzeArtifactId": "69c245bca5e657510a37a6a2",
  "auditArtifactId": "69c6582612aa26396bd2ace7",
  "sourceExcelUrl": "https://www.maybe.ai/docs/spreadsheets/d/69c2400ea25ba20198828d73?gid=0",
  "historyStorageKey": "superapp:upload-audit-file:sheet_history:v1"
}
```

## Workflow service

Public workflow detail endpoint:
- `POST https://play-be.omnimcp.ai/api/v1/workflow/detail/public`
- body: `{ "artifact_id": "..." }`

Workflow run endpoint:
- `POST https://play-be.omnimcp.ai/api/v1/workflow/run`
- headers:
  - `Accept: text/event-stream`
  - `Content-Type: application/json`
  - `Authorization: Bearer <token>`
  - `user-id: <user_id>`
- returns streamed events

Important request fields:

```json
{
  "artifact_id": "...",
  "interaction": true,
  "task": "",
  "task_id": "uuid",
  "workflow_id": "...",
  "variables": [],
  "metadata": {
    "case": "upload-audit-file",
    "title": "上传文件并审核"
  }
}
```

## Captured working examples

### 1. Copy source sheet

```json
{
  "artifact_id": "69ca226377efb8a1de743d34",
  "interaction": true,
  "task": "",
  "task_id": "7834c6b3-6f76-4415-b877-fc8945dc7c3d",
  "workflow_id": "69ca238877efb8a1de743e3b",
  "variables": [
    {
      "name": "variable:scalar:source_excel_url",
      "default_value": "https://www.maybe.ai/docs/spreadsheets/d/69c2400ea25ba20198828d73?gid=0"
    }
  ],
  "metadata": {
    "case": "upload-audit-file",
    "title": "上传文件并审核"
  }
}
```

### 2. Analyze uploaded files into the copied sheet

```json
{
  "artifact_id": "69c245bca5e657510a37a6a2",
  "interaction": true,
  "task": "",
  "task_id": "4d3b9ed5-00da-4fac-8cb5-684b6170f890",
  "workflow_id": "69ca2ce467a09db8deb16dc5",
  "variables": [
    {
      "name": "variable:dataframe:product_images",
      "default_value": [
        {
          "url": "https://statics.maybeai.cn/superapp/upload-audit-file/...jpg?imageMogr2/auto-orient/strip/thumbnail/3072x3072>/quality/95",
          "file_type": "image",
          "file_name": "光泽焕白系列_page-0001.jpg"
        }
      ]
    },
    {
      "name": "variable:scalar:copy_excel_url",
      "default_value": "https://www.maybe.ai/docs/spreadsheets/d/69ca5a223d9d52683626b454"
    }
  ],
  "metadata": {
    "case": "upload-audit-file",
    "title": "上传文件并审核"
  }
}
```

### 3. Audit the sheet

```json
{
  "artifact_id": "69c6582612aa26396bd2ace7",
  "interaction": true,
  "task": "",
  "task_id": "b827ed81-0d58-4f91-a5c9-abf875fdd8b9",
  "workflow_id": "69ca2e1d45c46dbe33ffcd49",
  "variables": [
    {
      "name": "variable:scalar:copy_excel_url",
      "default_value": "https://www.maybe.ai/docs/spreadsheets/d/69ca5a223d9d52683626b454"
    }
  ],
  "metadata": {
    "case": "upload-audit-file",
    "title": "上传文件并审核"
  }
}
```

Normalize captured URLs before replaying them:
- remove Slack angle brackets `<...>`
- remove HTML-escaped `&gt;`

## Variables

Analyze workflow writes uploaded media into the sheet using:
- `variable:dataframe:product_images`
- `variable:scalar:copy_excel_url`

Audit workflow accepts:
- `variable:scalar:copy_excel_url`
- `variable:scalar:excel_url`
- `variable:dataframe:copy_excel_url_data`

## Upload notes

The page code shows a tRPC upload mutation. Confirm actual payload encoding from browser requests before relying on automation.

Observed facts:
- `uploadImage` exists as a mutation path
- wrong payload shape returns validation errors for missing `key` and `base64`
- `uploadImages` path is not directly exposed as a tRPC procedure name on this host

## Validation shortcut

Token validity can be checked with:
- `GET https://www.maybe.ai/api/trpc/getVipInfo`
- headers: bearer token + user-id


path: scripts/html_report.py
content: #!/usr/bin/env python3
"""
Generate a self-contained HTML audit report from upload audit results.
"""
import datetime
import json
import sys
from pathlib import Path


def escape(s):
    if s is None:
        return ""
    return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")


def generate_html_report(sheet_url, uploaded_rows, output_path="audit_report.html", summary_data=None):
    """
    Generate HTML report.
    
    Args:
        sheet_url: URL of the working spreadsheet
        uploaded_rows: list of dicts with url, file_name, file_type
        output_path: local output file path
        summary_data: optional dict with high/med/low counts and overall result
    """
    items = []
    for row in uploaded_rows:
        url = row.get("url", "")
        fname = row.get("file_name", url.split("/")[-1].split("?")[0] if url else "unknown")
        ftype = row.get("file_type", "image")
        risk = row.get("risk", "high")
        ai = row.get("ai_analysis", {})
        
        thumb_url = ""
        if url and url != "#":
            thumb_url = url.split("?")[0] + "/thumbnail/300x300%3E/quality/80"
        
        items.append({
            "url": url,
            "file_name": fname,
            "file_type": ftype,
            "risk": risk,
            "thumb_url": thumb_url,
            "third_party": ai.get("third_party", "—"),
            "internal": ai.get("internal", "—"),
            "industry": ai.get("industry", "—"),
            "font_result": ai.get("font", "—"),
            "risk_hits": ai.get("risk_hits", []),
            "block_reason": ai.get("block_reason", "暂无明确原因"),
            "suggestions": ai.get("suggestions", []),
            "rewrite_direction": ai.get("rewrite_direction", ""),
        })

    if not items:
        items.append({
            "url": "#", "file_name": "No files", "file_type": "image",
            "risk": "low", "thumb_url": "", "third_party": "—",
            "internal": "—", "industry": "—", "font_result": "—",
            "risk_hits": [], "block_reason": "无数据", "suggestions": [], "rewrite_direction": ""
        })

    high_items = [i for i in items if i["risk"] == "high"]
    med_items = [i for i in items if i["risk"] == "med"]
    low_items = [i for i in items if i["risk"] == "low"]

    if high_items:
        overall_badge = "🔴 高风险批次"
        overall_class = "danger"
        overall_result = "不通过"
        overall_desc = "该批次中存在高风险内容，请查看具体风险点并按建议修改。"
    elif med_items:
        overall_badge = "🟠 中风险批次"
        overall_class = "warn"
        overall_result = "需修改"
        overall_desc = "该批次中存在中等风险内容，建议按建议优化后重新提交。"
    else:
        overall_badge = "🟢 低风险批次"
        overall_class = "ok"
        overall_result = "通过"
        overall_desc = "该批次内容风险较低，审核通过。"

    badge_color = {"danger": "#ff4757", "warn": "#ffa502", "ok": "#2ed573"}.get(overall_class, "#747d8c")

    cards_html = ""
    for item in items:
        risk = item["risk"]
        risk_label = {"high": "🔴 高风险", "med": "🟠 中风险", "low": "🟢 低风险"}.get(risk, risk)
        risk_cls = {"high": "risk-high", "med": "risk-med", "low": "risk-low"}.get(risk, "")

        hits = item["risk_hits"]
        hits_html = "".join(f"<li>• <span class='hit'>{escape(h)}</span></li>" for h in hits) if hits else "<li>暂无明确风险点</li>"
        
        suggestions = item["suggestions"]
        suggestions_html = "".join(f"<li>{i+1}. <span class='suggest'>{escape(s)}</span></li>" for i, s in enumerate(suggestions)) if suggestions else "<li>无具体建议</li>"

        def color_result(val):
            return "#ff6b7a" if "不通过" in str(val) else "#2ed573"

        tp_color = color_result(item["third_party"])
        int_color = color_result(item["internal"])
        ind_color = color_result(item["industry"])

        cards_html += f"""
        <div class="card" data-risk="{risk}">
          <div class="card-header">
            <img class="thumb" src="{escape(item['thumb_url'])}" alt="preview" onerror="this.style.display='none'">
            <div>
              <div class="card-title">{escape(item['file_name'])}</div>
              <span class="risk-badge {risk_cls}">{risk_label}</span>
              <div class="card-subtitle">{escape(item['file_type'])} | {escape(item['url'])}</div>
              <a href="{escape(item['url'])}" target="_blank" style="font-size:12px;color:#747d8c;">🔗 查看原图</a>
            </div>
          </div>
          <div class="section">
            <h4>⚠️ 风险点</h4>
            <ul>{hits_html}</ul>
          </div>
          <div class="section">
            <h4>🚫 BLOCK 原因</h4>
            <div class="block-reason">{escape(item['block_reason'])}</div>
          </div>
          <div class="section">
            <h4>📝 建议修改点</h4>
            <ul>{suggestions_html}</ul>
          </div>
          <div class="section">
            <h4>🔍 审核子项明细</h4>
            <ul>
              <li>第三方审核：<span style="color:{tp_color};">{escape(item['third_party'])}</span></li>
              <li>内部规则审核：<span style="color:{int_color};">{escape(item['internal'])}</span></li>
              <li>行业规则审核：<span style="color:{ind_color};">{escape(item['industry'])}</span></li>
              <li>字体审核：<span style="color:#888;">{escape(item['font_result'])}</span></li>
            </ul>
          </div>
          <div class="section">
            <h4>🏷️ 修改方向</h4>
            <p style="color:#2ed573;">{escape(item['rewrite_direction']) or '无特定方向建议'}</p>
          </div>
        </div>
        """

    now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")

    html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>审核报告</title>
<style>
  * {{ box-sizing: border-box; margin: 0; padding: 0; }}
  body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f0f14; color: #e0e0e6; min-height: 100vh; padding: 24px; }}
  .container {{ max-width: 900px; margin: 0 auto; }}
  .header {{ text-align: center; margin-bottom: 32px; }}
  .header h1 {{ font-size: 24px; color: #fff; margin-bottom: 8px; }}
  .header p {{ color: #888; font-size: 14px; }}
  .metrics {{ display: flex; gap: 16px; margin-bottom: 32px; flex-wrap: wrap; }}
  .metric {{ background: #1a1a24; border-radius: 12px; padding: 20px 24px; flex: 1; min-width: 140px; text-align: center; }}
  .metric .num {{ font-size: 36px; font-weight: 700; }}
  .metric .label {{ font-size: 12px; color: #888; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }}
  .metric.danger .num {{ color: #ff4757; }}
  .metric.warn .num {{ color: #ffa502; }}
  .metric.ok .num {{ color: #2ed573; }}
  .metric.total .num {{ color: #747d8c; }}
  .overall {{ background: #1a1a24; border-radius: 16px; padding: 24px; margin-bottom: 24px; border-left: 4px solid {badge_color}; }}
  .overall h2 {{ font-size: 18px; color: #fff; margin-bottom: 8px; }}
  .overall p {{ color: #aaa; font-size: 14px; line-height: 1.6; }}
  .overall .badge {{ display: inline-block; background: {badge_color}; color: #fff; font-size: 12px; padding: 4px 12px; border-radius: 20px; margin-bottom: 12px; font-weight: 600; }}
  .tabs {{ display: flex; gap: 4px; margin-bottom: 24px; }}
  .tab {{ padding: 10px 20px; border-radius: 8px; cursor: pointer; font-size: 14px; color: #888; background: #1a1a24; border: none; }}
  .tab.active {{ background: {badge_color}; color: #fff; }}
  .card {{ background: #1a1a24; border-radius: 16px; padding: 24px; margin-bottom: 16px; border: 1px solid #2a2a3a; }}
  .card.hidden {{ display: none; }}
  .card .card-header {{ display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }}
  .card .thumb {{ width: 80px; height: 80px; border-radius: 8px; object-fit: cover; background: #2a2a3a; flex-shrink: 0; }}
  .card .card-title {{ font-size: 16px; color: #fff; font-weight: 600; }}
  .card .card-subtitle {{ font-size: 12px; color: #888; margin-top: 4px; word-break: break-all; }}
  .card .risk-badge {{ display: inline-block; font-size: 11px; padding: 3px 10px; border-radius: 12px; font-weight: 600; margin-top: 4px; }}
  .risk-high {{ background: #ff475730; color: #ff4757; border: 1px solid #ff4757; }}
  .risk-med {{ background: #ffa50230; color: #ffa502; border: 1px solid #ffa502; }}
  .risk-low {{ background: #2ed57330; color: #2ed573; border: 1px solid #2ed573; }}
  .section {{ margin-top: 16px; }}
  .section h4 {{ font-size: 13px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }}
  .section p, .section li {{ font-size: 14px; color: #ccc; line-height: 1.7; }}
  .section ul {{ list-style: none; padding-left: 0; }}
  .section li {{ padding: 4px 0; border-bottom: 1px solid #2a2a3a; }}
  .section li:last-child {{ border-bottom: none; }}
  .section .hit {{ color: #ff6b7a; font-weight: 500; }}
  .section .suggest {{ color: #2ed573; }}
  .section .block-reason {{ background: #ff475720; border-left: 3px solid #ff4757; padding: 12px; border-radius: 0 8px 8px 0; color: #ff6b7a; font-size: 13px; }}
  .footer {{ text-align: center; color: #555; font-size: 12px; margin-top: 40px; padding: 20px; }}
  a {{ color: #747d8c; }}
</style>
</head>
<body>
<div class="container">
  <div class="header">
    <h1>🛡️ MaybeAI 审核报告</h1>
    <p>审核时间：{now} &nbsp;|&nbsp; 工作表：<a href="{escape(sheet_url)}" target="_blank">{escape(sheet_url)}</a></p>
  </div>

  <div class="metrics">
    <div class="metric total"><div class="num">{len(items)}</div><div class="label">总文件数</div></div>
    <div class="metric danger"><div class="num">{len(high_items)}</div><div class="label">高风险</div></div>
    <div class="metric warn"><div class="num">{len(med_items)}</div><div class="label">中风险</div></div>
    <div class="metric ok"><div class="num">{len(low_items)}</div><div class="label">低风险</div></div>
  </div>

  <div class="overall">
    <span class="badge">{overall_badge}</span>
    <h2>整体审核结果：{overall_result}</h2>
    <p>{overall_desc}</p>
  </div>

  <div class="tabs">
    <button class="tab active" onclick="showTab('all')">全部 ({len(items)})</button>
    <button class="tab" onclick="showTab('high')">🔴 高风险 ({len(high_items)})</button>
    <button class="tab" onclick="showTab('med')">🟠 中风险 ({len(med_items)})</button>
    <button class="tab" onclick="showTab('low')">🟢 低风险 ({len(low_items)})</button>
  </div>

  {cards_html}

  <div class="footer">
    MaybeAI 合规审核报告 · 由 OpenClaw 自动生成 · {now}<br>
    <a href="{escape(sheet_url)}" target="_blank">📄 查看 MaybeAI 工作表</a>
  </div>
</div>
<script>
function showTab(tab) {{
  document.querySelectorAll('.card').forEach(c => {{
    if (tab === 'all') {{ c.style.display = 'block'; }}
    else {{ c.style.display = c.dataset.risk === tab ? 'block' : 'none'; }}
  }});
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  event.target.classList.add('active');
}}
</script>
</body>
</html>"""

    Path(output_path).write_text(html, encoding="utf-8")
    print(f"HTML report generated: {output_path}")
    return output_path


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: html_report.py <sheet_url> <uploaded_json> [output_path]")
        sys.exit(1)
    sheet_url = sys.argv[1]
    uploaded_rows = json.loads(sys.argv[2])
    output_path = sys.argv[3] if len(sys.argv) > 3 else "audit_report.html"
    generate_html_report(sheet_url, uploaded_rows, output_path)


path: scripts/run_workflow_curl_examples.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Fill these before running.
[REDACTED_SECRET]"
USER_ID="${USER_ID:-}"

if [[ -z "$TOKEN" || -z "$USER_ID" ]]; then
  echo "Set TOKEN and USER_ID first." >&2
  exit 1
fi

# 1) Copy source sheet
curl 'https://play-be.omnimcp.ai/api/v1/workflow/run' \
  -H 'accept: text/event-stream' \
  -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -H 'origin: https://maibei.maybe.ai' \
  -H 'referer: https://maibei.maybe.ai/' \
  -H "user-id: $USER_ID" \
  --data-raw '{"artifact_id":"69ca226377efb8a1de743d34","interaction":true,"task":"","task_id":"copy-sheet-task-id","workflow_id":"69ca238877efb8a1de743e3b","variables":[{"name":"variable:scalar:source_excel_url","default_value":"https://www.maybe.ai/docs/spreadsheets/d/69c2400ea25ba20198828d73?gid=0"}],"metadata":{"case":"upload-audit-file","title":"上传文件并审核"}}'

# 2) Analyze uploaded files into copied sheet
# Replace SHEET_URL and uploaded file entries before running.
# curl 'https://play-be.omnimcp.ai/api/v1/workflow/run' ...

# 3) Audit sheet
# curl 'https://play-be.omnimcp.ai/api/v1/workflow/run' ...


path: scripts/share_url.py
content: #!/usr/bin/env python3
"""
Share a local file or directory via trycloudflare tunnel.
Returns the public URL after verification.
"""
import argparse
import os
import subprocess
import sys
import time
import signal

def find_free_port():
    import socket
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(('', 0))
    port = s.getsockname()[1]
    s.close()
    return port

def start_http_server(directory, port):
    """Start a simple HTTP server in the background."""
    cmd = [sys.executable, '-m', 'http.server', str(port)]
    proc = subprocess.Popen(cmd, cwd=directory, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    return proc

def start_cloudflared(port):
    """Start cloudflared tunnel and return the public URL."""
    cmd = [
        'cloudflared', 'tunnel',
        '--url', f'http://127.0.0.1:{port}',
        '--no-autoupdate',
        '--protocol', 'http2',
        '--metrics', 'localhost:20241'
    ]
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    return proc

def wait_for_url(proc, timeout=30):
    """Wait for cloudflared to output the tunnel URL."""
    start = time.time()
    url = None
    while time.time() - start < timeout:
        line = proc.stderr.readline()
        if not line:
            if proc.poll() is not None:
                break
            time.sleep(0.5)
            continue
        if 'trycloudflare.com' in line:
            idx = line.find('https://')
            if idx != -1:
                tail = line[idx:]
                end = tail.find(' ')
                if end == -1:
                    end = tail.find('\n')
                url = tail[:end].rstrip()
                break
        # Also check stdout
        line_out = proc.stdout.readline()
        if line_out and 'trycloudflare.com' in line_out:
            idx = line_out.find('https://')
            if idx != -1:
                url = line_out[idx:].split()[0].rstrip()
                break
    return url

def verify_url(url, path_hint='', timeout=15):
    """Verify the URL is actually reachable."""
    import urllib.request
    import urllib.error
    target = f"{url}/{path_hint}" if path_hint else url
    try:
        req = urllib.request.Request(target, method='HEAD')
        req.add_header('User-Agent', 'Mozilla/5.0')
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.status == 200, resp.status
    except Exception as e:
        # Try GET as fallback
        try:
            req = urllib.request.Request(target)
            req.add_header('User-Agent', 'Mozilla/5.0')
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                return resp.status == 200, resp.status
        except Exception as e2:
            return False, str(e)

def share_local_path(file_or_dir, filename_hint='', port=None):
    """
    Share a local file or directory via trycloudflare.
    Returns (public_url, tunnel_proc, server_proc) on success.
    """
    path = os.path.abspath(os.path.expanduser(file_or_dir))
    if not os.path.exists(path):
        raise FileNotFoundError(f"Path not found: {path}")

    is_file = os.path.isfile(path)
    if is_file:
        serve_dir = os.path.dirname(path)
        filename_hint = filename_hint or os.path.basename(path)
    else:
        serve_dir = path
        filename_hint = filename_hint or ''

    port = port or find_free_port()

    # Start HTTP server
    server_proc = start_http_server(serve_dir, port)
    time.sleep(1.5)

    # Start cloudflared
    tunnel_proc = start_cloudflared(port)

    try:
        url = wait_for_url(tunnel_proc, timeout=30)
        if not url:
            raise RuntimeError("Could not get trycloudflare URL")

        # Verify
        hint = filename_hint if is_file else ''
        ok, status = verify_url(url, hint)
        if not ok:
            print(f"Warning: URL verification returned {status}", file=sys.stderr)

        return url, tunnel_proc, server_proc
    except Exception:
        tunnel_proc.terminate()
        server_proc.terminate()
        raise

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Share local file/dir via trycloudflare')
    parser.add_argument('path', help='File or directory to share')
    parser.add_argument('--filename', default='', help='Filename hint for single file URL')
    parser.add_argument('--port', type=int, help='HTTP server port (auto-detected if omitted)')
    args = parser.parse_args()

    url, tunnel_proc, server_proc = share_local_path(args.path, args.filename, args.port)
    print(url)

    # Keep alive until interrupted
    try:
        tunnel_proc.wait()
    except KeyboardInterrupt:
        tunnel_proc.terminate()
        server_proc.terminate()
        sys.exit(0)


path: scripts/upload_audit.py
content: #!/usr/bin/env python3
import argparse
import json
import mimetypes
import sys
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Optional

import requests

try:
    from html_report import generate_html_report
except ImportError:
    # Fallback: try as standalone script
    import sys, os
    sys.path.insert(0, os.path.dirname(__file__))
    from html_report import generate_html_report

COPY_EXCEL_ARTIFACT_ID = "69ca226377efb8a1de743d34"
ANALYZE_ARTIFACT_ID = "69c245bca5e657510a37a6a2"
AUDIT_ARTIFACT_ID = "69c6582612aa26396bd2ace7"
FONT_AUDIT_ARTIFACT_ID = "69ccb9d584cf626fe68ff7ea"
SUMMARY_WORKFLOW_ARTIFACT_ID = "69ca16d067a09db8deb145c2"
SOURCE_EXCEL_URL = "https://www.maybe.ai/docs/spreadsheets/d/69c2400ea25ba20198828d73?gid=0"
UPLOAD_URL_ENDPOINT = "https://maibei.maybe.ai/api/trpc/imageGetUploadUrl"
WORKFLOW_DETAIL_ENDPOINT = "https://play-be.omnimcp.ai/api/v1/workflow/detail/public"
WORKFLOW_RUN_ENDPOINT = "https://play-be.omnimcp.ai/api/v1/workflow/run"


def workflow_detail(artifact_id: str):
    r = requests.post(
        WORKFLOW_DETAIL_ENDPOINT,
        headers={"Content-Type": "application/json"},
        data=json.dumps({"artifact_id": artifact_id}),
        timeout=60,
    )
    r.raise_for_status()
    return r.json()


def make_auth_headers(token: str, user_id: str, extra: Optional[dict] = None):
    headers = {
        "Authorization": f"Bearer {token}",
        "user-id": user_id,
    }
    if extra:
        headers.update(extra)
    return headers


def infer_file_type(path_or_url: str):
    lowered = path_or_url.lower()
    if any(lowered.endswith(ext) for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".heic"]):
        return "image"
    if any(lowered.endswith(ext) for ext in [".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"]):
        return "video"
    mime, _ = mimetypes.guess_type(path_or_url)
    if mime and mime.startswith("image/"):
        return "image"
    if mime and mime.startswith("video/"):
        return "video"
    return "image"


def upload_file(token: str, user_id: str, file_path: str, filename: Optional[str] = None):
    path = Path(file_path)
    if not path.exists():
        raise FileNotFoundError(f"file not found: {file_path}")

    filename = filename or path.name
    key = f"superapp/uploads/{int(time.time() * 1000)}-{filename}"

    headers = make_auth_headers(
        token,
        user_id,
        {
            "accept": "*/*",
            "content-type": "application/json",
            "origin": "https://maibei.maybe.ai",
            "referer": "https://maibei.maybe.ai/e-commerce/try-on",
        },
    )

    r = requests.post(
        UPLOAD_URL_ENDPOINT,
        headers=headers,
        data=json.dumps({"key": key}),
        timeout=60,
    )
    r.raise_for_status()
    data = r.json()
    payload = data.get("result", {}).get("data") or data.get("data") or data
    upload_url = payload["uploadUrl"]
    public_url = payload["url"]

    content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
    with path.open("rb") as fh:
        put = requests.put(
            upload_url,
            data=fh,
            headers={"Content-Type": content_type},
            timeout=300,
        )
    put.raise_for_status()

    return {
        "local_path": str(path),
        "file_name": filename,
        "file_type": infer_file_type(filename),
        "key": key,
        "url": public_url,
    }


def run_workflow(token: str, user_id: str, artifact_id: str, variables: list, service: str = "e-commerce", metadata: Optional[dict] = None):
    detail = workflow_detail(artifact_id)
    body = {
        "artifact_id": detail["artifact_id"],
        "interaction": True,
        "task": "",
        "task_id": str(uuid.uuid4()),
        "workflow_id": detail["id"],
        "variables": variables,
        "metadata": metadata or {"case": "upload-audit-file", "title": "上传文件并审核"},
        "service": service,
    }
    r = requests.post(
        WORKFLOW_RUN_ENDPOINT,
        headers=make_auth_headers(
            token,
            user_id,
            {
                "Accept": "text/event-stream",
                "Content-Type": "application/json",
            },
        ),
        data=json.dumps(body),
        timeout=300,
        stream=True,
    )
    r.raise_for_status()
    events = []
    for line in r.iter_lines(decode_unicode=True):
        if line:
            events.append(line)
    return events


def extract_sheet_url(events):
    for line in reversed(events):
        if "dataframe:copy_excel_url_data" in line and "copy_excel_url" in line:
            try:
                payload = json.loads(line.removeprefix("data: "))
                content = payload["data"]["content"]
                inner = json.loads(content)
                content2 = inner.get("content")
                if content2:
                    out = json.loads(content2)
                    data = out.get("output", {}).get("data", [])
                    if data and isinstance(data, list):
                        url = data[0].get("copy_excel_url")
                        if url:
                            return url
            except Exception:
                pass
        if "spreadsheet_url" in line:
            idx = line.find("https://www.maybe.ai/docs/spreadsheets/d/")
            if idx != -1:
                tail = line[idx:]
                end = tail.find('\\"')
                if end != -1:
                    return tail[:end].replace("?gid=0", "").replace("?gid=2", "")
    return None


def print_events(events):
    for e in events:
        print(e)


def normalize_uploaded_rows(rows):
    out = []
    for r in rows:
        out.append(
            {
                "url": r["url"],
                "file_type": r.get("file_type") or infer_file_type(r["url"]),
                "file_name": r.get("file_name") or Path(r["url"]).name,
            }
        )
    return out


def print_summary(sheet_url, uploaded_rows, ran_analyze, ran_audit, ran_font, ran_summary):
    print(
        json.dumps(
            {
                "sheet_url": sheet_url,
                "uploaded_rows": uploaded_rows,
                "ran_analyze": ran_analyze,
                "ran_audit": ran_audit,
                "ran_font": ran_font,
                "ran_summary": ran_summary,
            },
            ensure_ascii=False,
            indent=2,
        )
    )


def shared_sheet_variables(sheet_url):
    return [
        {"name": "variable:scalar:copy_excel_url", "default_value": sheet_url},
        {"name": "variable:scalar:excel_url", "default_value": sheet_url},
        {"name": "variable:dataframe:copy_excel_url_data", "default_value": [{"copy_excel_url": sheet_url}]},
    ]


def share_via_trycloudflare(file_path):
    """Share a local file via trycloudflare tunnel. Returns (url, tunnel_proc, server_proc)."""
    import subprocess, time, socket, os, urllib.request

    def find_free_port():
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.bind(('', 0))
        port = s.getsockname()[1]
        s.close()
        return port

    path = os.path.abspath(os.path.expanduser(file_path))
    is_file = os.path.isfile(path)
    if is_file:
        serve_dir = os.path.dirname(path)
        filename_hint = os.path.basename(path)
    else:
        serve_dir = path
        filename_hint = ""

    port = find_free_port()
    server_proc = subprocess.Popen(
        [sys.executable, '-m', 'http.server', str(port)],
        cwd=serve_dir, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
    )
    time.sleep(1.5)

    tunnel_proc = subprocess.Popen(
        ['cloudflared', 'tunnel', '--url', f'http://127.0.0.1:{port}',
         '--no-autoupdate', '--protocol', 'http2'],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
    )

    url = None
    start = time.time()
    while time.time() - start < 30:
        line = tunnel_proc.stderr.readline()
        if line and 'trycloudflare.com' in line:
            idx = line.find('https://')
            if idx != -1:
                url = line[idx:].split()[0].rstrip()
                break
        time.sleep(0.3)
        if tunnel_proc.poll() is not None:
            break

    if not url:
        tunnel_proc.terminate()
        server_proc.terminate()
        raise RuntimeError("Could not get trycloudflare URL")

    # Verify
    hint = filename_hint if is_file else ''
    target = f"{url}/{hint}" if hint else url
    try:
        req = urllib.request.Request(target)
        req.add_header('User-Agent', 'Mozilla/5.0')
        with urllib.request.urlopen(req, timeout=15) as resp:
            if resp.status != 200:
                print(f"Warning: URL verification returned {resp.status}", file=sys.stderr)
    except Exception as e:
        print(f"Warning: URL verification failed: {e}", file=sys.stderr)

    return url, tunnel_proc, server_proc


def main():
    ap = argparse.ArgumentParser(description="Run Maibei upload-audit workflows end-to-end.")
    ap.add_argument("--token", required=True)
    ap.add_argument("--user-id", required=True)
    ap.add_argument("--mode", choices=["upload", "copy", "analyze", "audit", "font", "summary", "full"], required=True)
    ap.add_argument("--sheet-url", help="Existing working sheet URL")
    ap.add_argument("--source-excel-url", default=SOURCE_EXCEL_URL)
    ap.add_argument("--file", action="append", default=[], help="Local file path to upload; repeatable")
    ap.add_argument("--uploaded-json", help="JSON array of uploaded file rows with url/file_type/file_name")
    ap.add_argument("--print-json", action="store_true")
    ap.add_argument("--skip-analyze", action="store_true", help="In full mode, skip analyze step")
    ap.add_argument("--skip-audit", action="store_true", help="In full mode, skip audit step")
    ap.add_argument("--skip-font", action="store_true", help="In full mode, skip font-audit step")
    ap.add_argument("--skip-summary", action="store_true", help="In full mode, skip final summary workflow")
    ap.add_argument("--html", action="store_true", help="Generate HTML report after workflows complete")
    ap.add_argument("--share", action="store_true", help="Share HTML report via trycloudflare tunnel")
    ap.add_argument("--html-only", action="store_true", help="Skip workflows, generate HTML from prior --sheet-url and --uploaded-json")
    ap.add_argument("--html-output", default="audit_report.html", help="Output HTML filename (default: audit_report.html)")
    args = ap.parse_args()

    uploaded_rows = []
    if args.uploaded_json:
        uploaded_rows = json.loads(args.uploaded_json)

    if args.mode in {"upload", "full"} and args.file:
        for f in args.file:
            uploaded_rows.append(upload_file(args.token, args.user_id, f))
        if args.mode == "upload":
            if args.print_json:
                print(json.dumps(uploaded_rows, ensure_ascii=False, indent=2))
            else:
                for row in uploaded_rows:
                    print(row["url"])
            return

    if args.mode == "copy":
        events = run_workflow(args.token, args.user_id, COPY_EXCEL_ARTIFACT_ID, [
            {"name": "variable:scalar:source_excel_url", "default_value": args.source_excel_url}
        ])
        print_events(events)
        return

    sheet_url = args.sheet_url

    if args.mode == "full":
        copy_events = run_workflow(args.token, args.user_id, COPY_EXCEL_ARTIFACT_ID, [
            {"name": "variable:scalar:source_excel_url", "default_value": args.source_excel_url}
        ])
        sheet_url = extract_sheet_url(copy_events)
        if not sheet_url:
            print_events(copy_events)
            raise SystemExit("failed to extract copied sheet url from copy workflow")

        ran_analyze = False
        ran_audit = False
        ran_font = False
        ran_summary = False

        if not args.skip_analyze:
            if not uploaded_rows:
                raise SystemExit("full mode requires uploaded files or --uploaded-json unless --skip-analyze is used")
            analyze_events = run_workflow(args.token, args.user_id, ANALYZE_ARTIFACT_ID, [
                {"name": "variable:scalar:copy_excel_url", "default_value": sheet_url},
                {"name": "variable:dataframe:product_images", "default_value": normalize_uploaded_rows(uploaded_rows)},
            ])
            ran_analyze = True
            if not args.print_json:
                print_events(analyze_events)

        audit_events = None
        font_events = None
        with ThreadPoolExecutor(max_workers=2) as ex:
            futures = {}
            if not args.skip_audit:
                futures["audit"] = ex.submit(run_workflow, args.token, args.user_id, AUDIT_ARTIFACT_ID, shared_sheet_variables(sheet_url))
            if not args.skip_font:
                futures["font"] = ex.submit(run_workflow, args.token, args.user_id, FONT_AUDIT_ARTIFACT_ID, [
                    {"name": "variable:scalar:copy_excel_url", "default_value": sheet_url}
                ])
            for name, fut in futures.items():
                result = fut.result()
                if name == "audit":
                    audit_events = result
                    ran_audit = True
                    if not args.print_json:
                        print_events(audit_events)
                elif name == "font":
                    font_events = result
                    ran_font = True
                    if not args.print_json:
                        print_events(font_events)

        if not args.skip_summary:
            summary_events = run_workflow(args.token, args.user_id, SUMMARY_WORKFLOW_ARTIFACT_ID, shared_sheet_variables(sheet_url))
            ran_summary = True
            if not args.print_json:
                print_events(summary_events)

        print_summary(sheet_url, uploaded_rows, ran_analyze, ran_audit, ran_font, ran_summary)

        # Post-processing: HTML report and sharing
        if args.html:
            html_output = args.html_output
            generate_html_report(sheet_url, uploaded_rows, html_output)
            if args.share:
                print("\n🌐 正在分享到公网...")
                url, tunnel_proc, server_proc = share_via_trycloudflare(html_output)
                print(f"✅ 报告已发布: {url}")
                # Keep processes alive
                try:
                    tunnel_proc.wait()
                except KeyboardInterrupt:
                    tunnel_proc.terminate()
                    server_proc.terminate()

        return

    if not sheet_url:
        raise SystemExit("--sheet-url is required for analyze/audit/font/summary modes")

    if args.mode == "analyze":
        if not uploaded_rows and not args.file:
            raise SystemExit("--uploaded-json or --file is required for analyze")
        events = run_workflow(args.token, args.user_id, ANALYZE_ARTIFACT_ID, [
            {"name": "variable:scalar:copy_excel_url", "default_value": sheet_url},
            {"name": "variable:dataframe:product_images", "default_value": normalize_uploaded_rows(uploaded_rows)},
        ])
        print_events(events)
        return

    if args.mode == "audit":
        events = run_workflow(args.token, args.user_id, AUDIT_ARTIFACT_ID, shared_sheet_variables(sheet_url))
        print_events(events)
        return

    if args.mode == "font":
        events = run_workflow(args.token, args.user_id, FONT_AUDIT_ARTIFACT_ID, [
            {"name": "variable:scalar:copy_excel_url", "default_value": sheet_url}
        ])
        print_events(events)
        return

    if args.mode == "summary":
        events = run_workflow(args.token, args.user_id, SUMMARY_WORKFLOW_ARTIFACT_ID, shared_sheet_variables(sheet_url))
        print_events(events)
        return


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(130)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)