Skill: kelcey2023/auto-ppt (version 0.1.2)

--- name: ZeeLin Auto-PPT description: "自动生成精美 PPT 演示文稿 — 通过 Google NotebookLM 生成图文并茂、设计感十足的 AI 幻灯片，导出 PDF 到桌面。用户需自行登录 NotebookLM 网页版。标题微软雅黑 40 号加粗，排版震撼，逻辑图清晰，内容有深度有创新，引用权威数据。配合 desearch skill 使用效果更好。Keywords: PPT, presentation, slides, NotebookLM, PDF, design, infographic, AI generated." user-invocable: true metadata: {"openclaw":{"emoji":"📊","skillKey":"auto-ppt"}} --- # ZeeLin Auto-PPT — AI 精美演示文稿生成器 📊 通过 Google NotebookLM 一键生成**图文并茂、排版精美、设计震撼**的演示文稿，导出 **PDF** 到桌面。 > ⚠️ **使用前请自行登录 NotebookLM 网页版**（https://notebooklm.google.com/）。登录由用户完成，Agent 只负责在已登录状态下完成创建笔记本、补充来源、粘贴内容、生成幻灯片和下载 PDF。 > 💡 **配合 desearch / deep-research 风格工作流使用效果更好**。若用户要求“深度研究报告大纲、分章节 PDF、最后合并、统一白板手绘风格、先补 YouTube 信息源”，请读取：`/Users/youke/.openclaw/workspace/skills/auto-ppt/references/deep-research-multipart-workflow.md`。 ## 触发与模式选择 ### 模式 A：普通 Auto-PPT 适用于： - 给一个主题，直接生成一份 PDF - 用 NotebookLM 做一份成品幻灯片 - 用户只要单份 PDF/PPT 收口 ### 模式 B：多部分深度研究 Auto-PPT 适用于用户提到这些要求时： - 先做成**具有传播力的深度研究报告大纲** - 拆成 **6–10 个专业研究部分** - **每个部分先单独做一份 PDF** - **最后再合并** - 先去 **YouTube 检索相关视频链接** 作为信息源 - 要 **白板手绘风格 / 布局多样 / 视觉惊艳 / 风格统一** - 参考 `desearch-ppt-1.0.0 2` 的 PPT 风格 如果是模式 B，**只额外读取一个 reference**： - `/Users/youke/.openclaw/workspace/skills/auto-ppt/references/deep-research-multipart-workflow.md` --- ## 🚨 最重要的规则：一次性连贯完成，不要中断 **你必须在一个回合内连续调用所有工具，一次性完成全部步骤。** ❌ 禁止的行为： - 每做一步就停下来向用户汇报，等用户说"继续" - 把每个步骤拆成独立回复 - 用长篇解释代替脚本执行 ✅ 正确的行为： - 生成内容后，**立即**调用 exec 执行脚本 - 所有 tool call 在同一个回合内连续发出 - 最后只给用户**一条简洁汇报** **节省 tokens 规则：** - 不复述显而易见的步骤 - 不把整段内容再次贴回聊天 - 复杂收尾优先走脚本 - 多部分模式下，先定大纲，再分 part 执行，不要一次塞一个超长 prompt --- ## 新默认流程：先补 YouTube 信息源，再生成演示文稿 当用户明确提到以下任一要求时，优先使用这个流程： - “先打开 YouTube 搜关键词，再把视频链接加到 NotebookLM 来源里” - “先加 YouTube 信息源，再做 PPT” - “龙虾管理 / OpenClaw manage 这种主题先搜视频再生成” - “把这个流程封装到自动做 PPT 的 skill 里” ### 标准执行顺序 1. 打开 YouTube 2. 以用户给的主题关键词检索（如 `龙虾管理`、`OpenClaw manage`） 3. 选 2–5 个相关视频链接 4. 打开 NotebookLM，新建 notebook 5. 点击 **添加来源 → 网站** 6. 将 YouTube 视频 URL 逐条插入为网站来源 7. 再把正文内容作为 **Copied text / 复制的文字** 插入 8. 点击 **自定义演示文稿 / Customize presentation** 9. 输入统一风格要求（如白板手绘、布局多样、标题强、信息图感强） 10. 点击生成 11. 下载 PDF 到桌面 12. 如用户要求，继续合并 PDF、清理重复页、导出 PPTX ### 参数约定（给脚本） 优先通过环境变量把这些信息传给脚本，而不是把所有流程写死在聊天里： - `AUTO_PPT_YOUTUBE_QUERY`：YouTube 搜索关键词 - `AUTO_PPT_MAX_YOUTUBE`：最多采集多少个视频链接，默认 3 - `AUTO_PPT_CUSTOM_PROMPT`：NotebookLM 的“自定义演示文稿”风格提示词；未提供时默认使用 whiteboard sketch/doodle 英文风格模板 ### 推荐风格提示词 如果用户没给具体风格，可默认： - `STYLE: Whiteboard sketch/doodle style - Background: light gray paper texture with wooden frame border - Hand-drawn/sketch illustrations in black ink lines - Blue and red accent colors - All Chinese text must be perfectly rendered, clear and readable - Layout should be clean and professional like a real presentation slide Design a beautiful presentation slide with the following content. Arrange text and illustrations naturally for the best visual effect.` 如果用户只说“按默认白板手绘风格做”，优先使用上面这段英文风格提示，不要自行改写弱化。 --- ## ⚡ 主生成脚本 普通模式： ```json {"tool": "exec", "args": {"command": "cat > /tmp/ppt_content.txt << 'CONTENT_EOF'\n你的完整内容文本...\nCONTENT_EOF"}} ``` 然后： ```json {"tool": "exec", "args": {"command": "bash /Users/youke/.openclaw/workspace/skills/auto-ppt/scripts/create_ppt.sh \"$(cat /tmp/ppt_content.txt)\" \"文件名.pdf\""}} ``` 带 YouTube 来源和自定义演示文稿模式： ```json {"tool": "exec", "args": {"command": "AUTO_PPT_YOUTUBE_QUERY='OpenClaw manage' AUTO_PPT_MAX_YOUTUBE=3 AUTO_PPT_CUSTOM_PROMPT='请做成白板手绘风格、强信息图表达、标题有冲击力、布局多样、避免重复版式。' bash /Users/youke/.openclaw/workspace/skills/auto-ppt/scripts/create_ppt.sh \"$(cat /tmp/ppt_content.txt)\" \"OpenClaw龙虾管理学-01-问题定义与总框架.pdf\"", "timeout": 1800}} ``` --- ## 收尾后处理（推荐，最省 token） 当用户提到这些需求时： - 把下载到桌面的 PDF 合并成 1 个 - 再导出成 PPT / PPTX - 把语义重复的页面删除、合并 - 让再次打开的 PPT 逻辑更通顺 优先执行： ```json {"tool": "exec", "args": {"command": "python3 /Users/youke/.openclaw/workspace/skills/auto-ppt/scripts/postprocess_ppt_outputs.py --all-desktop --output-name final_deck", "timeout": 600}} ``` 输出到桌面： - `final_deck.merged.pdf` - `final_deck.cleaned.pdf` - `final_deck.cleaned.pptx` 如需指定顺序： ```json {"tool": "exec", "args": {"command": "python3 /Users/youke/.openclaw/workspace/skills/auto-ppt/scripts/postprocess_ppt_outputs.py --output-name final_deck 第一部分.pdf 第二部分.pdf 第三部分.pdf", "timeout": 600}} ``` --- ## Agent 规则 1. **用 `exec` 执行脚本**，不要直接用 `browser` 工具拼流程 2. 默认主生成阶段先产出 PDF 3. 若用户明确要求“后续收尾 / 合并 / 删重 / 导出 PPT”，优先调用后处理脚本 4. 若用户明确要求“先分 6–10 部分、每部分 PDF、先补 YouTube 信息源”，切到**多部分深度研究模式**并读取对应 reference 5. 对“删语义重复页”，优先低成本脚本启发式处理，不默认调用大模型逐页判断 6. 整体风格要统一，但单页布局要尽量多样 7. 涉及 YouTube 来源时，优先用脚本自动搜索并插入 2–5 个高相关视频 URL，不要只把搜索词写进正文冒充来源 --- ## 设计标准 | 项目 | 要求 | |------|------| | 标题字体 | 微软雅黑，40 号，加粗 | | 整体风格 | 图文并茂、精美震撼、配图精良 | | 逻辑图 | 专业清晰（流程图/对比图/数据图表） | | 内容 | 有创新、有深度、权威数据标注来源 | | 初始输出 | PDF，保存到 `~/Desktop/` | | 收尾输出 | 合并 PDF + cleaned PDF + cleaned PPTX | | 高级模式 | 6–10 部分深度研究 + YouTube 信息源 + 分 part PDF + 最终合并 | --- ## TL;DR - 普通需求：主题 → 长文 → NotebookLM → PDF - 加强版：YouTube 检索 → 视频 URL 加到 NotebookLM 网站来源 → 长文 → 自定义演示文稿 → PDF - 收尾需求：PDF → 合并/删重 → cleaned PDF + PPTX - 深度研究需求：先出 6–10 部分大纲 → 每部分先补 YouTube 信息源 → 分 part 生成 PDF → 最后合并 → 必要时再后处理

path: _meta.json
content: slug: auto-ppt
version: 4.2.0

path: references/deep-research-multipart-workflow.md
content: # Deep-research multipart workflow for Auto-PPT

Use this mode when the user wants any of these:
- 先做成深度研究报告大纲
- 分成 6–10 个专业研究部分
- 每个部分先单独做一份 PDF
- 最后再合并成完整 PPT/PDF
- 先补充 YouTube 视频信息源
- 白板手绘风格 / 视觉惊艳 / 布局多样 / 统一风格
- 参考 `desearch-ppt-1.0.0 2` 的 PPT 风格

## Goal

Given a topic, produce a **high-spreadability deep research deck workflow**:
1. Turn the topic into a 6–10 part professional research outline
2. For each part, gather a few relevant YouTube links first
3. Feed those links plus the part text into NotebookLM as sources
4. Generate one PDF per part in a unified visual style
5. Merge the PDFs at the end
6. If requested, run the postprocess script to produce cleaned PDF + PPTX

## Output shape

Default structure: 6–10 parts.
Each part should feel like one chapter in a serious research report, not random bullets.

Suggested chapter types:
1. 背景与问题定义
2. 市场/行业现状
3. 关键技术或方法演进
4. 典型案例与代表玩家
5. 商业模式/传播逻辑/增长机制
6. 风险、争议与限制
7. 未来趋势与判断
8. 行动建议 / 结论

If the topic needs more granularity, expand to 9–10 parts; if narrower, compress to 6–7.

## Hard rules

- The first step is **not** generating one giant body of text.
- First create a **传播力强的深度研究大纲** with 6–10 parts.
- Each part must have:
  - part title
  - 3–5 key questions
  - the point of that part in the overall narrative
- Keep a single unified style across all parts.
- Style target: **whiteboard sketch / doodle / visual-explainer**, with strong contrast and varied layouts.
- Layouts should vary across parts, but the overall deck must still look like one family.

## YouTube-source rule

Before generating each part's NotebookLM deck:
1. Search YouTube for that part's topic
2. Prefer:
   - official talks
   - conference videos
   - lectures
   - credible creator explainers
   - product demos if relevant
3. Capture 2–5 links per part
4. Paste those links into NotebookLM as sources before or alongside the text

Do not dump a huge list. A few high-signal links are better.

Suggested searches:
- `<topic> overview YouTube`
- `<part title> explained YouTube`
- `<topic> conference talk YouTube`
- `<topic> case study YouTube`

## Narrative design rule

The deck should feel like a publishable deep-research presentation with strong shareability.
That means:
- opening should hook with a sharp framing
- middle sections should move from context -> evidence -> cases -> conflict
- later sections should move to judgment, implications, and next steps
- each part should naturally lead to the next

Think of the final merged deck as one long story, not a folder of unrelated PDFs.

## Unified style rule

Reference style benchmark:
- `/Users/youke/Downloads/desearch-ppt-1.0.0 2`

Desired visual qualities:
- whiteboard hand-drawn feel
- visually surprising but still readable
- mixed layouts, not repetitive left-text-right-image slides
- clear title hierarchy
- use diagrams, arrows, maps, dashboards, split comparisons, timelines
- maintain the same visual language, footer convention, and title rhythm across parts

## Execution workflow

### Step 1: create the 6–10 part outline
Produce a deep-research outline first.
Each part should include:
- `part_num`
- `part_title`
- `role_in_story`
- `key_questions`
- `style_hint`

### Step 2: generate one part at a time
For each part:
1. Search YouTube for that part
2. Collect 2–5 relevant links
3. Write the part text in a NotebookLM-friendly way
4. Keep explicit visual cues in the text so NotebookLM produces varied pages
5. Export one PDF per part

### Step 3: naming convention
Use stable filenames like:
- `主题-01-背景与问题定义.pdf`
- `主题-02-行业现状.pdf`
- `主题-03-技术演进.pdf`

Avoid generic names like `part1.pdf` if the topic is user-facing.

### Step 4: final merge
After all part PDFs are created:
- merge in part order
- if user asked for cleanup, run `postprocess_ppt_outputs.py`

## Token-efficiency guidance

- Do not explain the whole workflow to the user every time.
- When this mode is clearly requested, execute directly.
- Keep SKILL.md short; use this reference only when needed.
- Generate the outline first, then work part-by-part.
- Avoid giant monolithic prompts when part-wise prompts will do.

## Recommended final commands

### Merge ordered PDFs
```bash
bash /Users/youke/.openclaw/workspace/skills/auto-ppt/scripts/merge_pdf_on_desktop.sh 最终研究报告.pdf 主题-01-*.pdf 主题-02-*.pdf
```

### Postprocess after merge
```bash
python3 /Users/youke/.openclaw/workspace/skills/auto-ppt/scripts/postprocess_ppt_outputs.py --output-name 主题_final 主题-01-背景与问题定义.pdf 主题-02-行业现状.pdf 主题-03-技术演进.pdf
```

## What to tell the user at the end

Keep the final reply short:
- how many parts were created
- whether YouTube sources were added
- where the merged PDF/PPTX is
- whether duplicate-page cleanup was applied


path: scripts/create_ppt.sh
content: #!/bin/bash
# create_ppt.sh — Automate NotebookLM to create a presentation from text content
# Optional env vars:
#   AUTO_PPT_YOUTUBE_QUERY   YouTube search query; if set, script will collect 2–N video URLs and add them as website sources first
#   AUTO_PPT_MAX_YOUTUBE     Max number of YouTube URLs to add (default 3)
#   AUTO_PPT_CUSTOM_PROMPT   Customize presentation prompt/style text
# Usage: bash create_ppt.sh "content text" ["output filename"]

set -euo pipefail

CONTENT="$1"
OUTPUT_NAME="${2:-PPT_$(date +%Y%m%d_%H%M).pdf}"
OUTPUT_PATH="$HOME/Desktop/$OUTPUT_NAME"
CLI="${OPENCLAW_CLI:-openclaw browser}"
YOUTUBE_QUERY="${AUTO_PPT_YOUTUBE_QUERY:-}"
MAX_YOUTUBE="${AUTO_PPT_MAX_YOUTUBE:-3}"
CUSTOM_PROMPT="${AUTO_PPT_CUSTOM_PROMPT:-STYLE: Whiteboard sketch/doodle style
- Background: light gray paper texture with wooden frame border
- Hand-drawn/sketch illustrations in black ink lines
- Blue and red accent colors
- All Chinese text must be perfectly rendered, clear and readable
- Layout should be clean and professional like a real presentation slide

Design a beautiful presentation slide with the following content. Arrange text and illustrations naturally for the best visual effect.}"
TMP_DIR="${TMPDIR:-/tmp}/auto-ppt"
mkdir -p "$TMP_DIR"
YT_LINKS_FILE="$TMP_DIR/youtube-links.txt"

if [ -z "$CONTENT" ]; then
  echo "ERROR: No content provided"
  echo "Usage: bash create_ppt.sh \"content text\" [\"output_filename.pdf\"]"
  exit 1
fi

wait_and_snap() {
  local wait_sec="${1:-3}"
  sleep "$wait_sec"
  $CLI snapshot 2>/dev/null
}

find_ref() {
  local snap="$1"
  local pattern="$2"
  echo "$snap" | grep -i "$pattern" | grep -oE 'ref=e[0-9]+' | tail -1 | sed 's/ref=//'
}

find_button_ref() {
  local snap="$1"
  local pattern="$2"
  echo "$snap" | grep -i "button.*$pattern" | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=//'
}

click_ref() {
  local ref="$1"
  local label="$2"
  if [ -z "$ref" ]; then
    echo "ERROR: Could not find element: $label"
    return 1
  fi
  echo "  Clicking: $label ($ref)"
  $CLI click "$ref" 2>/dev/null
}

find_button_by_patterns() {
  local snap="$1"
  shift
  local pattern
  for pattern in "$@"; do
    local ref
    ref=$(find_button_ref "$snap" "$pattern" 2>/dev/null || true)
    if [ -n "$ref" ]; then
      printf '%s' "$ref"
      return 0
    fi
  done
  return 1
}

is_login_prompt() {
  local snap="$1"
  if echo "$snap" | grep -qi "sign\\.in\|登录\|login"; then
    if ! echo "$snap" | grep -qi "NotebookLM\|Notebook\|Studio"; then
      return 0
    fi
  fi
  return 1
}

find_presentation_ref() {
  local snap="$1"
  local ref
  local patterns=(
    'button.*个来源.*前'
    'button.*source.*ago'
    'button.*个来源'
    'button.*source'
    'generic.*cursor=pointer.*个来源'
    'generic.*cursor=pointer.*source'
  )
  for pattern in "${patterns[@]}"; do
    ref=$(echo "$snap" | grep -i "$pattern" | grep -v "disabled\|正在生成\|generating" | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=//') || true
    if [ -n "$ref" ]; then
      printf '%s' "$ref"
      return 0
    fi
  done
  return 1
}

extract_youtube_links() {
  if [ -z "$YOUTUBE_QUERY" ]; then
    : > "$YT_LINKS_FILE"
    return 0
  fi

  echo ""
  echo "=== Step YT/1: Searching YouTube for sources ==="
  local encoded_query snap
  encoded_query=$(python3 - <<'PY' "$YOUTUBE_QUERY"
import sys, urllib.parse
print(urllib.parse.quote(sys.argv[1]))
PY
)
  $CLI open "https://www.youtube.com/results?search_query=${encoded_query}" 2>/dev/null
  snap=$(wait_and_snap 6)

  # Path A: direct DOM extraction
  $CLI evaluate --fn "() => { const anchors = Array.from(document.querySelectorAll('a[href*="/watch?v="]')); const urls = anchors.map(a => a.href).filter(Boolean); const uniq = [...new Set(urls)].filter(u => /^https:\/\/www\.youtube\.com\/watch\?v=/.test(u)); return uniq.slice(0, ${MAX_YOUTUBE}).join('\\n'); }" 2>/dev/null | grep -Eo 'https://www\.youtube\.com/watch\?[^[:space:]"'\''<>]+' | head -n "${MAX_YOUTUBE}" > "$YT_LINKS_FILE" || true

  # Path B: snapshot text fallback
  if [ ! -s "$YT_LINKS_FILE" ]; then
    printf '%s\n' "$snap" | grep -Eo 'https://www\.youtube\.com/watch\?[^[:space:]"'\''<>]+' | head -n "${MAX_YOUTUBE}" > "$YT_LINKS_FILE" || true
  fi

  # Path C: href fragments fallback
  if [ ! -s "$YT_LINKS_FILE" ]; then
    printf '%s\n' "$snap" | grep -Eo '/watch\?v=[A-Za-z0-9_-]+' | sed 's#^#https://www.youtube.com#' | head -n "${MAX_YOUTUBE}" > "$YT_LINKS_FILE" || true
  fi

  # Normalize escaped newlines that may come back from CLI output
  if [ -s "$YT_LINKS_FILE" ]; then
    python3 - <<'PY' "$YT_LINKS_FILE"
from pathlib import Path
import sys
p = Path(sys.argv[1])
text = p.read_text()
text = text.replace('\\n', '\n')
p.write_text(text)
PY
  fi

  # Final de-dup in case multiple selectors resolve to the same video
  if [ -s "$YT_LINKS_FILE" ]; then
    python3 - <<'PY' "$YT_LINKS_FILE"
from pathlib import Path
import sys
p = Path(sys.argv[1])
seen = set()
out = []
for line in p.read_text().splitlines():
    u = line.strip()
    if not u or u in seen:
        continue
    seen.add(u)
    out.append(u)
p.write_text('\\n'.join(out) + ('\\n' if out else ''))
PY
  fi

  if [ ! -s "$YT_LINKS_FILE" ]; then
    echo "  WARNING: Could not auto-extract YouTube links; continuing without video sources"
    : > "$YT_LINKS_FILE"
    return 0
  fi

  echo "  Collected YouTube links:"
  sed 's/^/    - /' "$YT_LINKS_FILE"
}

add_website_source() {
  local url="$1"
  local snap ref
  echo "  Adding website source: $url"

  snap=$(wait_and_snap 2)

  # If add-source dialog is already open, go straight to Website.
  ref=$(find_button_by_patterns "$snap" 'button "网站"' '网站\|[Ww]ebsite\|[Ll]ink' '网址\|[Uu][Rr][Ll]') || true
  if [ -z "$ref" ]; then
    ref=$(find_button_by_patterns "$snap" '添加来源\|[Aa]dd source' '添加\|[Aa]dd') || true
    if [ -n "$ref" ]; then
      click_ref "$ref" "Add source"
      snap=$(wait_and_snap 3)
      ref=$(find_button_by_patterns "$snap" 'button "网站"' '网站\|[Ww]ebsite\|[Ll]ink' '网址\|[Uu][Rr][Ll]') || true
    fi
  fi

  click_ref "$ref" "Website source option"
  snap=$(wait_and_snap 3)

  ref=$(find_ref "$snap" 'textbox.*https\|textbox.*网址\|textbox.*URL\|textbox.*查询\|textbox')
  if [ -z "$ref" ]; then
    echo "ERROR: Could not find website URL input"
    return 1
  fi
  $CLI type "$ref" "$url" 2>/dev/null
  snap=$(wait_and_snap 1)

  ref=$(find_button_by_patterns "$snap" '提交\|插入\|[Ii]nsert\|添加' '继续\|[Aa]dd\|[Ss]ubmit') || true
  click_ref "$ref" "Insert website source"
  wait_and_snap 5 >/dev/null || true
}

apply_custom_prompt() {
  if [ -z "$CUSTOM_PROMPT" ]; then
    return 0
  fi

  echo ""
  echo "=== Step 5.5/7: Setting custom presentation prompt ==="
  local snap ref
  snap=$(wait_and_snap 2)

  ref=$(find_button_by_patterns "$snap" '自定义演示文稿\|[Cc]ustomize.*presentation' '自定义\|[Cc]ustomize') || true
  if [ -z "$ref" ]; then
    ref=$(find_ref "$snap" 'textbox.*自定义\|textbox.*演示文稿\|textbox.*presentation\|textbox.*slides') || true
  fi

  if [ -n "$ref" ]; then
    if echo "$ref" | grep -q '^e'; then
      click_ref "$ref" "Customize presentation"
      snap=$(wait_and_snap 2)
      ref=$(find_ref "$snap" 'textbox.*自定义\|textbox.*演示\|textbox.*presentation\|textbox') || true
    fi
  fi

  if [ -z "$ref" ]; then
    echo "  WARNING: Could not find custom-prompt input; continuing with default NotebookLM generation"
    return 0
  fi

  echo "  Typing custom prompt (${#CUSTOM_PROMPT} chars)..."
  $CLI type "$ref" "$CUSTOM_PROMPT" 2>/dev/null || true
  wait_and_snap 1 >/dev/null || true
}

echo "============================================"
echo "  NotebookLM PPT Generator"
echo "============================================"
echo "Output: $OUTPUT_PATH"
echo ""

extract_youtube_links

echo "=== Step 1/7: Opening NotebookLM ==="
$CLI open "https://notebooklm.google.com/" 2>/dev/null
SNAP=$(wait_and_snap 5)

if is_login_prompt "$SNAP"; then
  echo "Detected login prompt. Waiting 6s for the session to refresh..."
  SNAP=$(wait_and_snap 6)
  if is_login_prompt "$SNAP"; then
    echo "WARNING: NotebookLM still shows a sign-in screen. Please sign in manually, then re-run this script."
    exit 1
  fi
  echo "Login prompt cleared."
fi
echo "  NotebookLM loaded"

echo ""
echo "=== Step 2/7: Creating new notebook ==="
REF=$(find_button_ref "$SNAP" "新建笔记本\|[Nn]ew notebook")
if [ -z "$REF" ]; then
  REF=$(find_button_ref "$SNAP" "新建\|[Cc]reate.*new")
fi
click_ref "$REF" "New notebook button"
SNAP=$(wait_and_snap 6)
echo "  New notebook created"

if [ -s "$YT_LINKS_FILE" ]; then
  echo ""
  echo "=== Step 2.5/7: Adding YouTube website sources ==="
  while IFS= read -r url; do
    [ -n "$url" ] || continue
    add_website_source "$url"
    SNAP=$(wait_and_snap 3)
  done < "$YT_LINKS_FILE"
  echo "  YouTube sources added"
fi

echo ""
echo "=== Step 3/7: Finding 'Copied text' option ==="
REF=$(find_button_by_patterns "$SNAP" \
  '复制的文字\|[Cc]opied text\|[Pp]aste text' \
  'content_paste\|粘贴')

if [ -z "$REF" ]; then
  echo "  Dialog not auto-opened, clicking Add source..."
  ADD_REF=$(find_button_ref "$SNAP" "添加来源\|[Aa]dd source")
  if [ -z "$ADD_REF" ]; then
    ADD_REF=$(find_button_ref "$SNAP" "add\|添加")
  fi
  if [ -n "$ADD_REF" ]; then
    click_ref "$ADD_REF" "Add source"
    SNAP=$(wait_and_snap 4)
    REF=$(find_button_by_patterns "$SNAP" \
      '复制的文字\|[Cc]opied text\|[Pp]aste text' \
      'content_paste\|粘贴')
  fi
fi

click_ref "$REF" "Copied text option"
SNAP=$(wait_and_snap 3)
echo "  Text source dialog opened"

echo ""
echo "=== Step 4/7: Pasting content ==="
REF=$(find_ref "$SNAP" 'textbox.*粘贴\|textbox.*[Pp]aste\|textbox.*在此处')
if [ -z "$REF" ]; then
  REF=$(find_ref "$SNAP" 'textbox.*active\|textbox.*placeholder')
fi
if [ -z "$REF" ]; then
  REF=$(find_ref "$SNAP" "textbox\|textarea")
fi
if [ -z "$REF" ]; then
  echo "ERROR: Could not find text input area"
  echo "SNAPSHOT:"
  echo "$SNAP" | head -30
  exit 1
fi
echo "  Found text input: $REF"
echo "  Typing content (${#CONTENT} chars)..."
$CLI type "$REF" "$CONTENT" 2>/dev/null
SNAP=$(wait_and_snap 2)

REF=$(find_button_ref "$SNAP" "插入\|[Ii]nsert\|[Ss]ubmit")
if [ -z "$REF" ]; then
  REF=$(find_button_ref "$SNAP" "确[定认]\|[Aa]dd")
fi
click_ref "$REF" "Insert button"
SNAP=$(wait_and_snap 5)
echo "  Content inserted"

apply_custom_prompt
SNAP=$(wait_and_snap 2)

echo ""
echo "=== Step 5/7: Generating presentation ==="
REF=$(find_button_by_patterns "$SNAP" \
  '演示文稿\|[Pp]resentation' \
  '[Ss]lides\|幻灯片')
if [ -z "$REF" ]; then
  SNAP=$(wait_and_snap 5)
  REF=$(find_button_by_patterns "$SNAP" \
    '演示文稿\|[Pp]resentation' \
    '[Ss]lides\|幻灯片')
fi
click_ref "$REF" "Generate presentation"
echo "  Generating slides... (this may take 30-60 seconds)"

READY=false
for i in $(seq 1 90); do
  sleep 10
  SNAP=$($CLI snapshot 2>/dev/null)
  if echo "$SNAP" | grep -qi "已准备就绪\|幻灯片.*已准备"; then
    READY=true
    echo "  Presentation ready! (detected: 已准备就绪)"
    break
  fi
  if echo "$SNAP" | grep -qi "button.*个来源.*前"; then
    READY=true
    echo "  Presentation ready! (detected: entry with timestamp)"
    break
  fi
  if echo "$SNAP" | grep -qi "正在生成\|generating"; then
    echo "  Still generating... ($((i*10))s elapsed)"
  else
    echo "  Waiting... ($((i*10))s elapsed)"
  fi
done

if [ "$READY" = false ]; then
  echo "WARNING: Timed out after 900s. Taking final snapshot..."
  SNAP=$($CLI snapshot 2>/dev/null)
fi

echo ""
echo "=== Step 6/7: Opening presentation ==="
REF=$(find_presentation_ref "$SNAP")
if [ -z "$REF" ]; then
  echo "WARNING: Could not locate presentation. Capturing extra snapshot..."
  sleep 8
  SNAP=$($CLI snapshot 2>/dev/null)
  REF=$(find_presentation_ref "$SNAP")
fi

if [ -z "$REF" ]; then
  echo "ERROR: Presentation entry still missing after retries."
  echo "Last snapshot snippets:"
  echo "$SNAP" | grep -i "presentation\|source\|Studio" | head -20
  exit 1
fi

click_ref "$REF" "Open presentation"
SNAP=$(wait_and_snap 5)
echo "  Presentation opened"

echo ""
echo "=== Step 7/7: Downloading PDF ==="
REF=$(find_button_by_patterns "$SNAP" \
  '更多选项\|more_horiz' \
  '菜单.*更多\|[Mm]ore.*options')
if [ -z "$REF" ]; then
  REF=$(find_button_ref "$SNAP" "[Mm]ore.*option\|更多")
fi
click_ref "$REF" "More options menu"
SNAP=$(wait_and_snap 3)

REF=$(echo "$SNAP" | grep -i 'menuitem.*PDF' | grep -oE 'ref=e[0-9]+' | head -1 | sed 's/ref=//')
if [ -z "$REF" ]; then
  REF=$(find_button_by_patterns "$SNAP" \
    '下载 PDF\|[Dd]ownload.*PDF\|PDF.*文档' \
    '保存.*PDF')
fi

if [ -z "$REF" ]; then
  echo "ERROR: Could not find PDF download option"
  echo "SNAPSHOT MENU:"
  echo "$SNAP" | grep -i "menu\|PDF\|下载\|download" | head -20
  echo "  Please download manually from the open presentation"
  exit 1
fi

echo "  Downloading PDF to $OUTPUT_PATH ..."
$CLI download "$REF" "$OUTPUT_PATH" --timeout-ms 30000 2>/dev/null
sleep 3

if [ -f "$OUTPUT_PATH" ]; then
  SIZE=$(du -h "$OUTPUT_PATH" | cut -f1)
  echo ""
  echo "============================================"
  echo "  SUCCESS!"
  echo "  File: $OUTPUT_PATH"
  echo "  Size: $SIZE"
  echo "============================================"
else
  echo ""
  echo "WARNING: PDF file not found at $OUTPUT_PATH"
  echo "  The download may still be in progress."
  echo "  Check ~/Desktop/ for the file."
fi


path: scripts/merge_pdf_on_desktop.sh
content: #!/usr/bin/env bash
# 合并桌面上的多个 PDF 为一个文件（PPT skill 配套：生成多份 PDF 后合并）
# Usage:
#   bash merge_pdf_on_desktop.sh [输出文件名.pdf] [PDF1.pdf PDF2.pdf ...]
#   bash merge_pdf_on_desktop.sh merged.pdf           # 合并 Desktop 下所有 PDF（按修改时间排序）
#   bash merge_pdf_on_desktop.sh merged.pdf a.pdf b.pdf  # 只合并指定文件（顺序即合并顺序）
# 输出默认在 ~/Desktop/

set -e
DESKTOP="${DESKTOP:-$HOME/Desktop}"
OUTPUT_NAME="${1:-merged_$(date +%Y%m%d_%H%M).pdf}"
shift || true

# 收集要合并的 PDF 列表
PDFS=()
if [ $# -eq 0 ]; then
  # 无额外参数：合并桌面所有 PDF（按文件名排序）
  while IFS= read -r f; do PDFS+=("$f"); done < <(find "$DESKTOP" -maxdepth 1 -name "*.pdf" -type f 2>/dev/null | sort)
else
  # 有参数：按参数顺序合并（可带路径或仅文件名，在 Desktop 下找）
  PDFS=()
  for f in "$@"; do
    if [ -f "$f" ]; then
      PDFS+=("$f")
    elif [ -f "$DESKTOP/$f" ]; then
      PDFS+=("$DESKTOP/$f")
    else
      echo "WARNING: not found: $f"
    fi
  done
fi

if [ ${#PDFS[@]} -eq 0 ]; then
  echo "No PDF files to merge on Desktop."
  exit 1
fi

if [ ${#PDFS[@]} -eq 1 ]; then
  echo "Only one PDF, copying to $OUTPUT_NAME"
  cp "${PDFS[0]}" "$DESKTOP/$OUTPUT_NAME"
  echo "Done: $DESKTOP/$OUTPUT_NAME"
  exit 0
fi

OUTPUT_PATH="$DESKTOP/$OUTPUT_NAME"
echo "Merging ${#PDFS[@]} PDFs -> $OUTPUT_PATH"

# 优先用 qpdf（brew install qpdf）
if command -v qpdf >/dev/null 2>&1; then
  qpdf --empty --pages "${PDFS[@]}" -- "$OUTPUT_PATH"
  echo "Done (qpdf): $OUTPUT_PATH"
  exit 0
fi

# 其次用 gs
if command -v gs >/dev/null 2>&1; then
  gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile="$OUTPUT_PATH" "${PDFS[@]}"
  echo "Done (gs): $OUTPUT_PATH"
  exit 0
fi

# 再用 Python pypdf（pip install pypdf）
if python3 -c "import pypdf" 2>/dev/null; then
  python3 - "$OUTPUT_PATH" "${PDFS[@]}" << 'PY'
import sys
from pypdf import PdfMerger
out = sys.argv[1]
paths = sys.argv[2:]
m = PdfMerger()
for p in paths:
    m.append(p)
m.write(out)
m.close()
print("Done (pypdf):", out)
PY
  exit 0
fi

# 最后尝试 pdfunite（poppler）
if command -v pdfunite >/dev/null 2>&1; then
  pdfunite "${PDFS[@]}" "$OUTPUT_PATH"
  echo "Done (pdfunite): $OUTPUT_PATH"
  exit 0
fi

echo "No PDF merger found. Install one of: qpdf (brew install qpdf), gs (brew install gs), or pip install pypdf"
exit 1


path: scripts/postprocess_ppt_outputs.py
content: #!/usr/bin/env python3
"""
Post-process Auto-PPT outputs on Desktop:
1) merge PDFs
2) remove/merge semantically repetitive pages (text-similarity heuristic)
3) export a cleaned PPTX to Desktop

Requires:
- pypdf (`python3 -m pip install pypdf`)
- python-pptx (already importable as `pptx` in this environment)

Usage:
  python3 postprocess_ppt_outputs.py --output-name final_deck --all-desktop
  python3 postprocess_ppt_outputs.py --output-name final_deck a.pdf b.pdf c.pdf

Outputs:
  ~/Desktop/final_deck.merged.pdf
  ~/Desktop/final_deck.cleaned.pdf
  ~/Desktop/final_deck.cleaned.pptx
"""

from __future__ import annotations

import argparse
import re
import sys
from collections import Counter
from dataclasses import dataclass
from difflib import SequenceMatcher
from io import BytesIO
from pathlib import Path
from typing import Iterable, List, Sequence

try:
    from pypdf import PdfReader, PdfWriter
except Exception:
    print("ERROR: pypdf is required. Install with: python3 -m pip install pypdf", file=sys.stderr)
    raise

from pptx import Presentation
from pptx.util import Inches, Pt

DESKTOP = Path.home() / "Desktop"


@dataclass
class PageInfo:
    source_pdf: Path
    source_index: int
    global_index: int
    text: str
    norm: str
    title: str
    bullets: List[str]


def normalize_text(text: str) -> str:
    text = text or ""
    text = text.replace("\x00", " ")
    text = re.sub(r"\s+", " ", text)
    text = re.sub(r"[^\w\u4e00-\u9fff ]+", " ", text.lower())
    text = re.sub(r"\b(page|slide|figure|chart|table|source|references?)\b", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def first_nontrivial_line(text: str) -> str:
    for line in (text or "").splitlines():
        line = re.sub(r"\s+", " ", line).strip(" -•·\t")
        if len(line) >= 8:
            return line[:80]
    return "Untitled"


def make_bullets(text: str, limit: int = 4) -> List[str]:
    parts = []
    for line in text.splitlines():
        line = re.sub(r"\s+", " ", line).strip(" -•·\t")
        if len(line) >= 12:
            parts.append(line)
    if not parts:
        text2 = re.sub(r"\s+", " ", text).strip()
        parts = re.split(r"(?<=[。！？.!?])\s+", text2)
        parts = [p.strip() for p in parts if len(p.strip()) >= 12]
    deduped = []
    seen = set()
    for p in parts:
        key = normalize_text(p)
        if not key or key in seen:
            continue
        seen.add(key)
        deduped.append(p[:120])
        if len(deduped) >= limit:
            break
    return deduped or ["See cleaned PDF for original layout."]


def token_set(s: str) -> set[str]:
    return set(t for t in s.split() if len(t) >= 2)


def jaccard(a: str, b: str) -> float:
    sa, sb = token_set(a), token_set(b)
    if not sa or not sb:
        return 0.0
    return len(sa & sb) / len(sa | sb)


def similarity(a: str, b: str) -> float:
    if not a or not b:
        return 0.0
    seq = SequenceMatcher(None, a[:4000], b[:4000]).ratio()
    jac = jaccard(a, b)
    return max(seq, jac)


def collect_input_pdfs(paths: Sequence[str], all_desktop: bool) -> List[Path]:
    if all_desktop:
        pdfs = sorted(DESKTOP.glob("*.pdf"), key=lambda p: p.name.lower())
    else:
        pdfs = []
        for raw in paths:
            p = Path(raw).expanduser()
            if not p.exists():
                candidate = DESKTOP / raw
                if candidate.exists():
                    p = candidate
            if not p.exists() or p.suffix.lower() != ".pdf":
                print(f"WARN: skipped missing/non-pdf: {raw}", file=sys.stderr)
                continue
            pdfs.append(p)
    pdfs = [p for p in pdfs if p.is_file()]
    if not pdfs:
        raise SystemExit("No input PDFs found.")
    return pdfs


def load_pdf_reader(path: Path) -> PdfReader:
    # Read bytes first to avoid macOS/iCloud Desktop weirdness like:
    # OSError: [Errno 11] Resource deadlock avoided
    data = path.read_bytes()
    return PdfReader(BytesIO(data))


def merge_pdfs(inputs: Sequence[Path], out_path: Path) -> None:
    writer = PdfWriter()
    for path in inputs:
        reader = load_pdf_reader(path)
        for page in reader.pages:
            writer.add_page(page)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with out_path.open("wb") as f:
        writer.write(f)


def extract_pages(inputs: Sequence[Path]) -> List[PageInfo]:
    pages: List[PageInfo] = []
    global_idx = 0
    for pdf in inputs:
        reader = load_pdf_reader(pdf)
        for i, page in enumerate(reader.pages):
            text = page.extract_text() or ""
            norm = normalize_text(text)
            title = first_nontrivial_line(text)
            bullets = make_bullets(text)
            pages.append(PageInfo(pdf, i, global_idx, text, norm, title, bullets))
            global_idx += 1
    return pages


def should_drop(page: PageInfo, kept: Iterable[PageInfo], threshold: float) -> bool:
    if len(page.norm) < 40:
        return False
    for prev in kept:
        if len(prev.norm) < 40:
            continue
        score = similarity(page.norm, prev.norm)
        shorter = min(len(page.norm), len(prev.norm))
        longer = max(len(page.norm), len(prev.norm))
        containment = shorter / longer if longer else 0
        if score >= threshold:
            return True
        if containment >= 0.92 and (page.norm in prev.norm or prev.norm in page.norm):
            return True
    return False


def dedupe_pages(pages: Sequence[PageInfo], threshold: float) -> List[PageInfo]:
    kept: List[PageInfo] = []
    for p in pages:
        if not should_drop(p, kept, threshold):
            kept.append(p)
    return kept


def write_clean_pdf(inputs: Sequence[Path], kept: Sequence[PageInfo], out_path: Path) -> None:
    writer = PdfWriter()
    cache = {p: load_pdf_reader(p) for p in {k.source_pdf for k in kept}}
    for info in kept:
        writer.add_page(cache[info.source_pdf].pages[info.source_index])
    with out_path.open("wb") as f:
        writer.write(f)


def choose_slide_title(info: PageInfo, idx: int) -> str:
    title = info.title.strip()
    if title.lower() == "untitled":
        title = f"Slide {idx}"
    if len(title) > 70:
        title = title[:67] + "..."
    return title


def build_pptx(kept: Sequence[PageInfo], out_path: Path) -> None:
    prs = Presentation()
    prs.slide_width = Inches(13.333)
    prs.slide_height = Inches(7.5)

    cover = prs.slides.add_slide(prs.slide_layouts[0])
    cover.shapes.title.text = "Cleaned Presentation"
    cover.placeholders[1].text = f"Merged + de-duplicated from {len(kept)} pages"

    for idx, info in enumerate(kept, start=1):
        slide = prs.slides.add_slide(prs.slide_layouts[1])
        slide.shapes.title.text = choose_slide_title(info, idx)
        tf = slide.placeholders[1].text_frame
        tf.clear()
        bullets = info.bullets[:4]
        for j, bullet in enumerate(bullets):
            p = tf.paragraphs[0] if j == 0 else tf.add_paragraph()
            p.text = bullet
            p.level = 0
            p.font.size = Pt(20)
        tx = slide.shapes.add_textbox(Inches(0.4), Inches(6.7), Inches(12.0), Inches(0.4))
        p = tx.text_frame.paragraphs[0]
        p.text = f"Source: {info.source_pdf.name} · page {info.source_index + 1}"
        p.font.size = Pt(9)

    prs.save(str(out_path))


def print_summary(inputs: Sequence[Path], pages: Sequence[PageInfo], kept: Sequence[PageInfo], merged: Path, cleaned: Path, pptx: Path) -> None:
    removed = len(pages) - len(kept)
    source_counts = Counter(p.source_pdf.name for p in kept)
    print("=== Auto-PPT postprocess summary ===")
    print(f"Inputs: {len(inputs)} PDFs")
    for p in inputs:
        print(f" - {p.name}")
    print(f"Pages before: {len(pages)}")
    print(f"Pages kept:   {len(kept)}")
    print(f"Pages removed:{removed}")
    print(f"Merged PDF:   {merged}")
    print(f"Clean PDF:    {cleaned}")
    print(f"PPTX:         {pptx}")
    print("Page distribution after cleaning:")
    for name, count in source_counts.items():
        print(f" - {name}: {count}")


def parse_args() -> argparse.Namespace:
    ap = argparse.ArgumentParser()
    ap.add_argument("pdfs", nargs="*", help="Input PDF files (names or paths).")
    ap.add_argument("--all-desktop", action="store_true", help="Use all PDFs on Desktop.")
    ap.add_argument("--output-name", default="auto_ppt_final", help="Base output name without extension.")
    ap.add_argument("--threshold", type=float, default=0.88, help="Similarity threshold for duplicate removal.")
    return ap.parse_args()


def main() -> int:
    args = parse_args()
    inputs = collect_input_pdfs(args.pdfs, args.all_desktop)
    base = re.sub(r"[^A-Za-z0-9._-]+", "_", args.output_name).strip("_") or "auto_ppt_final"
    merged = DESKTOP / f"{base}.merged.pdf"
    cleaned = DESKTOP / f"{base}.cleaned.pdf"
    pptx = DESKTOP / f"{base}.cleaned.pptx"

    merge_pdfs(inputs, merged)
    pages = extract_pages(inputs)
    kept = dedupe_pages(pages, args.threshold)
    write_clean_pdf(inputs, kept, cleaned)
    build_pptx(kept, pptx)
    print_summary(inputs, pages, kept, merged, cleaned, pptx)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


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