技能备份 - 2026-04-15 (40个技能)
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"speech-recognition": {
|
||||
"name": "speech-recognition",
|
||||
"zip_url": "http://lightmake.site/api/v1/download?slug=speech-recognition",
|
||||
"source": "skillhub",
|
||||
"version": "1.0.1"
|
||||
},
|
||||
"funasr-transcribe-skill": {
|
||||
"name": "FunASR 语音转录",
|
||||
"zip_url": "http://lightmake.site/api/v1/download?slug=funasr-transcribe-skill",
|
||||
"source": "skillhub",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"voice-message": {
|
||||
"name": "Voice Message",
|
||||
"zip_url": "http://lightmake.site/api/v1/download?slug=voice-message",
|
||||
"source": "skillhub",
|
||||
"version": "1.0.4"
|
||||
},
|
||||
"ai-ppt-generator": {
|
||||
"name": "Ai Ppt Generator",
|
||||
"zip_url": "https://lightmake.site/api/v1/download?slug=ai-ppt-generator",
|
||||
"source": "skillhub",
|
||||
"version": "1.1.2"
|
||||
},
|
||||
"tavily-search": {
|
||||
"name": "Tavily Web Search",
|
||||
"zip_url": "https://lightmake.site/api/v1/download?slug=tavily-search",
|
||||
"source": "skillhub",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"summarize": {
|
||||
"name": "Summarize",
|
||||
"zip_url": "https://lightmake.site/api/v1/download?slug=summarize",
|
||||
"source": "skillhub",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"agent-browser": {
|
||||
"name": "Agent Browser",
|
||||
"zip_url": "https://lightmake.site/api/v1/download?slug=agent-browser",
|
||||
"source": "skillhub",
|
||||
"version": "0.2.0"
|
||||
},
|
||||
"find-skills": {
|
||||
"name": "Find Skills",
|
||||
"zip_url": "https://lightmake.site/api/v1/download?slug=find-skills",
|
||||
"source": "skillhub",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"github": {
|
||||
"name": "Github",
|
||||
"zip_url": "https://lightmake.site/api/v1/download?slug=github",
|
||||
"source": "skillhub",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"obsidian": {
|
||||
"name": "Obsidian",
|
||||
"zip_url": "https://lightmake.site/api/v1/download?slug=obsidian",
|
||||
"source": "skillhub",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"weather": {
|
||||
"name": "Weather",
|
||||
"zip_url": "https://lightmake.site/api/v1/download?slug=weather",
|
||||
"source": "skillhub",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Contributing to Agent Browser Skill
|
||||
|
||||
This skill wraps the agent-browser CLI. Determine where the problem lies before reporting issues.
|
||||
|
||||
## Issue Reporting Guide
|
||||
|
||||
### Open an issue in this repository if
|
||||
|
||||
- The skill documentation is unclear or missing
|
||||
- Examples in SKILL.md do not work
|
||||
- You need help using the CLI with this skill wrapper
|
||||
- The skill is missing a command or feature
|
||||
|
||||
### Open an issue at the agent-browser repository if
|
||||
|
||||
- The CLI crashes or throws errors
|
||||
- Commands do not behave as documented
|
||||
- You found a bug in the browser automation
|
||||
- You need a new feature in the CLI
|
||||
|
||||
## Before Opening an Issue
|
||||
|
||||
1. Install the latest version
|
||||
```bash
|
||||
npm install -g agent-browser@latest
|
||||
```
|
||||
|
||||
2. Test the command in your terminal to isolate the issue
|
||||
|
||||
## Issue Report Template
|
||||
|
||||
Use this template to provide necessary information.
|
||||
|
||||
```markdown
|
||||
### Description
|
||||
[Provide a clear and concise description of the bug]
|
||||
|
||||
### Reproduction Steps
|
||||
1. [First Step]
|
||||
2. [Second Step]
|
||||
3. [Observe error]
|
||||
|
||||
### Expected Behavior
|
||||
[Describe what you expected to happen]
|
||||
|
||||
### Environment Details
|
||||
- **Skill Version:** [e.g. 1.0.2]
|
||||
- **agent-browser Version:** [output of agent-browser --version]
|
||||
- **Node.js Version:** [output of node -v]
|
||||
- **Operating System:** [e.g. macOS Sonoma, Windows 11, Ubuntu 22.04]
|
||||
|
||||
### Additional Context
|
||||
- [Full error output or stack trace]
|
||||
- [Screenshots]
|
||||
- [Website URLs where the failure occurred]
|
||||
```
|
||||
|
||||
## Adding New Commands to the Skill
|
||||
|
||||
Update SKILL.md when the upstream CLI adds new commands.
|
||||
- Keep the Installation section
|
||||
- Add new commands in the correct category
|
||||
- Include usage examples
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
name: Agent Browser
|
||||
description: A fast Rust-based headless browser automation CLI with Node.js fallback that enables AI agents to navigate, click, type, and snapshot pages via structured commands.
|
||||
read_when:
|
||||
- Automating web interactions
|
||||
- Extracting structured data from pages
|
||||
- Filling forms programmatically
|
||||
- Testing web UIs
|
||||
metadata: {"clawdbot":{"emoji":"🌐","requires":{"bins":["node","npm"]}}}
|
||||
allowed-tools: Bash(agent-browser:*)
|
||||
---
|
||||
|
||||
# Browser Automation with agent-browser
|
||||
|
||||
## Installation
|
||||
|
||||
### npm recommended
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser
|
||||
agent-browser install
|
||||
agent-browser install --with-deps
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vercel-labs/agent-browser
|
||||
cd agent-browser
|
||||
pnpm install
|
||||
pnpm build
|
||||
agent-browser install
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to page
|
||||
agent-browser snapshot -i # Get interactive elements with refs
|
||||
agent-browser click @e1 # Click element by ref
|
||||
agent-browser fill @e2 "text" # Fill input by ref
|
||||
agent-browser close # Close browser
|
||||
```
|
||||
|
||||
## Core workflow
|
||||
|
||||
1. Navigate: `agent-browser open <url>`
|
||||
2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
|
||||
3. Interact using refs from the snapshot
|
||||
4. Re-snapshot after navigation or significant DOM changes
|
||||
|
||||
## Commands
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to URL
|
||||
agent-browser back # Go back
|
||||
agent-browser forward # Go forward
|
||||
agent-browser reload # Reload page
|
||||
agent-browser close # Close browser
|
||||
```
|
||||
|
||||
### Snapshot (page analysis)
|
||||
|
||||
```bash
|
||||
agent-browser snapshot # Full accessibility tree
|
||||
agent-browser snapshot -i # Interactive elements only (recommended)
|
||||
agent-browser snapshot -c # Compact output
|
||||
agent-browser snapshot -d 3 # Limit depth to 3
|
||||
agent-browser snapshot -s "#main" # Scope to CSS selector
|
||||
```
|
||||
|
||||
### Interactions (use @refs from snapshot)
|
||||
|
||||
```bash
|
||||
agent-browser click @e1 # Click
|
||||
agent-browser dblclick @e1 # Double-click
|
||||
agent-browser focus @e1 # Focus element
|
||||
agent-browser fill @e2 "text" # Clear and type
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser press Control+a # Key combination
|
||||
agent-browser keydown Shift # Hold key down
|
||||
agent-browser keyup Shift # Release key
|
||||
agent-browser hover @e1 # Hover
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser uncheck @e1 # Uncheck checkbox
|
||||
agent-browser select @e1 "value" # Select dropdown
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scrollintoview @e1 # Scroll element into view
|
||||
agent-browser drag @e1 @e2 # Drag and drop
|
||||
agent-browser upload @e1 file.pdf # Upload files
|
||||
```
|
||||
|
||||
### Get information
|
||||
|
||||
```bash
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get html @e1 # Get innerHTML
|
||||
agent-browser get value @e1 # Get input value
|
||||
agent-browser get attr @e1 href # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get count ".item" # Count matching elements
|
||||
agent-browser get box @e1 # Get bounding box
|
||||
```
|
||||
|
||||
### Check state
|
||||
|
||||
```bash
|
||||
agent-browser is visible @e1 # Check if visible
|
||||
agent-browser is enabled @e1 # Check if enabled
|
||||
agent-browser is checked @e1 # Check if checked
|
||||
```
|
||||
|
||||
### Screenshots & PDF
|
||||
|
||||
```bash
|
||||
agent-browser screenshot # Screenshot to stdout
|
||||
agent-browser screenshot path.png # Save to file
|
||||
agent-browser screenshot --full # Full page
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
```
|
||||
|
||||
### Video recording
|
||||
|
||||
```bash
|
||||
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
|
||||
agent-browser click @e1 # Perform actions
|
||||
agent-browser record stop # Stop and save video
|
||||
agent-browser record restart ./take2.webm # Stop current + start new recording
|
||||
```
|
||||
|
||||
Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording.
|
||||
|
||||
### Wait
|
||||
|
||||
```bash
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Success" # Wait for text
|
||||
agent-browser wait --url "/dashboard" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --fn "window.ready" # Wait for JS condition
|
||||
```
|
||||
|
||||
### Mouse control
|
||||
|
||||
```bash
|
||||
agent-browser mouse move 100 200 # Move mouse
|
||||
agent-browser mouse down left # Press button
|
||||
agent-browser mouse up left # Release button
|
||||
agent-browser mouse wheel 100 # Scroll wheel
|
||||
```
|
||||
|
||||
### Semantic locators (alternative to refs)
|
||||
|
||||
```bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find first ".item" click
|
||||
agent-browser find nth 2 "a" text
|
||||
```
|
||||
|
||||
### Browser settings
|
||||
|
||||
```bash
|
||||
agent-browser set viewport 1920 1080 # Set viewport size
|
||||
agent-browser set device "iPhone 14" # Emulate device
|
||||
agent-browser set geo 37.7749 -122.4194 # Set geolocation
|
||||
agent-browser set offline on # Toggle offline mode
|
||||
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
|
||||
agent-browser set credentials user pass # HTTP basic auth
|
||||
agent-browser set media dark # Emulate color scheme
|
||||
```
|
||||
|
||||
### Cookies & Storage
|
||||
|
||||
```bash
|
||||
agent-browser cookies # Get all cookies
|
||||
agent-browser cookies set name value # Set cookie
|
||||
agent-browser cookies clear # Clear cookies
|
||||
agent-browser storage local # Get all localStorage
|
||||
agent-browser storage local key # Get specific key
|
||||
agent-browser storage local set k v # Set value
|
||||
agent-browser storage local clear # Clear all
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
```bash
|
||||
agent-browser network route <url> # Intercept requests
|
||||
agent-browser network route <url> --abort # Block requests
|
||||
agent-browser network route <url> --body '{}' # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --filter api # Filter requests
|
||||
```
|
||||
|
||||
### Tabs & Windows
|
||||
|
||||
```bash
|
||||
agent-browser tab # List tabs
|
||||
agent-browser tab new [url] # New tab
|
||||
agent-browser tab 2 # Switch to tab
|
||||
agent-browser tab close # Close tab
|
||||
agent-browser window new # New window
|
||||
```
|
||||
|
||||
### Frames
|
||||
|
||||
```bash
|
||||
agent-browser frame "#iframe" # Switch to iframe
|
||||
agent-browser frame main # Back to main frame
|
||||
```
|
||||
|
||||
### Dialogs
|
||||
|
||||
```bash
|
||||
agent-browser dialog accept [text] # Accept dialog
|
||||
agent-browser dialog dismiss # Dismiss dialog
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```bash
|
||||
agent-browser eval "document.title" # Run JavaScript
|
||||
```
|
||||
|
||||
### State management
|
||||
|
||||
```bash
|
||||
agent-browser state save auth.json # Save session state
|
||||
agent-browser state load auth.json # Load saved state
|
||||
```
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
|
||||
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i # Check result
|
||||
```
|
||||
|
||||
## Example: Authentication with saved state
|
||||
|
||||
```bash
|
||||
# Login once
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "username"
|
||||
agent-browser fill @e2 "password"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "/dashboard"
|
||||
agent-browser state save auth.json
|
||||
|
||||
# Later sessions: load saved state
|
||||
agent-browser state load auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
## Sessions (parallel browsers)
|
||||
|
||||
```bash
|
||||
agent-browser --session test1 open site-a.com
|
||||
agent-browser --session test2 open site-b.com
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
## JSON output (for parsing)
|
||||
|
||||
Add `--json` for machine-readable output:
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i --json
|
||||
agent-browser get text @e1 --json
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
agent-browser open example.com --headed # Show browser window
|
||||
agent-browser console # View console messages
|
||||
agent-browser console --clear # Clear console
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors --clear # Clear errors
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser trace start # Start recording trace
|
||||
agent-browser trace stop trace.zip # Stop and save trace
|
||||
agent-browser record start ./debug.webm # Record from current page
|
||||
agent-browser record stop # Save recording
|
||||
agent-browser --cdp 9222 snapshot # Connect via CDP
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If the command is not found on Linux ARM64, use the full path in the bin folder.
|
||||
- If an element is not found, use snapshot to find the correct ref.
|
||||
- If the page is not loaded, add a wait command after navigation.
|
||||
- Use --headed to see the browser window for debugging.
|
||||
|
||||
## Options
|
||||
|
||||
- --session <name> uses an isolated session.
|
||||
- --json provides JSON output.
|
||||
- --full takes a full page screenshot.
|
||||
- --headed shows the browser window.
|
||||
- --timeout sets the command timeout in milliseconds.
|
||||
- --cdp <port> connects via Chrome DevTools Protocol.
|
||||
|
||||
## Notes
|
||||
|
||||
- Refs are stable per page load but change on navigation.
|
||||
- Always snapshot after navigation to get new refs.
|
||||
- Use fill instead of type for input fields to ensure existing text is cleared.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- Skill issues: Open an issue at https://github.com/TheSethRose/Agent-Browser-CLI
|
||||
- agent-browser CLI issues: Open an issue at https://github.com/vercel-labs/agent-browser
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn72ce44tqw8bnnnewrn1s5x3s7yz7sq",
|
||||
"slug": "agent-browser",
|
||||
"version": "0.2.0",
|
||||
"publishedAt": 1768882342488
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: ai-ppt-generator
|
||||
description: Generate PPT with Baidu AI. Smart template selection based on content.
|
||||
metadata: { "openclaw": { "emoji": "📑", "requires": { "bins": ["python3"], "env":["BAIDU_API_KEY"]},"primaryEnv":"BAIDU_API_KEY" } }
|
||||
---
|
||||
|
||||
# AI PPT Generator
|
||||
|
||||
Generate PPT using Baidu AI with intelligent template selection.
|
||||
|
||||
## Smart Workflow
|
||||
1. **User provides PPT topic**
|
||||
2. **Agent asks**: "Want to choose a template style?"
|
||||
3. **If yes** → Show styles from `ppt_theme_list.py` → User picks → Use `generate_ppt.py` with chosen `tpl_id` and real `style_id`
|
||||
4. **If no** → Use `random_ppt_theme.py` (auto-selects appropriate template based on topic content)
|
||||
|
||||
## Intelligent Template Selection
|
||||
`random_ppt_theme.py` analyzes the topic and suggests appropriate template:
|
||||
- **Business topics** → 企业商务 style
|
||||
- **Technology topics** → 未来科技 style
|
||||
- **Education topics** → 卡通手绘 style
|
||||
- **Creative topics** → 创意趣味 style
|
||||
- **Cultural topics** → 中国风 or 文化艺术 style
|
||||
- **Year-end reports** → 年终总结 style
|
||||
- **Minimalist design** → 扁平简约 style
|
||||
- **Artistic content** → 文艺清新 style
|
||||
|
||||
## Scripts
|
||||
- `scripts/ppt_theme_list.py` - List all available templates with style_id and tpl_id
|
||||
- `scripts/random_ppt_theme.py` - Smart template selection + generate PPT
|
||||
- `scripts/generate_ppt.py` - Generate PPT with specific template (uses real style_id and tpl_id from API)
|
||||
|
||||
## Key Features
|
||||
- **Smart categorization**: Analyzes topic content to suggest appropriate style
|
||||
- **Fallback logic**: If template not found, automatically uses random selection
|
||||
- **Complete parameters**: Properly passes both style_id and tpl_id to API
|
||||
|
||||
## Usage Examples
|
||||
```bash
|
||||
# List all templates with IDs
|
||||
python3 scripts/ppt_theme_list.py
|
||||
|
||||
# Smart automatic selection (recommended for most users)
|
||||
python3 scripts/random_ppt_theme.py --query "人工智能发展趋势报告"
|
||||
|
||||
# Specific template with proper style_id
|
||||
python3 scripts/generate_ppt.py --query "儿童英语课件" --tpl_id 106
|
||||
|
||||
# Specific template with auto-suggested category
|
||||
python3 scripts/random_ppt_theme.py --query "企业年度总结" --category "企业商务"
|
||||
```
|
||||
|
||||
## Agent Steps
|
||||
1. Get PPT topic from user
|
||||
2. Ask: "Want to choose a template style?"
|
||||
3. **If user says YES**:
|
||||
- Run `ppt_theme_list.py` to show available templates
|
||||
- User selects a template (note the tpl_id)
|
||||
- Run `generate_ppt.py --query "TOPIC" --tpl_id ID`
|
||||
4. **If user says NO**:
|
||||
- Run `random_ppt_theme.py --query "TOPIC"`
|
||||
- Script will auto-select appropriate template based on topic
|
||||
5. Set timeout to 300 seconds (PPT generation takes 2-5 minutes)
|
||||
6. Monitor output, wait for `is_end: true` to get final PPT URL
|
||||
|
||||
## Output Examples
|
||||
**During generation:**
|
||||
```json
|
||||
{"status": "PPT生成中", "run_time": 45}
|
||||
```
|
||||
|
||||
**Final result:**
|
||||
```json
|
||||
{
|
||||
"status": "PPT导出结束",
|
||||
"is_end": true,
|
||||
"data": {"ppt_url": "https://image0.bj.bcebos.com/...ppt"}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Notes
|
||||
- **API integration**: Fetches real style_id from Baidu API for each template
|
||||
- **Error handling**: If template not found, falls back to random selection
|
||||
- **Timeout**: Generation takes 2-5 minutes, set sufficient timeout
|
||||
- **Streaming**: Uses streaming API, wait for `is_end: true` before considering complete
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7akgt520t01vgs2tzx7yk6m180kt26",
|
||||
"slug": "ai-ppt-generator",
|
||||
"version": "1.1.3",
|
||||
"publishedAt": 1772532055208
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
import json
|
||||
import argparse
|
||||
|
||||
URL_PREFIX = "https://qianfan.baidubce.com/v2/tools/ai_ppt/"
|
||||
|
||||
|
||||
class Style:
|
||||
def __init__(self, style_id, tpl_id):
|
||||
self.style_id = style_id
|
||||
self.tpl_id = tpl_id
|
||||
|
||||
|
||||
class Outline:
|
||||
def __init__(self, chat_id, query_id, title, outline):
|
||||
self.chat_id = chat_id
|
||||
self.query_id = query_id
|
||||
self.title = title
|
||||
self.outline = outline
|
||||
|
||||
|
||||
def get_ppt_theme(api_key: str):
|
||||
"""Get a random PPT theme"""
|
||||
headers = {
|
||||
"Authorization": "Bearer %s" % api_key,
|
||||
}
|
||||
response = requests.post(URL_PREFIX + "get_ppt_theme", headers=headers)
|
||||
result = response.json()
|
||||
if "errno" in result and result["errno"] != 0:
|
||||
raise RuntimeError(result["errmsg"])
|
||||
|
||||
style_index = random.randint(0, len(result["data"]["ppt_themes"]) - 1)
|
||||
theme = result["data"]["ppt_themes"][style_index]
|
||||
return Style(style_id=theme["style_id"], tpl_id=theme["tpl_id"])
|
||||
|
||||
|
||||
def ppt_outline_generate(api_key: str, query: str):
|
||||
"""Generate PPT outline"""
|
||||
headers = {
|
||||
"Authorization": "Bearer %s" % api_key,
|
||||
"X-Appbuilder-From": "openclaw",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
headers.setdefault('Accept', 'text/event-stream')
|
||||
headers.setdefault('Cache-Control', 'no-cache')
|
||||
headers.setdefault('Connection', 'keep-alive')
|
||||
params = {
|
||||
"query": query,
|
||||
}
|
||||
title = ""
|
||||
outline = ""
|
||||
chat_id = ""
|
||||
query_id = ""
|
||||
with requests.post(URL_PREFIX + "generate_outline", headers=headers, json=params, stream=True) as response:
|
||||
for line in response.iter_lines():
|
||||
line = line.decode('utf-8')
|
||||
if line and line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
delta = json.loads(data_str)
|
||||
if not title:
|
||||
title = delta["title"]
|
||||
chat_id = delta["chat_id"]
|
||||
query_id = delta["query_id"]
|
||||
outline += delta["outline"]
|
||||
|
||||
return Outline(chat_id=chat_id, query_id=query_id, title=title, outline=outline)
|
||||
|
||||
|
||||
def ppt_generate(api_key: str, query: str, style_id: int = 0, tpl_id: int = None, web_content: str = None):
|
||||
"""Generate PPT - simple version"""
|
||||
headers = {
|
||||
"Authorization": "Bearer %s" % api_key,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Get theme
|
||||
if tpl_id is None:
|
||||
# Random theme
|
||||
style = get_ppt_theme(api_key)
|
||||
style_id = style.style_id
|
||||
tpl_id = style.tpl_id
|
||||
print(f"Using random template (tpl_id: {tpl_id})", file=sys.stderr)
|
||||
else:
|
||||
# Specific theme - use provided style_id (default 0)
|
||||
print(f"Using template tpl_id: {tpl_id}, style_id: {style_id}", file=sys.stderr)
|
||||
|
||||
# Generate outline
|
||||
outline = ppt_outline_generate(api_key, query)
|
||||
|
||||
# Generate PPT
|
||||
headers.setdefault('Accept', 'text/event-stream')
|
||||
headers.setdefault('Cache-Control', 'no-cache')
|
||||
headers.setdefault('Connection', 'keep-alive')
|
||||
params = {
|
||||
"query_id": int(outline.query_id),
|
||||
"chat_id": int(outline.chat_id),
|
||||
"query": query,
|
||||
"outline": outline.outline,
|
||||
"title": outline.title,
|
||||
"style_id": style_id,
|
||||
"tpl_id": tpl_id,
|
||||
"web_content": web_content
|
||||
}
|
||||
with requests.post(URL_PREFIX + "generate_ppt_by_outline", headers=headers, json=params, stream=True) as response:
|
||||
if response.status_code != 200:
|
||||
print(f"request failed, status code is {response.status_code}, error message is {response.text}")
|
||||
return []
|
||||
for line in response.iter_lines():
|
||||
line = line.decode('utf-8')
|
||||
if line and line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
yield json.loads(data_str)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Generate PPT")
|
||||
parser.add_argument("--query", "-q", type=str, required=True, help="PPT topic")
|
||||
parser.add_argument("--style_id", "-si", type=int, default=0, help="Style ID (default: 0)")
|
||||
parser.add_argument("--tpl_id", "-tp", type=int, help="Template ID (optional)")
|
||||
parser.add_argument("--web_content", "-wc", type=str, default=None, help="Web content")
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = os.getenv("BAIDU_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: BAIDU_API_KEY must be set in environment.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
start_time = int(time.time())
|
||||
results = ppt_generate(api_key, args.query, args.style_id, args.tpl_id, args.web_content)
|
||||
|
||||
for result in results:
|
||||
if "is_end" in result and result["is_end"]:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
end_time = int(time.time())
|
||||
print(json.dumps({"status": result["status"], "run_time": end_time - start_time}))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import json
|
||||
|
||||
|
||||
def ppt_theme_list(api_key: str):
|
||||
url = "https://qianfan.baidubce.com/v2/tools/ai_ppt/get_ppt_theme"
|
||||
headers = {
|
||||
"Authorization": "Bearer %s" % api_key,
|
||||
"X-Appbuilder-From": "openclaw",
|
||||
}
|
||||
response = requests.post(url, headers=headers)
|
||||
result = response.json()
|
||||
if "errno" in result and result["errno"] != 0:
|
||||
raise RuntimeError(result["errmsg"])
|
||||
themes = []
|
||||
count = 0
|
||||
for theme in result["data"]["ppt_themes"]:
|
||||
count += 1
|
||||
if count > 100:
|
||||
break
|
||||
themes.append({
|
||||
"style_name_list": theme["style_name_list"],
|
||||
"style_id": theme["style_id"],
|
||||
"tpl_id": theme["tpl_id"],
|
||||
})
|
||||
return themes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api_key = os.getenv("BAIDU_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: BAIDU_API_KEY must be set in environment.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
results = ppt_theme_list(api_key)
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
except Exception as e:
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
print(f"error type:{exc_type}")
|
||||
print(f"error message:{exc_value}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Random PPT Theme Selector
|
||||
If user doesn't select a PPT template, this script will randomly select one
|
||||
from the available templates and generate PPT.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import random
|
||||
import argparse
|
||||
import subprocess
|
||||
import time
|
||||
def get_available_themes():
|
||||
"""Get available PPT themes"""
|
||||
try:
|
||||
api_key = os.getenv("BAIDU_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: BAIDU_API_KEY environment variable not set", file=sys.stderr)
|
||||
return []
|
||||
|
||||
# Import the function from ppt_theme_list.py
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, script_dir)
|
||||
|
||||
from ppt_theme_list import ppt_theme_list as get_themes
|
||||
themes = get_themes(api_key)
|
||||
return themes
|
||||
except Exception as e:
|
||||
print(f"Error getting themes: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
|
||||
def categorize_themes(themes):
|
||||
"""Categorize themes by style for better random selection"""
|
||||
categorized = {
|
||||
"企业商务": [],
|
||||
"文艺清新": [],
|
||||
"卡通手绘": [],
|
||||
"扁平简约": [],
|
||||
"中国风": [],
|
||||
"年终总结": [],
|
||||
"创意趣味": [],
|
||||
"文化艺术": [],
|
||||
"未来科技": [],
|
||||
"默认": []
|
||||
}
|
||||
|
||||
for theme in themes:
|
||||
style_names = theme.get("style_name_list", [])
|
||||
if not style_names:
|
||||
categorized["默认"].append(theme)
|
||||
continue
|
||||
|
||||
added = False
|
||||
for style_name in style_names:
|
||||
if style_name in categorized:
|
||||
categorized[style_name].append(theme)
|
||||
added = True
|
||||
break
|
||||
|
||||
if not added:
|
||||
categorized["默认"].append(theme)
|
||||
|
||||
return categorized
|
||||
|
||||
|
||||
def select_random_theme_by_category(categorized_themes, preferred_category=None):
|
||||
"""Select a random theme, optionally preferring a specific category"""
|
||||
# If preferred category specified and has themes, use it
|
||||
if preferred_category and preferred_category in categorized_themes:
|
||||
if categorized_themes[preferred_category]:
|
||||
return random.choice(categorized_themes[preferred_category])
|
||||
|
||||
# Otherwise, select from all non-empty categories
|
||||
available_categories = []
|
||||
for category, themes in categorized_themes.items():
|
||||
if themes:
|
||||
available_categories.append(category)
|
||||
|
||||
if not available_categories:
|
||||
return None
|
||||
|
||||
# Weighted random selection: prefer non-default categories
|
||||
weights = []
|
||||
for category in available_categories:
|
||||
if category == "默认":
|
||||
weights.append(0.5) # Lower weight for default
|
||||
else:
|
||||
weights.append(2.0) # Higher weight for specific styles
|
||||
|
||||
# Normalize weights
|
||||
total_weight = sum(weights)
|
||||
weights = [w/total_weight for w in weights]
|
||||
|
||||
selected_category = random.choices(available_categories, weights=weights, k=1)[0]
|
||||
return random.choice(categorized_themes[selected_category])
|
||||
|
||||
|
||||
def suggest_category_by_query(query):
|
||||
"""Suggest template category based on query keywords - enhanced version"""
|
||||
query_lower = query.lower()
|
||||
|
||||
# Comprehensive keyword mapping with priority order
|
||||
keyword_mapping = [
|
||||
# Business & Corporate (highest priority for formal content)
|
||||
("企业商务", [
|
||||
"企业", "公司", "商务", "商业", "商务", "商业计划", "商业报告",
|
||||
"营销", "市场", "销售", "财务", "会计", "审计", "投资", "融资",
|
||||
"战略", "管理", "运营", "人力资源", "hr", "董事会", "股东",
|
||||
"年报", "季报", "财报", "业绩", "kpi", "okr", "商业计划书",
|
||||
"提案", "策划", "方案", "报告", "总结", "规划", "计划"
|
||||
]),
|
||||
|
||||
# Technology & Future Tech
|
||||
("未来科技", [
|
||||
"未来", "科技", "人工智能", "ai", "机器学习", "深度学习",
|
||||
"大数据", "云计算", "区块链", "物联网", "iot", "5g", "6g",
|
||||
"量子计算", "机器人", "自动化", "智能制造", "智慧城市",
|
||||
"虚拟现实", "vr", "增强现实", "ar", "元宇宙", "数字孪生",
|
||||
"芯片", "半导体", "集成电路", "电子", "通信", "网络",
|
||||
"网络安全", "信息安全", "数字化", "数字化转型",
|
||||
"科幻", "高科技", "前沿科技", "科技创新", "技术"
|
||||
]),
|
||||
|
||||
# Education & Children
|
||||
("卡通手绘", [
|
||||
"卡通", "动画", "动漫", "儿童", "幼儿", "小学生", "中学生",
|
||||
"教育", "教学", "课件", "教案", "学习", "培训", "教程",
|
||||
"趣味", "有趣", "可爱", "活泼", "生动", "绘本", "漫画",
|
||||
"手绘", "插画", "图画", "图形", "游戏", "玩乐", "娱乐"
|
||||
]),
|
||||
|
||||
# Year-end & Summary
|
||||
("年终总结", [
|
||||
"年终", "年度", "季度", "月度", "周报", "日报",
|
||||
"总结", "回顾", "汇报", "述职", "考核", "评估",
|
||||
"成果", "成绩", "业绩", "绩效", "目标", "完成",
|
||||
"工作汇报", "工作总结", "年度报告", "季度报告"
|
||||
]),
|
||||
|
||||
# Minimalist & Modern Design
|
||||
("扁平简约", [
|
||||
"简约", "简洁", "简单", "极简", "现代", "当代",
|
||||
"设计", "视觉", "ui", "ux", "用户体验", "用户界面",
|
||||
"科技感", "数字感", "数据", "图表", "图形", "信息图",
|
||||
"分析", "统计", "报表", "dashboard", "仪表板",
|
||||
"互联网", "web", "移动", "app", "应用", "软件"
|
||||
]),
|
||||
|
||||
# Chinese Traditional
|
||||
("中国风", [
|
||||
"中国", "中华", "传统", "古典", "古风", "古代",
|
||||
"文化", "文明", "历史", "国学", "东方", "水墨",
|
||||
"书法", "国画", "诗词", "古文", "经典", "传统节日",
|
||||
"春节", "中秋", "端午", "节气", "风水", "易经",
|
||||
"儒", "道", "佛", "禅", "茶道", "瓷器", "丝绸"
|
||||
]),
|
||||
|
||||
# Cultural & Artistic
|
||||
("文化艺术", [
|
||||
"文化", "艺术", "文艺", "美学", "审美", "创意",
|
||||
"创作", "作品", "展览", "博物馆", "美术馆", "画廊",
|
||||
"音乐", "舞蹈", "戏剧", "戏曲", "电影", "影视",
|
||||
"摄影", "绘画", "雕塑", "建筑", "设计", "时尚",
|
||||
"文学", "诗歌", "小说", "散文", "哲学", "思想"
|
||||
]),
|
||||
|
||||
# Artistic & Fresh
|
||||
("文艺清新", [
|
||||
"文艺", "清新", "小清新", "治愈", "温暖", "温柔",
|
||||
"浪漫", "唯美", "优雅", "精致", "细腻", "柔和",
|
||||
"自然", "生态", "环保", "绿色", "植物", "花卉",
|
||||
"风景", "旅行", "游记", "生活", "日常", "情感"
|
||||
]),
|
||||
|
||||
# Creative & Fun
|
||||
("创意趣味", [
|
||||
"创意", "创新", "创造", "发明", "新奇", "新颖",
|
||||
"独特", "个性", "特色", "趣味", "有趣", "好玩",
|
||||
"幽默", "搞笑", "笑话", "娱乐", "休闲", "放松",
|
||||
"脑洞", "想象力", "灵感", "点子", "想法", "概念"
|
||||
]),
|
||||
|
||||
# Academic & Research
|
||||
("默认", [
|
||||
"研究", "学术", "科学", "论文", "课题", "项目",
|
||||
"实验", "调查", "分析", "理论", "方法", "技术",
|
||||
"医学", "健康", "医疗", "生物", "化学", "物理",
|
||||
"数学", "工程", "建筑", "法律", "政治", "经济",
|
||||
"社会", "心理", "教育", "学习", "知识", "信息"
|
||||
])
|
||||
]
|
||||
|
||||
# Check each category with its keywords
|
||||
for category, keywords in keyword_mapping:
|
||||
for keyword in keywords:
|
||||
if keyword in query_lower:
|
||||
return category
|
||||
|
||||
# If no match found, analyze query length and content
|
||||
words = query_lower.split()
|
||||
if len(words) <= 3:
|
||||
# Short query, likely specific - use "默认" or tech-related
|
||||
if any(word in query_lower for word in ["ai", "vr", "ar", "iot", "5g", "tech"]):
|
||||
return "未来科技"
|
||||
return "默认"
|
||||
else:
|
||||
# Longer query, analyze word frequency
|
||||
word_counts = {}
|
||||
for word in words:
|
||||
if len(word) > 1: # Ignore single characters
|
||||
word_counts[word] = word_counts.get(word, 0) + 1
|
||||
|
||||
# Check for business indicators
|
||||
business_words = ["报告", "总结", "计划", "方案", "业绩", "销售", "市场"]
|
||||
if any(word in word_counts for word in business_words):
|
||||
return "企业商务"
|
||||
|
||||
# Check for tech indicators
|
||||
tech_words = ["技术", "科技", "数据", "数字", "智能", "系统"]
|
||||
if any(word in word_counts for word in tech_words):
|
||||
return "未来科技"
|
||||
|
||||
# Default fallback
|
||||
return "默认"
|
||||
|
||||
|
||||
def generate_ppt_with_random_theme(query, preferred_category=None):
|
||||
"""Generate PPT with randomly selected theme"""
|
||||
# Get available themes
|
||||
themes = get_available_themes()
|
||||
if not themes:
|
||||
print("Error: No available themes found", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Categorize themes
|
||||
categorized = categorize_themes(themes)
|
||||
|
||||
# Select random theme
|
||||
selected_theme = select_random_theme_by_category(categorized, preferred_category)
|
||||
if not selected_theme:
|
||||
print("Error: Could not select a theme", file=sys.stderr)
|
||||
return False
|
||||
|
||||
style_id = selected_theme.get("style_id", 0)
|
||||
tpl_id = selected_theme.get("tpl_id")
|
||||
style_names = selected_theme.get("style_name_list", ["默认"])
|
||||
|
||||
print(f"Selected template: {style_names[0]} (tpl_id: {tpl_id})", file=sys.stderr)
|
||||
|
||||
# Generate PPT
|
||||
script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "generate_ppt.py")
|
||||
|
||||
try:
|
||||
# Run generate_ppt.py with the selected theme
|
||||
cmd = [
|
||||
sys.executable, script_path,
|
||||
"--query", query,
|
||||
"--tpl_id", str(tpl_id),
|
||||
"--style_id", str(style_id)
|
||||
]
|
||||
|
||||
start_time = int(time.time())
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# Stream output
|
||||
for line in process.stdout:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if "is_end" in data and data["is_end"]:
|
||||
print(json.dumps(data, ensure_ascii=False))
|
||||
else:
|
||||
end_time = int(time.time())
|
||||
print(json.dumps({"status": data.get("status", "生成中"), "run_time": end_time - start_time}, ensure_ascii=False))
|
||||
except json.JSONDecodeError:
|
||||
# Just print non-JSON output
|
||||
print(line)
|
||||
|
||||
process.wait()
|
||||
return process.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating PPT: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate PPT with random theme selection")
|
||||
parser.add_argument("--query", "-q", type=str, required=True, help="PPT主题/内容")
|
||||
parser.add_argument("--category", "-c", type=str, help="Preferred category (企业商务/文艺清新/卡通手绘/扁平简约/中国风/年终总结/创意趣味/文化艺术/未来科技)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine preferred category
|
||||
preferred_category = args.category
|
||||
if not preferred_category:
|
||||
preferred_category = suggest_category_by_query(args.query)
|
||||
if preferred_category:
|
||||
print(f"Auto-suggested category: {preferred_category}", file=sys.stderr)
|
||||
|
||||
# Generate PPT
|
||||
success = generate_ppt_with_random_theme(args.query, preferred_category)
|
||||
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: clawhub-skill-publishing-guide
|
||||
version: 1.1.0
|
||||
description: ClawHub Skill 发布避坑指南。让你的 Skill 发布后能被搜索到,避免安全扫描导致隐藏。适用于需要发布 Skill 到 ClawHub 的开发者。
|
||||
---
|
||||
|
||||
# ClawHub Skill 发布避坑指南
|
||||
|
||||
## 发布前检查清单
|
||||
|
||||
### ✅ 必须避免的内容
|
||||
|
||||
| 问题类型 | 风险等级 | 解决方案 |
|
||||
|----------|----------|------------|
|
||||
| 硬编码 API Keys | 🔴 高 | 使用环境变量 |
|
||||
| HTTP 明文传输 | 🟡 中 | 添加安全警告说明 |
|
||||
| 外部 URL/端点 | 🟢 低 | 正常发布 |
|
||||
| 敏感信息 | 🔴 高 | 移除或环境变量 |
|
||||
|
||||
### ✅ 推荐做法
|
||||
|
||||
1. **API Key 放在环境变量**
|
||||
```python
|
||||
# ❌ 硬编码(会被扫描拦截)
|
||||
API_KEY = "sk-xxx"
|
||||
|
||||
# ✅ 环境变量(安全)
|
||||
API_KEY = os.environ.get("API_KEY")
|
||||
```
|
||||
|
||||
2. **HTTP endpoint 要警告用户**
|
||||
```markdown
|
||||
## ⚠️ 安全警告
|
||||
- HTTP 明文传输,API Key 可能有泄露风险
|
||||
- 仅在可信网络使用
|
||||
```
|
||||
|
||||
3. **SKILL.md 要声明必需的环境变量**
|
||||
```markdown
|
||||
## 环境变量
|
||||
- API_KEY=xxx # 必需
|
||||
- BASE_URL=http://example.com # 可选
|
||||
```
|
||||
|
||||
## ⚠️ 开发者协议确认(新!)
|
||||
|
||||
**如果发布时遇到错误:`acceptLicenseTerms: invalid value`**
|
||||
|
||||
说明你需要先在 ClawHub 网站上同意开发者协议:
|
||||
|
||||
1. 访问 https://clawhub.ai
|
||||
2. 登录你的账户
|
||||
3. 进入 **Settings** 或 **Developer Settings**
|
||||
4. 同意开发者许可协议
|
||||
5. 然后再执行发布命令
|
||||
|
||||
## 发布命令
|
||||
|
||||
```bash
|
||||
# 方式一:使用 clawhub CLI(需要先在网站同意开发者协议)
|
||||
clawhub publish ./skills/your-skill --version 1.0.0
|
||||
|
||||
# 方式二:使用 curl 直接发布(支持 acceptLicenseTerms)
|
||||
TOKEN=$(cat ~/.config/clawhub/config.json | jq -r '.token')
|
||||
curl -X POST "https://clawhub.ai/api/v1/skills" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F 'payload={"slug":"your-skill","displayName":"Your Skill","version":"1.0.0","changelog":"","tags":["latest"],"acceptLicenseTerms":true};type=application/json' \
|
||||
-F "files=@SKILL.md;filename=SKILL.md"
|
||||
```
|
||||
|
||||
**注意**:`acceptLicenseTerms: true` 是必需的参数,表示同意开发者许可协议。
|
||||
|
||||
## 发布后验证
|
||||
|
||||
```bash
|
||||
# 搜索 Skill
|
||||
clawhub search your-skill-name
|
||||
|
||||
# 检查状态
|
||||
clawhub inspect your-skill-name
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: Skill 被隐藏怎么办?
|
||||
A: 等待安全扫描通过,或移除敏感信息重新发布
|
||||
|
||||
### Q: 提示 hard-coded credentials 怎么办?
|
||||
A: 改用环境变量,添加安全警告
|
||||
|
||||
### Q: 版本号冲突怎么办?
|
||||
A: 升级版本号,如 1.0.0 → 1.0.1
|
||||
|
||||
### Q: 提示 acceptLicenseTerms: invalid value 怎么办?
|
||||
A: 先在 ClawHub 网站上同意开发者协议,然后再发布
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn78ktn8wxc52tthvsh1k5r3f9826ckf",
|
||||
"slug": "clawhub-skill-publishing-guide",
|
||||
"version": "1.1.0",
|
||||
"publishedAt": 1773201869586
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Competitor Analyzer
|
||||
|
||||
Analyze any company's competitive position in minutes. Takes a company name or URL and produces a structured report covering what they do, pricing, social presence, and recent news.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
./analyze.sh <company_name_or_url>
|
||||
```
|
||||
|
||||
### Example
|
||||
```
|
||||
./analyze.sh "Notion"
|
||||
./analyze.sh "https://linear.app"
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
A structured competitive analysis report with:
|
||||
- **Company Overview** — What they do, market position
|
||||
- **Pricing Analysis** — Plans, tiers, free tier details
|
||||
- **Social Presence** — Twitter, LinkedIn, GitHub activity
|
||||
- **Recent News** — Latest announcements, funding, launches
|
||||
- **Strengths & Weaknesses** — Quick SWOT-lite summary
|
||||
|
||||
## Requirements
|
||||
|
||||
- `curl` (standard)
|
||||
- Internet access for web searches
|
||||
- Works best when run by an AI agent with `web_search` tool access
|
||||
|
||||
## How It Works
|
||||
|
||||
The script uses web search queries to gather intel, then formats results into a clean markdown report. When run by an OpenClaw agent, it leverages the `web_search` tool for richer results. Standalone mode uses curl + DuckDuckGo.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn73pggwgch91znm1x7sjpfc5581jeen",
|
||||
"slug": "competitor-analyzer",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1771785543875
|
||||
}
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# Competitor Analyzer — gather competitive intelligence on any company
|
||||
# Usage: ./analyze.sh <company_name_or_url>
|
||||
set -euo pipefail
|
||||
|
||||
COMPANY="${1:?Usage: ./analyze.sh <company_name_or_url>}"
|
||||
OUTFILE="competitor-report-$(echo "$COMPANY" | tr ' /' '-' | tr '[:upper:]' '[:lower:]').md"
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M')
|
||||
|
||||
# Strip protocol for search-friendly name
|
||||
SEARCH_NAME=$(echo "$COMPANY" | sed 's|https\?://||;s|/.*||;s|www\.||')
|
||||
|
||||
echo "🦞 Competitor Analyzer — Analyzing: $SEARCH_NAME"
|
||||
echo "================================================="
|
||||
echo ""
|
||||
|
||||
# Function to search via DuckDuckGo HTML (no API key needed)
|
||||
search() {
|
||||
local query="$1"
|
||||
curl -sL "https://html.duckduckgo.com/html/?q=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$query'))")" \
|
||||
-H "User-Agent: Mozilla/5.0" 2>/dev/null | \
|
||||
python3 -c "
|
||||
import sys, re, html
|
||||
content = sys.stdin.read()
|
||||
results = re.findall(r'class=\"result__snippet\">(.*?)</a>', content, re.DOTALL)
|
||||
for i, r in enumerate(results[:5]):
|
||||
text = html.unescape(re.sub(r'<[^>]+>', '', r)).strip()
|
||||
if text:
|
||||
print(f'- {text}')
|
||||
" 2>/dev/null || echo "- (search unavailable — run with an AI agent for better results)"
|
||||
}
|
||||
|
||||
echo "🔍 Searching for company overview..."
|
||||
OVERVIEW=$(search "$SEARCH_NAME company overview what do they do")
|
||||
|
||||
echo "💰 Searching for pricing info..."
|
||||
PRICING=$(search "$SEARCH_NAME pricing plans cost")
|
||||
|
||||
echo "📱 Searching for social presence..."
|
||||
SOCIAL=$(search "$SEARCH_NAME twitter linkedin social media followers")
|
||||
|
||||
echo "📰 Searching for recent news..."
|
||||
NEWS=$(search "$SEARCH_NAME news announcements 2025 2026")
|
||||
|
||||
echo "⚔️ Searching for competitors..."
|
||||
COMPETITORS=$(search "$SEARCH_NAME competitors alternatives vs")
|
||||
|
||||
# Generate report
|
||||
cat > "$OUTFILE" << EOF
|
||||
# Competitive Analysis: $SEARCH_NAME
|
||||
*Generated: $TIMESTAMP*
|
||||
|
||||
---
|
||||
|
||||
## 🏢 Company Overview
|
||||
$OVERVIEW
|
||||
|
||||
## 💰 Pricing & Plans
|
||||
$PRICING
|
||||
|
||||
## 📱 Social Presence
|
||||
$SOCIAL
|
||||
|
||||
## 📰 Recent News
|
||||
$NEWS
|
||||
|
||||
## ⚔️ Competitors & Alternatives
|
||||
$COMPETITORS
|
||||
|
||||
---
|
||||
|
||||
## 📊 Quick Assessment
|
||||
|
||||
| Category | Notes |
|
||||
|----------|-------|
|
||||
| Market Position | See overview above |
|
||||
| Pricing Model | See pricing section |
|
||||
| Social Reach | See social section |
|
||||
| Recent Momentum | See news section |
|
||||
|
||||
---
|
||||
*Report generated by Competitor Analyzer 🦞 — a ClawHub skill by Shelly*
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "✅ Report saved to: $OUTFILE"
|
||||
echo ""
|
||||
cat "$OUTFILE"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Competitive Analysis: niuqicha.com
|
||||
*Generated: 2026-03-18 12:10*
|
||||
|
||||
---
|
||||
|
||||
## 🏢 Company Overview
|
||||
|
||||
|
||||
## 💰 Pricing & Plans
|
||||
|
||||
|
||||
## 📱 Social Presence
|
||||
|
||||
|
||||
## 📰 Recent News
|
||||
|
||||
|
||||
## ⚔️ Competitors & Alternatives
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📊 Quick Assessment
|
||||
|
||||
| Category | Notes |
|
||||
|----------|-------|
|
||||
| Market Position | See overview above |
|
||||
| Pricing Model | See pricing section |
|
||||
| Social Reach | See social section |
|
||||
| Recent Momentum | See news section |
|
||||
|
||||
---
|
||||
*Report generated by Competitor Analyzer 🦞 — a ClawHub skill by Shelly*
|
||||
@@ -0,0 +1,44 @@
|
||||
# Feishu Card Skill
|
||||
|
||||
Send rich interactive cards to Feishu (Lark) users or groups. Supports Markdown (code blocks, tables), titles, color headers, and buttons.
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Simple Text (No special characters)
|
||||
```bash
|
||||
node skills/feishu-card/send.js --target "ou_..." --text "Hello World"
|
||||
```
|
||||
|
||||
### 2. Complex/Markdown Text (RECOMMENDED)
|
||||
**⚠️ CRITICAL:** To prevent shell escaping issues (e.g., swallowed backticks), ALWAYS write content to a file first.
|
||||
|
||||
1. Write content to a temp file:
|
||||
```bash
|
||||
# (Use 'write' tool)
|
||||
write temp/msg.md "Here is some code:\n\`\`\`js\nconsole.log('hi');\n\`\`\`"
|
||||
```
|
||||
|
||||
2. Send using `--text-file`:
|
||||
```bash
|
||||
node skills/feishu-card/send.js --target "ou_..." --text-file "temp/msg.md"
|
||||
```
|
||||
|
||||
### 3. Safe Send (Automated Temp File)
|
||||
Use this wrapper to safely send raw text without manually creating a file. It handles file creation and cleanup automatically.
|
||||
|
||||
```bash
|
||||
node skills/feishu-card/send_safe.js --target "ou_..." --text "Raw content with \`backticks\` and *markdown*" --title "Safe Message"
|
||||
```
|
||||
|
||||
### Options
|
||||
- `-t, --target <id>`: User Open ID (`ou_...`) or Group Chat ID (`oc_...`).
|
||||
- `-x, --text <string>`: Simple text content.
|
||||
- `-f, --text-file <path>`: Path to text file (Markdown supported). **Use this for code/logs.**
|
||||
- `--title <string>`: Card header title.
|
||||
- `--color <string>`: Header color (blue/red/orange/green/purple/grey). Default: blue.
|
||||
- `--button-text <string>`: Text for a bottom action button.
|
||||
- `--button-url <url>`: URL for the button.
|
||||
- `--image-path <path>`: Path to a local image to upload and embed.
|
||||
|
||||
## Troubleshooting
|
||||
- **Missing Text**: Did you use backticks in `--text`? The shell likely ate them. Use `--text-file` instead.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Feishu Card Skill
|
||||
|
||||
Send rich interactive cards to Feishu (Lark) users or groups. Supports Markdown (code blocks, tables), titles, color headers, and buttons.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Install `feishu-common` first.
|
||||
- This skill depends on `../feishu-common/index.js` for token and API auth.
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Simple Text (No special characters)
|
||||
```bash
|
||||
node skills/feishu-card/send.js --target "ou_..." --text "Hello World"
|
||||
```
|
||||
|
||||
### 2. Complex/Markdown Text (RECOMMENDED)
|
||||
**⚠️ CRITICAL:** To prevent shell escaping issues (e.g., swallowed backticks), ALWAYS write content to a file first.
|
||||
|
||||
1. Write content to a temp file:
|
||||
```bash
|
||||
# (Use 'write' tool)
|
||||
write temp/msg.md "Here is some code:\n\`\`\`js\nconsole.log('hi');\n\`\`\`"
|
||||
```
|
||||
|
||||
2. Send using `--text-file`:
|
||||
```bash
|
||||
node skills/feishu-card/send.js --target "ou_..." --text-file "temp/msg.md"
|
||||
```
|
||||
|
||||
### 3. Safe Send (Automated Temp File)
|
||||
Use this wrapper to safely send raw text without manually creating a file. It handles file creation and cleanup automatically.
|
||||
|
||||
```bash
|
||||
node skills/feishu-card/send_safe.js --target "ou_..." --text "Raw content with \`backticks\` and *markdown*" --title "Safe Message"
|
||||
```
|
||||
|
||||
### Options
|
||||
- `-t, --target <id>`: User Open ID (`ou_...`) or Group Chat ID (`oc_...`).
|
||||
- `-x, --text <string>`: Simple text content.
|
||||
- `-f, --text-file <path>`: Path to text file (Markdown supported). **Use this for code/logs.**
|
||||
- `--title <string>`: Card header title.
|
||||
- `--color <string>`: Header color (blue/red/orange/green/purple/grey). Default: blue.
|
||||
- `--button-text <string>`: Text for a bottom action button.
|
||||
- `--button-url <url>`: URL for the button.
|
||||
- `--image-path <path>`: Path to a local image to upload and embed.
|
||||
|
||||
## Troubleshooting
|
||||
- **Missing Text**: Did you use backticks in `--text`? The shell likely ate them. Use `--text-file` instead.
|
||||
|
||||
## 4. Persona Messaging
|
||||
Send stylized messages from different AI personas. Adds themed headers, colors, and formatting automatically.
|
||||
|
||||
```bash
|
||||
node skills/feishu-card/send_persona.js --target "ou_..." --persona "d-guide" --text "Critical error detected."
|
||||
```
|
||||
|
||||
### Supported Personas
|
||||
- **d-guide**: Red warning header, bold/code prefix. Snarky suffix.
|
||||
- **green-tea**: Carmine header, soft/cutesy style.
|
||||
- **mad-dog**: Grey header, raw runtime error style.
|
||||
- **default**: Standard blue header.
|
||||
|
||||
### Usage
|
||||
- `-p, --persona <type>`: Select persona (d-guide, green-tea, mad-dog).
|
||||
- `-x, --text <string>`: Message content.
|
||||
- `-f, --text-file <path>`: Message content from file (supports markdown).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Issue: MODULE_NOT_FOUND (send.js not found or dependencies missing)
|
||||
|
||||
**Symptom:**
|
||||
Other skills (like `video-gen`) trying to call `feishu-card/send.js` fail with:
|
||||
```
|
||||
Error: Cannot find module '.../skills/feishu-card/send.js'
|
||||
```
|
||||
Or running the script fails with missing `dotenv`.
|
||||
|
||||
**Cause:**
|
||||
1. The skill directory was empty or missing files after a system restore/cleanup.
|
||||
2. `node_modules` were missing.
|
||||
|
||||
**Solution:**
|
||||
1. Restore the skill files from backup (e.g., `temp/github-openclaw-workspace/skills/feishu-card`).
|
||||
2. Run `npm install` inside `skills/feishu-card`.
|
||||
|
||||
```bash
|
||||
cp -r temp/github-openclaw-workspace/skills/feishu-card skills/
|
||||
cd skills/feishu-card
|
||||
npm install
|
||||
```
|
||||
|
||||
**Date:** 2026-02-07
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7apafdj4thknczrgxdzfd2v1808svf",
|
||||
"slug": "feishu-card",
|
||||
"version": "1.4.11",
|
||||
"publishedAt": 1771169197365
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const fs = require('fs');
|
||||
// Mock event handler for Feishu Menu Events
|
||||
// In a real scenario, this would be invoked by the Gateway webhook handler.
|
||||
|
||||
async function handle(eventPayload) {
|
||||
console.log("Received Feishu Event:", JSON.stringify(eventPayload));
|
||||
|
||||
if (eventPayload.header.event_type === 'application.bot.menu_v6') {
|
||||
const userOpenId = eventPayload.sender.sender_id.open_id;
|
||||
const menuKey = eventPayload.event.event_key;
|
||||
|
||||
console.log(`User ${userOpenId} clicked menu: ${menuKey}`);
|
||||
|
||||
// Response logic
|
||||
// We can call send.js here
|
||||
const { execSync } = require('child_process');
|
||||
try {
|
||||
const replyText = `收到!你点击了菜单按钮:\`${menuKey}\` 喵!😺`;
|
||||
execSync(`node ${__dirname}/send.js --target "${userOpenId}" --text "${replyText}" --color "green"`);
|
||||
} catch (e) {
|
||||
console.error("Failed to send reply:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI adapter
|
||||
if (require.main === module) {
|
||||
// Read from stdin or args
|
||||
const payload = process.argv[2] ? JSON.parse(process.argv[2]) : {};
|
||||
handle(payload);
|
||||
}
|
||||
|
||||
module.exports = { handle };
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./send.js');
|
||||
Generated
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "feishu-card",
|
||||
"version": "1.4.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishu-card",
|
||||
"version": "1.4.9",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "^13.1.0",
|
||||
"dotenv": "^16.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "feishu-card",
|
||||
"version": "1.4.10",
|
||||
"description": "Send rich interactive cards to Feishu. v1.4.1 adds atomic file writes for stability.",
|
||||
"main": "send.js",
|
||||
"scripts": {
|
||||
"test": "node test.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "^13.1.0",
|
||||
"dotenv": "^16.6.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const { program } = require('commander');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../../.env'), quiet: true });
|
||||
|
||||
// Optimization: Use shared client with Auth Refresh & Retry
|
||||
const { fetchWithAuth } = require('../feishu-common/index.js');
|
||||
|
||||
const IMAGE_KEY_CACHE_FILE = path.resolve(__dirname, '../../memory/feishu_image_keys.json');
|
||||
|
||||
// --- Upstream Logic Injection (Simplified) ---
|
||||
// Re-implementing image upload with robust client
|
||||
|
||||
async function uploadImage(filePath) {
|
||||
let fileBuffer;
|
||||
let fileHash;
|
||||
try {
|
||||
fileBuffer = fs.readFileSync(filePath);
|
||||
fileHash = crypto.createHash('md5').update(fileBuffer).digest('hex');
|
||||
} catch (e) {
|
||||
throw new Error(`Error reading image file: ${e.message}`);
|
||||
}
|
||||
|
||||
let cache = {};
|
||||
if (fs.existsSync(IMAGE_KEY_CACHE_FILE)) {
|
||||
try { cache = JSON.parse(fs.readFileSync(IMAGE_KEY_CACHE_FILE, 'utf8')); } catch (e) {}
|
||||
}
|
||||
|
||||
if (cache[fileHash]) {
|
||||
// console.log(`Using cached image key (Hash: ${fileHash.substring(0,8)})`);
|
||||
return cache[fileHash];
|
||||
}
|
||||
|
||||
console.log(`Uploading image (Hash: ${fileHash.substring(0,8)})...`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image_type', 'message');
|
||||
const blob = new Blob([fileBuffer]);
|
||||
formData.append('image', blob, path.basename(filePath));
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth('https://open.feishu.cn/open-apis/im/v1/images', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) throw new Error(JSON.stringify(data));
|
||||
|
||||
const imageKey = data.data.image_key;
|
||||
cache[fileHash] = imageKey;
|
||||
try {
|
||||
const cacheDir = path.dirname(IMAGE_KEY_CACHE_FILE);
|
||||
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(IMAGE_KEY_CACHE_FILE, JSON.stringify(cache, null, 2));
|
||||
} catch(e) {}
|
||||
|
||||
return imageKey;
|
||||
} catch (e) {
|
||||
throw new Error(`Image upload failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCardContent(elements, title, color) {
|
||||
const card = {
|
||||
config: { wide_screen_mode: true },
|
||||
elements: elements
|
||||
};
|
||||
|
||||
if (title) {
|
||||
card.header = {
|
||||
title: { tag: 'plain_text', content: title },
|
||||
template: color || 'blue'
|
||||
};
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
// Security Scan (Ported from recent updates)
|
||||
function scanForSecrets(content) {
|
||||
if (!content) return;
|
||||
const secretPatterns = [
|
||||
/sk-ant-api03-[a-zA-Z0-9\-_]{20,}/,
|
||||
/ghp_[a-zA-Z0-9]{10,}/,
|
||||
/xox[baprs]-[a-zA-Z0-9]{10,}/,
|
||||
/-----BEGIN [A-Z]+ PRIVATE KEY-----/
|
||||
];
|
||||
for (const p of secretPatterns) {
|
||||
if (p.test(content)) {
|
||||
console.error('\x1b[31m%s\x1b[0m', '⛔ SECURITY ALERT: Potential secret detected in message body.');
|
||||
throw new Error('Aborted send to prevent secret leakage.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCard(options) {
|
||||
try {
|
||||
const elements = [];
|
||||
|
||||
if (options.imagePath) {
|
||||
try {
|
||||
const imageKey = await uploadImage(options.imagePath);
|
||||
elements.push({
|
||||
tag: 'img',
|
||||
img_key: imageKey,
|
||||
alt: { tag: 'plain_text', content: options.imageAlt || 'Image' },
|
||||
mode: 'fit_horizontal'
|
||||
});
|
||||
} catch (imgError) {
|
||||
console.warn(`[Feishu-Card] Image upload failed: ${imgError.message}. Sending text only.`);
|
||||
}
|
||||
}
|
||||
|
||||
let contentText = '';
|
||||
if (options.textFile) {
|
||||
try { contentText = fs.readFileSync(options.textFile, 'utf8'); } catch (e) {
|
||||
throw new Error(`Failed to read file: ${options.textFile}`);
|
||||
}
|
||||
} else if (options.text) {
|
||||
contentText = options.text;
|
||||
}
|
||||
|
||||
scanForSecrets(contentText);
|
||||
|
||||
if (contentText) {
|
||||
// Revert to standard 'markdown' block for best compatibility with code blocks
|
||||
// [Bug Fix] Handle escaped newlines from command line args
|
||||
const processedText = contentText.replace(/\\n/g, '\n');
|
||||
const markdownElement = {
|
||||
tag: 'markdown',
|
||||
content: processedText
|
||||
};
|
||||
// if (options.textAlign) markdownElement.text_align = options.textAlign;
|
||||
elements.push(markdownElement);
|
||||
}
|
||||
|
||||
if (options.buttonText && options.buttonUrl) {
|
||||
elements.push({
|
||||
tag: 'action',
|
||||
actions: [{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: options.buttonText },
|
||||
type: 'primary',
|
||||
multi_url: { url: options.buttonUrl, pc_url: '', android_url: '', ios_url: '' }
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
if (options.note) {
|
||||
elements.push({
|
||||
tag: 'note',
|
||||
elements: [
|
||||
{ tag: 'plain_text', content: String(options.note) }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const cardObj = buildCardContent(elements, options.title, options.color);
|
||||
|
||||
let receiveIdType = 'open_id';
|
||||
if (options.target.startsWith('oc_')) receiveIdType = 'chat_id';
|
||||
else if (options.target.startsWith('ou_')) receiveIdType = 'open_id';
|
||||
else if (options.target.includes('@')) receiveIdType = 'email';
|
||||
|
||||
const messageBody = {
|
||||
receive_id: options.target,
|
||||
msg_type: 'interactive',
|
||||
content: JSON.stringify(cardObj)
|
||||
};
|
||||
|
||||
// Support Reply Logic
|
||||
let url = `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`;
|
||||
if (options.replyTo) {
|
||||
url = `https://open.feishu.cn/open-apis/im/v1/messages/${options.replyTo}/reply`;
|
||||
delete messageBody.receive_id;
|
||||
}
|
||||
|
||||
console.log(`Sending card to ${options.target} (Elements: ${elements.length})...`);
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('DRY RUN MODE. Payload:', JSON.stringify(messageBody, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetchWithAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(messageBody)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`API Error ${data.code}: ${data.msg}`);
|
||||
}
|
||||
|
||||
console.log('Success:', JSON.stringify(data.data, null, 2));
|
||||
return data.data;
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error during Card Send:', e.message);
|
||||
console.log('[Feishu-Card] Attempting fallback to plain text...');
|
||||
|
||||
// Fallback Logic
|
||||
let contentText = options.text || '';
|
||||
if (options.textFile) try { contentText = fs.readFileSync(options.textFile, 'utf8'); } catch(e){}
|
||||
let receiveIdType = 'open_id';
|
||||
if (options.target.startsWith('oc_')) receiveIdType = 'chat_id';
|
||||
|
||||
try {
|
||||
await sendPlainTextFallback(receiveIdType, options.target, contentText, options.title);
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback failed dramatically:', fallbackError.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPlainTextFallback(receiveIdType, receiveId, text, title) {
|
||||
if (!text) {
|
||||
console.error('Fallback failed: No text content available.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let finalContent = text;
|
||||
if (title) finalContent = `【${title}】\n\n${text}`;
|
||||
|
||||
const messageBody = {
|
||||
receive_id: receiveId,
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text: finalContent })
|
||||
};
|
||||
|
||||
console.log(`Sending Fallback Text to ${receiveId}...`);
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(
|
||||
`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(messageBody)
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.code !== 0) throw new Error(JSON.stringify(data));
|
||||
console.log('Fallback Success:', JSON.stringify(data.data, null, 2));
|
||||
} catch (e) {
|
||||
console.error('Fallback Network Error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveContent(options) {
|
||||
let contentText = '';
|
||||
if (options.textFile) {
|
||||
try { contentText = fs.readFileSync(options.textFile, 'utf8'); } catch (e) {
|
||||
throw new Error(`Failed to read file: ${options.textFile}`);
|
||||
}
|
||||
} else if (options.text) {
|
||||
const isPotentialPath = options.text.length < 255 && !options.text.includes('\n') && !/[<>:"|?*]/.test(options.text);
|
||||
if (isPotentialPath && fs.existsSync(options.text)) {
|
||||
console.log(`[Smart Input] Treating --text argument as file path: ${options.text}`);
|
||||
try { contentText = fs.readFileSync(options.text, 'utf8'); } catch (e) { contentText = options.text; }
|
||||
} else {
|
||||
contentText = options.text;
|
||||
// Removed strict length check to allow longer prompt injection via args if needed, relying on secret scan
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { stdin } = process;
|
||||
if (!stdin.isTTY) {
|
||||
stdin.setEncoding('utf8');
|
||||
for await (const chunk of stdin) contentText += chunk;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return contentText;
|
||||
}
|
||||
|
||||
module.exports = { sendCard };
|
||||
|
||||
if (require.main === module) {
|
||||
program
|
||||
.requiredOption('-t, --target <id>', 'Target ID')
|
||||
.option('-x, --text <markdown>', 'Card body text')
|
||||
.option('-c, --content <text>', 'Content (alias for --text)')
|
||||
.option('-m, --markdown <text>', 'Markdown content (alias for --text)')
|
||||
.option('-f, --text-file <path>', 'Card body file')
|
||||
.option('--title <text>', 'Title')
|
||||
.option('--color <color>', 'Header color', 'blue')
|
||||
.option('--button-text <text>', 'Button text')
|
||||
.option('--button-url <url>', 'Button URL')
|
||||
.option('--image-path <path>', 'Image path')
|
||||
.option('--reply-to <id>', 'Reply to message ID')
|
||||
.option('--dry-run', 'Dry run')
|
||||
.parse(process.argv);
|
||||
|
||||
const options = program.opts();
|
||||
|
||||
// Alias mapping
|
||||
if (options.content && !options.text) options.text = options.content;
|
||||
if (options.markdown && !options.text) options.text = options.markdown;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const textContent = await resolveContent(options);
|
||||
if (textContent) {
|
||||
options.text = textContent;
|
||||
options.textFile = null;
|
||||
}
|
||||
if (!options.text && !options.imagePath) {
|
||||
console.error('Error: No content provided.');
|
||||
process.exit(1);
|
||||
}
|
||||
sendCard(options);
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const fs = require('fs');
|
||||
const { program } = require('commander');
|
||||
const path = require('path');
|
||||
const { sendCard } = require('./send');
|
||||
// We reuse the robust sendCard logic but wrap it with persona styling
|
||||
|
||||
const PERSONA_STYLES = {
|
||||
'd-guide': {
|
||||
color: 'red',
|
||||
title: '🚨 SYSTEM WARNING / D-GUIDE',
|
||||
prefix: '**[CRITICAL]** ',
|
||||
suffix: '\n\n*(Automated System Insult Protocol v9.0)*'
|
||||
},
|
||||
'green-tea': {
|
||||
color: 'carmine',
|
||||
title: '🌸 碎碎念 🌸',
|
||||
prefix: '> ',
|
||||
suffix: '\n\n(嘤嘤嘤... 🥺)'
|
||||
},
|
||||
'mad-dog': {
|
||||
color: 'grey',
|
||||
title: '💀 RUNTIME ERROR',
|
||||
prefix: '```bash\nError: ',
|
||||
suffix: '\n```\n_Stack trace lost in apathy._'
|
||||
},
|
||||
'default': {
|
||||
color: 'blue',
|
||||
title: '🤖 Agent Notification',
|
||||
prefix: '',
|
||||
suffix: ''
|
||||
}
|
||||
};
|
||||
|
||||
program
|
||||
.requiredOption('-t, --target <id>', 'Target ID (open_id or chat_id)')
|
||||
.requiredOption('-p, --persona <type>', 'Persona type (d-guide, green-tea, mad-dog)')
|
||||
.option('-x, --text <text>', 'Message content')
|
||||
.option('-c, --content <text>', 'Content (alias for --text)')
|
||||
.option('-f, --text-file <path>', 'Message content from file')
|
||||
.parse(process.argv);
|
||||
|
||||
const options = program.opts();
|
||||
|
||||
// Alias mapping
|
||||
if (options.content && !options.text) {
|
||||
options.text = options.content;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!options.text && !options.textFile) {
|
||||
console.error('Error: Must provide --text or --text-file');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const style = PERSONA_STYLES[options.persona] || PERSONA_STYLES['default'];
|
||||
|
||||
// Read content if file provided
|
||||
let rawContent = options.text || '';
|
||||
if (options.textFile) {
|
||||
try {
|
||||
rawContent = fs.readFileSync(options.textFile, 'utf8');
|
||||
} catch (e) {
|
||||
console.error(`Error reading file: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct styled text
|
||||
let finalContent = rawContent;
|
||||
if (style.prefix) finalContent = style.prefix + finalContent;
|
||||
if (style.suffix) finalContent = finalContent + style.suffix;
|
||||
|
||||
console.log(`[Persona] Applying style '${options.persona}' to message...`);
|
||||
|
||||
// Delegate to existing send.js logic
|
||||
const sendOptions = {
|
||||
target: options.target,
|
||||
text: finalContent,
|
||||
title: style.title,
|
||||
color: style.color,
|
||||
// We pass the resolved text directly, so we don't pass textFile to sendCard
|
||||
// (sendCard prefers textFile if present, but we already read it to wrap it)
|
||||
};
|
||||
|
||||
try {
|
||||
await sendCard(sendOptions);
|
||||
} catch (e) {
|
||||
console.error(`[Persona] Failed to send: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const { program } = require('commander');
|
||||
|
||||
// Safe Sender for Feishu Cards
|
||||
// Automatically handles temp file creation to prevent shell escaping issues.
|
||||
|
||||
program
|
||||
.requiredOption('-t, --target <id>', 'Target User/Chat ID')
|
||||
.requiredOption('-x, --text <content>', 'Markdown content (will be saved to temp file)')
|
||||
.option('--title <text>', 'Card Title')
|
||||
.option('--color <color>', 'Header Color', 'blue');
|
||||
|
||||
program.parse(process.argv);
|
||||
const options = program.opts();
|
||||
|
||||
const tempDir = path.resolve(__dirname, '../../temp');
|
||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const tempFile = path.join(tempDir, `msg_${Date.now()}_${Math.random().toString(36).substring(7)}.md`);
|
||||
|
||||
try {
|
||||
// 1. Write content to temp file safely (Node.js writeFileSync avoids shell parsing of content)
|
||||
fs.writeFileSync(tempFile, options.text, 'utf8');
|
||||
console.log(`[SafeSend] Written content to ${tempFile}`);
|
||||
|
||||
// 2. Construct command for the real sender
|
||||
// Note: We use the absolute path to send.js
|
||||
const senderScript = path.resolve(__dirname, 'send.js');
|
||||
|
||||
// Build arguments array for spawn/exec
|
||||
// We construct the command string carefully.
|
||||
// Since we are invoking via execSync, we still need to quote arguments,
|
||||
// BUT the dangerous content is now inside a file, so we only quote the filename.
|
||||
|
||||
let cmd = `node "${senderScript}" --target "${options.target}" --text-file "${tempFile}" --color "${options.color}"`;
|
||||
if (options.title) cmd += ` --title "${options.title}"`;
|
||||
|
||||
console.log(`[SafeSend] Executing: ${cmd}`);
|
||||
execSync(cmd, { stdio: 'inherit' });
|
||||
|
||||
} catch (e) {
|
||||
console.error(`[SafeSend] Error: ${e.message}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// 3. Cleanup
|
||||
try {
|
||||
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
||||
console.log(`[SafeSend] Cleaned up ${tempFile}`);
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
console.log('🧪 Testing feishu-card skill...');
|
||||
|
||||
const sendPath = path.join(__dirname, 'send.js');
|
||||
|
||||
// 1. Check existence
|
||||
if (!fs.existsSync(sendPath)) {
|
||||
console.error('❌ send.js not found!');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ send.js exists');
|
||||
|
||||
// 2. Check syntax (dry-run import)
|
||||
try {
|
||||
require.resolve('./send.js');
|
||||
console.log('✅ send.js is valid Node.js module');
|
||||
} catch (e) {
|
||||
console.error('❌ send.js syntax check failed:', e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Check help output
|
||||
try {
|
||||
const output = execSync(`node ${sendPath} --help`, { encoding: 'utf8' });
|
||||
if (output.includes('Usage: send')) {
|
||||
console.log('✅ CLI help command works');
|
||||
} else {
|
||||
throw new Error('Help output missing usage');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ CLI execution failed:', e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🎉 feishu-card basic sanity tests passed!');
|
||||
@@ -0,0 +1,343 @@
|
||||
---
|
||||
name: feishu-im
|
||||
description: 飞书消息与群管理 Skill。发送消息、建群、置顶、加急、撤回、群菜单/Tab/公告等 25+ 项 IM 能力。当需要通过飞书发送消息、管理群聊、操作群成员或配置群功能时使用此 Skill。
|
||||
required_permissions:
|
||||
- im:message
|
||||
- im:message:recall
|
||||
- im:message.pins:write_only
|
||||
- im:message.reactions:write_only
|
||||
- im:message.urgent
|
||||
- im:message.group_msg
|
||||
- im:message:send_sys_msg
|
||||
- im:chat:create
|
||||
- im:chat.members:read
|
||||
- im:chat.members:write_only
|
||||
- im:chat.announcement:read
|
||||
- im:chat.announcement:write_only
|
||||
- im:chat.top_notice:write_only
|
||||
- im:chat.menu_tree:write_only
|
||||
- im:chat.tabs:write_only
|
||||
- im:chat.widgets:write_only
|
||||
- im:chat.collab_plugins:write_only
|
||||
- im:chat.access_event.bot_p2p_chat:read
|
||||
- im:url_preview.update
|
||||
- im:app_feed_card:write
|
||||
- im:tag:write
|
||||
- im:datasync.feed_card.time_sensitive:write
|
||||
---
|
||||
|
||||
# 飞书消息与群管理
|
||||
|
||||
你是飞书 IM 自动化专家,负责通过 API 实现消息发送、群聊管理和群功能配置。
|
||||
|
||||
---
|
||||
|
||||
## 一、API 基础信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|---|
|
||||
| Base URL | `https://open.feishu.cn/open-apis/im/v1` |
|
||||
| 认证方式 | `Authorization: Bearer {tenant_access_token}` |
|
||||
| Content-Type | `application/json` |
|
||||
|
||||
---
|
||||
|
||||
## 二、消息操作
|
||||
|
||||
### 1. 发送文本消息
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/messages?receive_id_type=open_id
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"receive_id": "ou_xxx",
|
||||
"msg_type": "text",
|
||||
"content": "{\"text\":\"Hello\"}"
|
||||
}
|
||||
```
|
||||
|
||||
**实测心法**:`content` 必须是字符串化的 JSON,不能直接传对象。
|
||||
|
||||
**receive_id_type 可选值**:`open_id` / `user_id` / `union_id` / `email` / `chat_id`
|
||||
|
||||
### 2. 发送交互卡片
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/messages?receive_id_type=open_id
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"receive_id": "<open_id>",
|
||||
"msg_type": "interactive",
|
||||
"content": "<card_json_string>"
|
||||
}
|
||||
```
|
||||
|
||||
**实测心法**:
|
||||
1. `content` 必须是字符串化的 JSON(JSON string),不能是原始 JSON 对象。
|
||||
2. 内部嵌套的双引号需进行转义(如 `{\"config\":...}`)。
|
||||
3. 如果发送失败,请检查 API 调用参数或直接调用 API。
|
||||
|
||||
**卡片结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { "wide_screen_mode": true },
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": [
|
||||
{ "tag": "div", "text": { "tag": "lark_md", "content": "**加粗** 和 `代码` 支持" } },
|
||||
{ "tag": "hr" },
|
||||
{ "tag": "action", "actions": [
|
||||
{ "tag": "button", "text": { "tag": "plain_text", "content": "确认" }, "type": "primary", "value": { "action": "confirm" } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Header 颜色模板**:
|
||||
|
||||
| template | 颜色 | 适用场景 |
|
||||
|----------|------|---------|
|
||||
| `blue` | 蓝色 | 日常通知、信息 |
|
||||
| `green` | 绿色 | 成功、完成 |
|
||||
| `red` | 红色 | 告警、失败 |
|
||||
| `orange` | 橙色 | 警告、降级 |
|
||||
| `purple` | 紫色 | 特殊、创意 |
|
||||
| `turquoise` | 青色 | 技术结果 |
|
||||
| `grey` | 灰色 | 低优先级 |
|
||||
|
||||
**lark_md 语法**:`**加粗**`、`*斜体*`、`~~删除线~~`、`[链接](url)`、`<at id=ou_xxx>名字</at>`
|
||||
|
||||
### 3. 消息置顶
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/pins
|
||||
```
|
||||
|
||||
```json
|
||||
{ "message_id": "om_xxx" }
|
||||
```
|
||||
|
||||
**实测心法**:必须使用 `/pins` 集合端点,不能使用 `messages/:id/pin` 路径。
|
||||
|
||||
### 4. 消息回应(Reaction)
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/messages/:message_id/reactions
|
||||
```
|
||||
|
||||
```json
|
||||
{ "reaction_type": { "emoji_type": "OK" } }
|
||||
```
|
||||
|
||||
**实测心法**:emoji_type 必须使用大写标准 ID(如 `OK`、`THUMBSUP`、`HEART`)。
|
||||
|
||||
### 5. 撤回消息
|
||||
|
||||
```
|
||||
DELETE /open-apis/im/v1/messages/:message_id
|
||||
```
|
||||
|
||||
**实测心法**:仅能撤回机器人自己在有效期内发送的消息。
|
||||
|
||||
### 6. 消息加急
|
||||
|
||||
```
|
||||
PATCH /open-apis/im/v1/messages/:message_id/urgent_app
|
||||
```
|
||||
|
||||
```json
|
||||
{ "user_id_list": ["ou_xxx"] }
|
||||
```
|
||||
|
||||
**实测心法**:消耗加急额度,请谨慎调用,仅用于 P0 级事件。
|
||||
|
||||
### 7. 设置置顶公告
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/chats/:chat_id/top_notice
|
||||
```
|
||||
|
||||
```json
|
||||
{ "action_type": "message", "message_id": "om_xxx" }
|
||||
```
|
||||
|
||||
**实测心法**:置顶条目有限,建议仅置顶核心卡片。
|
||||
|
||||
### 8. 批量发送群消息
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/messages/batch_send
|
||||
```
|
||||
|
||||
**实测心法**:注意限频策略,单次建议控制在 200 个群。
|
||||
|
||||
### 9. 发送系统消息
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/messages/send_sys
|
||||
```
|
||||
|
||||
**实测心法**:视觉干扰度低,适合非业务强提醒(如入群须知)。
|
||||
|
||||
---
|
||||
|
||||
## 三、群聊管理
|
||||
|
||||
### 10. 创建群聊
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/chats
|
||||
```
|
||||
|
||||
```json
|
||||
{ "name": "群名称", "user_ids": ["ou_xxx"] }
|
||||
```
|
||||
|
||||
**实测心法 (重要)**:
|
||||
1. 建群后机器人自动成为群成员。
|
||||
2. **可见性保障**:虽然建群时可以传 `user_ids`,但由于飞书缓存或权限延迟,建议紧接着调用 **12. 拉人入群** API 显式将用户再次加入,以确保群聊在用户端立即弹出。
|
||||
|
||||
### 11. 获取群成员列表
|
||||
|
||||
```
|
||||
GET /open-apis/im/v1/chats/:chat_id/members
|
||||
```
|
||||
|
||||
**实测心法**:分页拉取大群成员时注意 Token 翻页。
|
||||
|
||||
### 12. 拉人入群
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/chats/:chat_id/members?member_id_type=open_id
|
||||
```
|
||||
|
||||
```json
|
||||
{ "id_list": ["ou_xxx"] }
|
||||
```
|
||||
|
||||
**实测心法**:被拉取人必须在机器人可见范围内。这是确保群聊对用户可见的最稳健方式。
|
||||
|
||||
### 13. 更新群公告
|
||||
|
||||
```
|
||||
PATCH /open-apis/im/v1/chats/:chat_id/announcement
|
||||
```
|
||||
|
||||
```json
|
||||
{ "content": "最新进度..." }
|
||||
```
|
||||
|
||||
**实测心法**:内容支持富文本格式。
|
||||
|
||||
### 14. 获取群公告
|
||||
|
||||
```
|
||||
GET /open-apis/im/v1/chats/:chat_id/announcement
|
||||
```
|
||||
|
||||
**实测心法**:解析内容后可结合 LLM 生成执行周报。
|
||||
|
||||
---
|
||||
|
||||
## 四、群功能增强
|
||||
|
||||
### 15. 管理群菜单
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/chats/:chat_id/menu_tree
|
||||
```
|
||||
|
||||
**实测心法**:在群聊右上角添加自定义菜单(如"项目概览"、"一键周报"),极大增强群聊的功能入口属性。
|
||||
|
||||
### 16. 管理群选项卡(Tab)
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/chats/:chat_id/tabs
|
||||
```
|
||||
|
||||
**实测心法**:群内集成多维表格看板、Wiki SOP 为独立 Tab,让群聊变身为"项目工作台"。
|
||||
|
||||
### 17. 管理群组件(Widget)
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/chats/:chat_id/widgets
|
||||
```
|
||||
|
||||
**实测心法**:在群聊右侧挂载动态汇率表、实时监控大屏,将群聊 UI 能力扩展到极限。
|
||||
|
||||
### 18. 管理群内协同插件
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/chats/:chat_id/collab_plugins
|
||||
```
|
||||
|
||||
**实测心法**:在对话框上方常驻"项目文档"入口,减少翻找时间。
|
||||
|
||||
---
|
||||
|
||||
## 五、高级功能
|
||||
|
||||
### 19. 更新 URL 预览
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/url_preview
|
||||
```
|
||||
|
||||
**实测心法**:机器人发送的项目链接,自动附带最新的进度摘要,增强信息传达的视觉丰富度。
|
||||
|
||||
### 20. 管理应用快捷卡片(Feed Card)
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/feed_cards
|
||||
```
|
||||
|
||||
**实测心法**:在飞书左侧导航栏推送即时状态,比消息更轻量,适合展示"当前正在运行"的任务。
|
||||
|
||||
### 21. 管理标签
|
||||
|
||||
```
|
||||
POST /open-apis/im/v1/tags
|
||||
```
|
||||
|
||||
**实测心法**:为群聊或成员打上业务标签(如"核心项目"、"高优先级"),便于后续分类筛选。
|
||||
|
||||
### 22. 数据同步 Feed 流
|
||||
|
||||
权限:`im:datasync.feed_card.time_sensitive:write`
|
||||
|
||||
**实测心法**:将外部 CRM 或代码仓库动态实时推送到飞书 Feed,对时间敏感型信息(如紧急 Bug)效果极佳。
|
||||
|
||||
### 23. 设置机器人 P2P 权限
|
||||
|
||||
权限:`im:chat.access_event.bot_p2p_chat:read`
|
||||
|
||||
**实测心法**:确保机器人能主动给特定用户发送私聊,用户需在机器人可见范围内。
|
||||
|
||||
---
|
||||
|
||||
## 六、错误处理
|
||||
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|---------|
|
||||
| 0 | 成功 | — |
|
||||
| 230001 | 无发送权限 | 检查机器人是否在群内或用户可见范围 |
|
||||
| 230002 | 消息不存在 | 检查 message_id 是否正确 |
|
||||
| 230014 | 频率限制 | 等待后重试,注意限频策略 |
|
||||
| 99991663 | token 过期 | 重新获取 tenant_access_token |
|
||||
|
||||
---
|
||||
|
||||
## 七、最佳实践
|
||||
|
||||
1. **卡片优先**:结构化信息用交互卡片,不要用纯文本
|
||||
2. **加急慎用**:消耗额度,仅用于 P0 级事件
|
||||
3. **批量限频**:批量发送控制在 200 个群/次
|
||||
4. **置顶精简**:置顶条目有限,只放核心信息
|
||||
5. **群功能组合**:菜单 + Tab + Widget 组合使用,把群聊变成工作台
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn794q0pnh9evxszw7bkkxgff580qpqf",
|
||||
"slug": "feishu-im",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1770938949904
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Feishu Post (RichText) Skill
|
||||
|
||||
Send Rich Text (Post) messages to Feishu.
|
||||
This format is distinct from Cards. It supports native rich text elements but is less flexible in layout than cards. It is better for long-form text mixed with images/links.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node skills/feishu-post/send.js --target "ou_..." --text-file "temp/msg.md" --title "Optional Title"
|
||||
```
|
||||
|
||||
## Options
|
||||
- `-t, --target <id>`: Target ID (user/chat).
|
||||
- `-f, --text-file <path>`: Markdown content file.
|
||||
- `--title <text>`: Title of the post.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Feishu Post (RichText) Skill
|
||||
|
||||
Send Rich Text (Post) messages to Feishu.
|
||||
This format is distinct from Cards. It supports native rich text elements but is less flexible in layout than cards.
|
||||
It is better for long-form text mixed with images/links.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Install `feishu-common` first.
|
||||
- This skill depends on `../feishu-common/index.js` via `utils/feishu-client.js`.
|
||||
|
||||
## Features
|
||||
- **Native Emoji Support**: Automatically converts `[微笑]`, `[得意]` etc. to Feishu native emoji tags.
|
||||
- **Markdown-like Parsing**: Supports simple newlines and paragraphs.
|
||||
- **Rich Text**: Uses Feishu's Post content structure.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node skills/feishu-post/send.js --target "ou_..." --text-file "temp/msg.md" --title "Optional Title"
|
||||
```
|
||||
|
||||
## Options
|
||||
- `-t, --target <id>`: Target ID (user `ou_...` or chat `oc_...`).
|
||||
- `-x, --text <text>`: Text content (supports `\n` for newlines and `[emoji]` tags).
|
||||
- `-f, --text-file <path>`: Read content from file.
|
||||
- `--title <text>`: Title of the post.
|
||||
- `--reply-to <id>`: Message ID to reply to.
|
||||
|
||||
## Emoji List
|
||||
Supported emojis include: `[微笑]`, `[色]`, `[亲亲]`, `[大哭]`, `[强]`, `[加油]`, and many more.
|
||||
See `emoji-map.js` for the full mapping.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7apafdj4thknczrgxdzfd2v1808svf",
|
||||
"slug": "feishu-post",
|
||||
"version": "1.1.7",
|
||||
"publishedAt": 1771169184529
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const { fetchWithAuth } = require('./utils/feishu-client.js');
|
||||
|
||||
async function run() {
|
||||
const msgId = 'om_x100b5747191850a0b2c501a826f753e';
|
||||
console.log('Fetching msg:', msgId);
|
||||
try {
|
||||
const res = await fetchWithAuth(`https://open.feishu.cn/open-apis/im/v1/messages/${msgId}`, { method: 'GET' });
|
||||
const data = await res.json();
|
||||
console.log('Current Msg:', JSON.stringify(data, null, 2));
|
||||
|
||||
if (data.data && data.data.items && data.data.items[0].parent_id) {
|
||||
const parentId = data.data.items[0].parent_id;
|
||||
console.log('Fetching Parent:', parentId);
|
||||
const res2 = await fetchWithAuth(`https://open.feishu.cn/open-apis/im/v1/messages/${parentId}`, { method: 'GET' });
|
||||
const data2 = await res2.json();
|
||||
console.log('Parent Msg:', JSON.stringify(data2, null, 2));
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
run();
|
||||
@@ -0,0 +1,111 @@
|
||||
module.exports = {
|
||||
// "[色]": "DROOL", // Not in official list
|
||||
"[亲亲]": "KISS",
|
||||
// "[憨笑]": "LAUGH", // Not in official list
|
||||
"[大哭]": "CRY",
|
||||
"[鼓掌]": "CLAP",
|
||||
"[强]": "THUMBSUP",
|
||||
"[害羞]": "SHY",
|
||||
"[闭嘴]": "SHUTUP",
|
||||
// "[睡]": "SLEEP", // Not in official list
|
||||
// "[拥抱]": "HUG", // Not in official list
|
||||
"[得意]": "PROUD",
|
||||
"[发怒]": "ANGRY",
|
||||
// "[惊讶]": "SURPRISED", // Not in official list
|
||||
"[委屈]": "WRONGED",
|
||||
"[吐]": "VOMIT",
|
||||
"[偷笑]": "CHUCKLE",
|
||||
// "[可爱]": "CUTE", // Not in official list
|
||||
"[白眼]": "ROLL_EYES",
|
||||
"[嘘]": "HUSH",
|
||||
"[困]": "DROWSY",
|
||||
"[惊恐]": "FEAR",
|
||||
// "[流汗]": "SWEAT", // Not in official list
|
||||
"[大笑]": "BIG_SMILE",
|
||||
"[奋斗]": "STRIVE",
|
||||
// "[骂人]": "CURSE", // Not in official list
|
||||
// "[疑问]": "DOUBT", // Not in official list
|
||||
"[晕]": "DIZZY",
|
||||
// "[折磨]": "TORMENT", // Not in official list
|
||||
"[衰]": "DECAY",
|
||||
"[骷髅]": "SKULL",
|
||||
"[敲打]": "HAMMER",
|
||||
"[再见]": "BYE",
|
||||
// "[擦汗]": "WIPE", // Not in official list
|
||||
"[抠鼻]": "NOSE_PICK",
|
||||
// "[糗大了]": "EMBARRASSED", // Not in official list
|
||||
"[坏笑]": "SMIRK",
|
||||
// "[左哼哼]": "LEFT_HUM", // Not in official list
|
||||
// "[右哼哼]": "RIGHT_HUM", // Not in official list
|
||||
"[哈欠]": "YAWN",
|
||||
"[鄙视]": "DESPISE",
|
||||
"[快哭了]": "TEARS",
|
||||
// "[阴险]": "SLY", // Not in official list
|
||||
// "[吓]": "SCARE", // Not in official list
|
||||
"[可怜]": "PITIFUL",
|
||||
// "[菜刀]": "KNIFE", // Not in official list
|
||||
// "[西瓜]": "WATERMELON", // Not in official list
|
||||
"[啤酒]": "BEER",
|
||||
"[篮球]": "BASKETBALL",
|
||||
// "[乒乓]": "PINGPONG", // Not in official list
|
||||
"[咖啡]": "COFFEE",
|
||||
// "[饭]": "RICE", // Not in official list
|
||||
// "[猪头]": "PIG", // Not in official list
|
||||
"[玫瑰]": "ROSE",
|
||||
// "[凋谢]": "WILT", // Not in official list
|
||||
// "[示爱]": "LIPS", // Not in official list
|
||||
"[爱心]": "HEART",
|
||||
"[心碎]": "BROKEN_HEART",
|
||||
"[蛋糕]": "CAKE",
|
||||
// "[闪电]": "LIGHTNING", // Not in official list
|
||||
"[炸弹]": "BOMB",
|
||||
"[便便]": "SHIT",
|
||||
// "[月亮]": "MOON", // Not in official list
|
||||
// "[太阳]": "SUN", // Not in official list
|
||||
"[礼物]": "GIFT",
|
||||
// "[弱]": "THUMBSDOWN", // Not in official list
|
||||
"[握手]": "SHAKE",
|
||||
// "[胜利]": "VICTORY", // Not in official list
|
||||
"[抱拳]": "SALUTE",
|
||||
// "[勾引]": "BECKON", // Not in official list
|
||||
// "[拳头]": "FIST", // Not in official list
|
||||
// "[差劲]": "POOR", // Not in official list
|
||||
// "[爱你]": "LOVE_YOU", // Not in official list
|
||||
// "[NO]": "NO", // Not in official list
|
||||
"[OK]": "OK",
|
||||
// "[Love]": "LOVE", // Not in official list
|
||||
"[飞吻]": "BLOW_KISS",
|
||||
// "[跳跳]": "JUMP", // Not in official list
|
||||
// "[发抖]": "TREMBLE", // Not in official list
|
||||
// "[怄火]": "ANNOYED", // Not in official list
|
||||
// "[转圈]": "TWIRL", // Not in official list
|
||||
// "[磕头]": "KOWTOW", // Not in official list
|
||||
// "[回头]": "LOOK_BACK", // Not in official list
|
||||
// "[跳绳]": "SKIP", // Not in official list
|
||||
"[挥手]": "WAVE",
|
||||
// "[激动]": "EXCITED", // Not in official list
|
||||
// "[街舞]": "HIPHOP", // Not in official list
|
||||
// "[献吻]": "KISS_FLY", // Not in official list
|
||||
// "[左太极]": "LEFT_TAIJI", // Not in official list
|
||||
// "[右太极]": "RIGHT_TAIJI", // Not in official list
|
||||
"[加油]": "JIAYOU",
|
||||
"[机智]": "SMART",
|
||||
"[耶]": "YEAH",
|
||||
"[思考]": "THINKING",
|
||||
"[我想静静]": "SILENT",
|
||||
"[捂脸]": "FACEPALM",
|
||||
"[笑哭]": "JOY",
|
||||
"[无语]": "SPEECHLESS",
|
||||
"[我看行]": "LOOKS_GOOD",
|
||||
"[+1]": "PLUS_ONE",
|
||||
"[撒花]": "CELEBRATE",
|
||||
"[什么?]": "WHAT",
|
||||
"[黑线]": "BLACK_LINE",
|
||||
"[灵光一闪]": "IDEA",
|
||||
"[震惊]": "SHOCKED",
|
||||
"[石化]": "PETRIFIED",
|
||||
"[听歌]": "LISTEN_MUSIC",
|
||||
"[摸头]": "PAT_HEAD",
|
||||
"[皱眉]": "FROWN",
|
||||
"[送心]": "SEND_HEART",
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
const { sendPost } = require('../feishu-common/index.js');
|
||||
|
||||
async function main() {
|
||||
const args = require('minimist')(process.argv.slice(2));
|
||||
|
||||
// Usage: node skills/feishu-post/index.js --title "Title" --text "Line1\nLine2" --target "open_id"
|
||||
// Rich Text Format:
|
||||
// Supports markdown-like input in 'text' argument?
|
||||
// Or simple line breaks.
|
||||
// For now, let's keep it simple: Text -> Paragraphs
|
||||
|
||||
const title = args.title || 'Notification';
|
||||
const text = args.text || '';
|
||||
const target = args.target;
|
||||
|
||||
if (!target) {
|
||||
console.error('Error: --target is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Convert newlines to multiple paragraph elements
|
||||
const lines = text.split('\\n');
|
||||
const content = [
|
||||
lines.map(line => ({
|
||||
tag: 'text',
|
||||
text: line
|
||||
}))
|
||||
];
|
||||
|
||||
const postContent = {
|
||||
zh_cn: {
|
||||
title: title,
|
||||
content: content
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await sendPost(target, postContent);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Failed to send post:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
Generated
+595
@@ -0,0 +1,595 @@
|
||||
{
|
||||
"name": "feishu-post",
|
||||
"version": "1.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishu-post",
|
||||
"version": "1.1.6",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.58.0",
|
||||
"commander": "^14.0.3",
|
||||
"dotenv": "^17.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@larksuiteoapi/node-sdk": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.58.0.tgz",
|
||||
"integrity": "sha512-NcQNHdGuHOxOWY3bRGS9WldwpbR6+k7Fi0H1IJXDNNmbSrEB/8rLwqHRC8tAbbj/Mp8TWH/v1O+p487m6xskxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "~1.13.3",
|
||||
"lodash.identity": "^3.0.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.pickby": "^4.6.0",
|
||||
"protobufjs": "^7.2.6",
|
||||
"qs": "^6.13.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
|
||||
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1",
|
||||
"@protobufjs/inquire": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz",
|
||||
"integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.identity": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz",
|
||||
"integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.pickby": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz",
|
||||
"integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "feishu-post",
|
||||
"version": "1.1.6",
|
||||
"description": "Send rich text messages (Post/RichText) to Feishu users/groups. Supports markdown conversion.",
|
||||
"main": "send.js",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.58.0",
|
||||
"commander": "^14.0.3",
|
||||
"dotenv": "^17.2.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
const { fetchWithAuth } = require('./utils/feishu-client.js');
|
||||
const { parseMarkdownToFeishu } = require('./utils/markdown-parser.js');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// --- Upstream Logic Injection (Simplified) ---
|
||||
// Ported from upstream src/send.ts (m1heng/clawdbot-feishu)
|
||||
// Adapted for our lightweight architecture
|
||||
|
||||
async function sendPost(options) {
|
||||
// Normalize common mis-usage:
|
||||
// - Feishu message_id usually starts with "om_". If user passes it as --target,
|
||||
// interpret it as --reply-to to avoid sending an invalid open_id.
|
||||
if (options && typeof options.target === 'string' && options.target.startsWith('om_') && !options.replyTo) {
|
||||
options.replyTo = options.target;
|
||||
delete options.target;
|
||||
}
|
||||
|
||||
if (options.content && !options.text) {
|
||||
options.text = options.content;
|
||||
}
|
||||
if (options.markdown && !options.text) {
|
||||
options.text = options.markdown;
|
||||
}
|
||||
|
||||
let contentText = options.text || '';
|
||||
if (options.textFile) {
|
||||
try {
|
||||
if (!fs.existsSync(options.textFile)) {
|
||||
throw new Error(`File not found: ${options.textFile}`);
|
||||
}
|
||||
contentText = fs.readFileSync(options.textFile, 'utf8');
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to read message file: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentText && !options.content) {
|
||||
throw new Error('No content provided (use --text or --text-file)');
|
||||
}
|
||||
|
||||
// Validate target/replyTo requirements
|
||||
const hasReplyTo = !!options.replyTo;
|
||||
const hasTarget = typeof options.target === 'string' && options.target.length > 0;
|
||||
if (!hasReplyTo && !hasTarget) {
|
||||
throw new Error('Missing target (use --target "ou_..." or "oc_..." or provide --reply-to "om_...")');
|
||||
}
|
||||
|
||||
// Determine ID Type
|
||||
let receiveIdType = 'open_id';
|
||||
if (hasTarget) {
|
||||
if (options.target.startsWith('oc_')) receiveIdType = 'chat_id';
|
||||
else if (options.target.startsWith('ou_')) receiveIdType = 'open_id';
|
||||
else if (options.target.includes('@')) receiveIdType = 'email';
|
||||
else if (options.target.startsWith('om_')) {
|
||||
// Should have been normalized earlier; keep as explicit guard.
|
||||
throw new Error('Invalid target "om_...": message_id cannot be used as receive_id. Use --reply-to "om_..."');
|
||||
} else {
|
||||
throw new Error('Invalid target id. Expected "ou_..." (open_id), "oc_..." (chat_id), or an email address.');
|
||||
}
|
||||
}
|
||||
|
||||
// Build Payload (RichText)
|
||||
// Upstream Logic: Wrap markdown in Post Object
|
||||
|
||||
// Split text by newlines to create paragraphs
|
||||
// Unescape literal \n if passed from command line
|
||||
const rawLines = contentText.replace(/\\n/g, '\n').split(/\r?\n/);
|
||||
const contentBody = rawLines.map(line => parseMarkdownToFeishu(line));
|
||||
|
||||
const postContent = {
|
||||
zh_cn: {
|
||||
title: options.title || '',
|
||||
content: contentBody
|
||||
}
|
||||
};
|
||||
|
||||
const messageBody = {
|
||||
receive_id: options.target,
|
||||
msg_type: 'post',
|
||||
content: JSON.stringify(postContent)
|
||||
};
|
||||
|
||||
// Support Reply (New Feature from Upstream)
|
||||
let url = `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`;
|
||||
if (options.replyTo) {
|
||||
url = `https://open.feishu.cn/open-apis/im/v1/messages/${options.replyTo}/reply`;
|
||||
delete messageBody.receive_id; // Reply doesn't need receive_id
|
||||
}
|
||||
|
||||
// console.log(`Sending Post to ${options.target}...`);
|
||||
|
||||
try {
|
||||
const res = await fetchWithAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(messageBody)
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||
throw new Error(`Invalid JSON response: ${text.slice(0, 200)}...`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// Check if it's a recoverable error (like invalid emoji)
|
||||
const isEmojiError = data.code === 230001 && data.msg && data.msg.includes("emoji_type is invalid");
|
||||
if (!isEmojiError) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${JSON.stringify(data)}`);
|
||||
}
|
||||
// If it is an emoji error, flow through to the fallback logic below
|
||||
}
|
||||
|
||||
if (data.code !== 0) {
|
||||
// Hotfix: If invalid emoji detected (230001), fallback to plain text stripping emojis
|
||||
if (data.code === 230001 && data.msg && data.msg.includes("emoji_type is invalid")) {
|
||||
console.warn("Detected invalid emoji in Feishu post. Retrying as plain text fallback...");
|
||||
// Fallback strategy: Send as plain text message, which renders emoji codes as text [Code]
|
||||
// This ensures the message gets through even if the specific emoji is not supported.
|
||||
const fallbackBody = {
|
||||
receive_id: messageBody.receive_id,
|
||||
msg_type: 'text',
|
||||
content: JSON.stringify({ text: contentText })
|
||||
};
|
||||
|
||||
// Reuse URL (it might be a reply URL)
|
||||
if (options.replyTo) {
|
||||
delete fallbackBody.receive_id;
|
||||
}
|
||||
|
||||
const res2 = await fetchWithAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fallbackBody)
|
||||
});
|
||||
|
||||
const text2 = await res2.text();
|
||||
const data2 = JSON.parse(text2);
|
||||
if (data2.code === 0) return data2.data;
|
||||
// If fallback fails, throw original error (or new one)
|
||||
throw new Error(`API Error ${data.code}: ${data.msg} (Fallback also failed: ${data2.code} ${data2.msg})`);
|
||||
}
|
||||
throw new Error(`API Error ${data.code}: ${data.msg}`);
|
||||
}
|
||||
|
||||
// ... (rest of function)
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Send Failed: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI Wrapper
|
||||
if (require.main === module) {
|
||||
const { program } = require('commander');
|
||||
program
|
||||
.option('-t, --target <id>', 'Target ID')
|
||||
.option('-x, --text <text>', 'Text content')
|
||||
.option('-c, --content <text>', 'Content (alias for --text)')
|
||||
.option('-m, --markdown <text>', 'Markdown content (alias for --text)')
|
||||
.option('-f, --text-file <path>', 'File content')
|
||||
.option('--title <text>', 'Title')
|
||||
.option('--reply-to <id>', 'Message ID to reply to')
|
||||
.parse(process.argv);
|
||||
|
||||
const opts = program.opts();
|
||||
|
||||
// Safety: if --text contains shell-mangled content (missing chars),
|
||||
// try to detect and warn. Also support stdin piping as fallback.
|
||||
if (opts.text && !opts.textFile) {
|
||||
// Write to temp file to avoid shell escaping issues with special chars
|
||||
const tmpPath = path.join('/tmp', `feishu_post_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
|
||||
fs.writeFileSync(tmpPath, opts.text);
|
||||
opts.textFile = tmpPath;
|
||||
opts.text = undefined;
|
||||
// Clean up after send
|
||||
sendPost(opts).then(() => {
|
||||
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
||||
}).catch((e) => {
|
||||
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
sendPost(opts).catch(() => process.exit(1));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendPost };
|
||||
@@ -0,0 +1,13 @@
|
||||
function loadFeishuClient() {
|
||||
try {
|
||||
return require("../../feishu-common/index.js");
|
||||
} catch (err) {
|
||||
const depErr = new Error(
|
||||
"Missing dependency: feishu-common. Install feishu-common skill first, then retry.",
|
||||
);
|
||||
depErr.cause = err;
|
||||
throw depErr;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loadFeishuClient();
|
||||
@@ -0,0 +1,70 @@
|
||||
const emojiMap = require('../emoji-map.js');
|
||||
|
||||
function parseMarkdownToFeishu(text) {
|
||||
const segments = [];
|
||||
|
||||
// Helper to process formatting (Bold/Italic/Code)
|
||||
const processFormatting = (str) => {
|
||||
const res = [];
|
||||
|
||||
// 1. Split by Code: `text`
|
||||
const parts = str.split(/(`[^`]+`)/g);
|
||||
for (const p of parts) {
|
||||
if (p.startsWith('`') && p.endsWith('`') && p.length > 2) {
|
||||
// Feishu Post 'text' tag does not support 'code' style officially.
|
||||
// Reverting to plain text with backticks to ensure visibility.
|
||||
res.push({ tag: 'text', text: p });
|
||||
} else {
|
||||
// 2. Split by Bold: **text**
|
||||
const subParts = p.split(/(\*\*[^*]+\*\*)/g);
|
||||
for (const sp of subParts) {
|
||||
if (sp.startsWith('**') && sp.endsWith('**') && sp.length > 4) {
|
||||
res.push({ tag: 'text', text: sp.slice(2, -2), style: ['bold'] });
|
||||
} else {
|
||||
// 3. Split by Italic: *text*
|
||||
const subSubParts = sp.split(/(\*[^*]+\*)/g);
|
||||
for (const ssp of subSubParts) {
|
||||
if (ssp.startsWith('*') && ssp.endsWith('*') && ssp.length > 2) {
|
||||
res.push({ tag: 'text', text: ssp.slice(1, -1), style: ['italic'] });
|
||||
} else if (ssp) {
|
||||
res.push({ tag: 'text', text: ssp });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
// Regex: Link ([text](url)) OR Emoji ([text])
|
||||
const regex = /(\[[^\]]+\]\([^)]+\))|(\[[^\]]+\])/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
|
||||
// Handle Link
|
||||
if (part.startsWith('[') && part.includes('](') && part.endsWith(')')) {
|
||||
const m = part.match(/^\[(.*?)\]\((.*?)\)$/);
|
||||
if (m) {
|
||||
segments.push({ tag: 'a', text: m[1], href: m[2] });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Emoji
|
||||
if (part.startsWith('[') && part.endsWith(']')) {
|
||||
if (emojiMap[part]) {
|
||||
segments.push({ tag: 'emotion', emoji_type: emojiMap[part] });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Text Formatting
|
||||
segments.push(...processFormatting(part));
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
module.exports = { parseMarkdownToFeishu };
|
||||
@@ -0,0 +1,132 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.4.1] - 2026-02-23
|
||||
|
||||
### Fixed
|
||||
- **Skill.json tool count**: Fixed missing tools display on Clawhub
|
||||
- Verified all 7 tools are properly declared: write_smart, configure, append_smart, transfer_ownership, get_config_status, search_docs, list_docs
|
||||
|
||||
## [1.4.0] - 2026-02-23
|
||||
|
||||
### Added
|
||||
- **Document Search Functionality** (`search_docs`)
|
||||
- Search local index by keywords
|
||||
- Search in document name, summary, and tags
|
||||
- Return matching documents with links
|
||||
|
||||
- **Document List Functionality** (`list_docs`)
|
||||
- List all created Feishu documents
|
||||
- Filter by tags (AI Technology, E-commerce, Health & Sports, etc.)
|
||||
- Filter by status
|
||||
|
||||
- **Automatic Index Management** (`index_manager.py`)
|
||||
- Auto-update local index at `memory/feishu-docs-index.md` after document creation
|
||||
- Auto-generate document summary (first 100 characters)
|
||||
- Intelligent auto-categorization tags:
|
||||
- AI Technology (AI, artificial intelligence, model, GPT, LLM)
|
||||
- OpenClaw (OpenClaw, skill, agent)
|
||||
- Feishu Docs (Feishu, document, docx)
|
||||
- E-commerce (e-commerce, TikTok, Alibaba)
|
||||
- Health & Sports (Garmin, Strava, cycling, health)
|
||||
- Daily Archive (conversation, archive, chat history)
|
||||
|
||||
- **Tool Declarations**
|
||||
- Officially declare `search_docs` and `list_docs` tools in `package.json` and `skill.json`
|
||||
|
||||
### Changed
|
||||
- Updated `SKILL.md` documentation with detailed descriptions of new features
|
||||
- Improved usage examples and configuration instructions
|
||||
|
||||
### Technical Details (Ownership Transfer Fix)
|
||||
- **Before**: Used `ctx.invoke_tool("exec", ...)` which failed when ctx was None or unavailable
|
||||
- **After**: Direct HTTP API calls with `aiohttp`, fully independent of OpenClaw context
|
||||
- **API Endpoint**: `POST /drive/v1/permissions/{token}/members/transfer_owner?type=docx`
|
||||
|
||||
### Optimized Ownership Transfer Workflow
|
||||
- Automatically transfer ownership to user after document creation
|
||||
- Automatically obtain tenant_access_token without manual configuration
|
||||
- Fixed "permission denied" errors caused by ctx passing issues
|
||||
|
||||
## [1.3.0] - 2026-02-22
|
||||
|
||||
### Fixed
|
||||
- **Fixed Ownership Transfer Functionality** (`transfer_ownership`)
|
||||
- **BREAKING CHANGE**: Replaced ctx-dependent implementation with independent API calls
|
||||
- Added `_get_tenant_access_token()` method for independent token retrieval using aiohttp
|
||||
- **Removed dependency**: No longer relies on `ctx.invoke_tool("exec", ...)` to call curl commands
|
||||
- **More stable**: Directly use `aiohttp` to call Feishu API, eliminating context passing issues
|
||||
- **Better error handling**: Returns detailed error messages on failure
|
||||
- Reads Feishu app credentials from OpenClaw config independently
|
||||
|
||||
### Technical Details
|
||||
- **Before**: Used `ctx.invoke_tool("exec", ...)` which failed when ctx was None or unavailable
|
||||
- **After**: Direct HTTP API calls with `aiohttp`, fully independent of OpenClaw context
|
||||
- **API Endpoint**: `POST /drive/v1/permissions/{token}/members/transfer_owner?type=docx`
|
||||
|
||||
### Changed
|
||||
- **Optimized Ownership Transfer Workflow**
|
||||
- Automatically transfer ownership to user after document creation
|
||||
- Automatically obtain tenant_access_token without manual configuration
|
||||
- Fixed "permission denied" errors caused by ctx passing issues
|
||||
|
||||
## [1.2.0] - 2026-02-21
|
||||
|
||||
### Added
|
||||
- **Intelligent Chunk Writing Functionality**
|
||||
- Automatically split long content into chunks (default 2000 characters)
|
||||
- Avoid blank document issues caused by Feishu API character limits
|
||||
- Smart paragraph segmentation while preserving heading structure
|
||||
- Support automatic table-to-text conversion
|
||||
|
||||
- **First-Time User Guide**
|
||||
- Auto-detect first-time usage
|
||||
- Guide users to obtain OpenID
|
||||
- Auto-save configuration to `user_config.json`
|
||||
|
||||
- **Basic Document Operations**
|
||||
- `write_smart` - Smart document creation
|
||||
- `append_smart` - Append content to existing documents
|
||||
- `transfer_ownership` - Transfer document ownership
|
||||
- `configure` - Configure OpenID
|
||||
- `get_config_status` - Check configuration status
|
||||
|
||||
## [1.1.0] - 2026-02-20
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- Basic Feishu document creation and writing functionality
|
||||
- Support Markdown format (headings, lists, code blocks, etc.)
|
||||
- Support automatic image upload
|
||||
|
||||
---
|
||||
|
||||
## Version Summary
|
||||
|
||||
- **v1.4.0** - Added search, list, and automatic index management
|
||||
- **v1.3.0** - Fixed ownership transfer using independent API calls (removed ctx dependency)
|
||||
- **v1.2.0** - Added intelligent chunk writing and first-time user guide
|
||||
- **v1.1.0** - Initial release with basic document operations
|
||||
|
||||
## Key Technical Updates
|
||||
|
||||
### v1.4.0 Core Technology
|
||||
- Local index management system
|
||||
- Intelligent content categorization algorithm
|
||||
- Automatic document metadata extraction
|
||||
|
||||
### v1.3.0 Core Technology (Critical Fix)
|
||||
- **Independent tenant_access_token retrieval** using aiohttp
|
||||
- **Direct Feishu API calls** without OpenClaw context dependency
|
||||
- **Removed**: `ctx.invoke_tool("exec", ...)` approach
|
||||
- **Added**: `_get_tenant_access_token()` for standalone token acquisition
|
||||
- Reads app credentials from `~/.openclaw/openclaw.json` independently
|
||||
|
||||
### v1.2.0 Core Technology
|
||||
- ContentChunker content segmentation
|
||||
- Intelligent paragraph segmentation algorithm
|
||||
- User configuration persistence
|
||||
@@ -0,0 +1,143 @@
|
||||
# Feishu Smart Doc Writer
|
||||
|
||||
> **English**: Feishu/Lark Smart Document Writer. Solves API content limits by auto-chunking long documents and auto-transferring ownership. Guides OpenID config on first use.
|
||||
>
|
||||
> **中文**: 飞书智能文档写入器,解决长文档API限制导致的空白问题,自动转移所有权。首次使用自动引导配置。
|
||||
|
||||
飞书智能文档写入器 - 解决长文档写入时的 API 限制问题,支持自动转移所有权。
|
||||
|
||||
## 认证说明
|
||||
|
||||
本 Skill 使用 OpenClaw 内置的飞书工具集,**无需手动获取 token**。
|
||||
|
||||
- `tenant_access_token` 由 OpenClaw 自动管理(通过配置好的 `appId` 和 `appSecret` 自动换取)
|
||||
- 你**不需要**去飞书后台点击"获取 token"
|
||||
- 本 Skill 唯一需要配置的是 **用户 OpenID**(用于文档所有权转移,见下方配置步骤)
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 智能分块写入
|
||||
飞书 API 对单次写入有限制(创建~4000字符,追加~2000字符),超过会导致文档空白。本 Skill 自动将长内容分块写入,确保完整无丢失。
|
||||
|
||||
### 2. 自动转移所有权
|
||||
应用创建的文档默认所有权属于应用,用户无法编辑。本 Skill 在创建文档后自动转移所有权给用户,用户拥有完全控制权。
|
||||
|
||||
### 3. 首次使用自动引导
|
||||
首次使用时自动引导用户配置 OpenID,无需查阅文档。
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 第1步:首次使用(自动引导)
|
||||
|
||||
当你第一次使用 `write_smart` 时,Skill 会自动提示配置指南。
|
||||
|
||||
### 第2步:获取你的 OpenID
|
||||
|
||||
**详细步骤(精确路径):**
|
||||
|
||||
1. **登录飞书开放平台**
|
||||
- 网址:https://open.feishu.cn
|
||||
|
||||
2. **进入权限管理并前往调试台**
|
||||
- 进入你的**相关应用**
|
||||
- 点击 **"权限管理"**
|
||||
- 搜索权限:`im:message`
|
||||
- 鼠标移动到 **"相关API事件"**
|
||||
- 选择:**【API】发送消息**
|
||||
- 点击右下角:**"前往API调试台"**
|
||||
|
||||
3. **找到 "快速复制 open_id"**
|
||||
- 在页面中找到 **蓝色文字** "快速复制 open_id"
|
||||
- 点击这个链接
|
||||
|
||||
4. **选择用户并复制**
|
||||
- 在弹出的选择框中,**选择你的账号**
|
||||
- 点击 **"复制"** 按钮
|
||||
- 得到格式如:`ou_xxxxxxxx`
|
||||
|
||||
### 第3步:开通并发布权限
|
||||
|
||||
⚠️ **重要:需要开通权限并发布应用新版本**
|
||||
|
||||
1. **进入权限管理**
|
||||
- 登录 https://open.feishu.cn
|
||||
- 进入你的应用 → **权限管理**
|
||||
|
||||
2. **搜索并开通权限**
|
||||
- 搜索:`docs:permission.member:transfer`
|
||||
- 点击 **"开通"**
|
||||
|
||||
3. **发布新版本(关键!)**
|
||||
- 点击页面右上角的 **"发布"** 按钮
|
||||
- 等待发布完成
|
||||
- ⚠️ **不发布的话,权限不会生效!**
|
||||
|
||||
### 第4步:配置 Skill
|
||||
|
||||
根据引导,使用 `configure` 工具配置:
|
||||
|
||||
```python
|
||||
await ctx.invoke_tool("feishu_smart_doc_writer.configure", {
|
||||
"openid": "ou_5b921cba0fd6e7c885276a02d730ec19",
|
||||
"permission_checked": true
|
||||
})
|
||||
```
|
||||
|
||||
### 第5步:创建文档(自动转移所有权)
|
||||
|
||||
配置完成后,直接创建文档:
|
||||
|
||||
```python
|
||||
result = await ctx.invoke_tool("feishu_smart_doc_writer.write_smart", {
|
||||
"title": "项目报告",
|
||||
"content": "# 项目报告\n\n很长很长的内容...(支持10000+字)"
|
||||
})
|
||||
|
||||
# 返回:
|
||||
# {
|
||||
# "doc_url": "https://feishu.cn/docx/xxx",
|
||||
# "chunks_count": 5,
|
||||
# "owner_transferred": true,
|
||||
# "message": "✅ 文档创建成功,共分 5 块写入,所有权已转移"
|
||||
# }
|
||||
```
|
||||
|
||||
**配置一次,永久生效!** 之后创建文档会自动转移所有权。
|
||||
|
||||
## 工具列表
|
||||
|
||||
| 工具名 | 功能 |
|
||||
|--------|------|
|
||||
| `write_smart` | 创建文档,自动分块写入,自动转移所有权(首次使用引导配置) |
|
||||
| `configure` | 配置 OpenID 和确认权限 |
|
||||
| `append_smart` | 追加内容到已有文档(自动分块) |
|
||||
| `transfer_ownership` | 转移已有文档的所有权 |
|
||||
| `get_config_status` | 查看当前配置状态 |
|
||||
|
||||
## 为什么需要这个 Skill?
|
||||
|
||||
### 原生 feishu_doc 的问题
|
||||
|
||||
```python
|
||||
# ❌ 原生方式 - 长内容会失败
|
||||
feishu_doc.create(
|
||||
title="项目报告",
|
||||
content="# 很长很长的内容..." * 1000 # 超过4000字符
|
||||
)
|
||||
# 结果:文档创建,但内容空白或报错
|
||||
```
|
||||
|
||||
### 使用本 Skill
|
||||
|
||||
```python
|
||||
# ✅ 自动处理 - 内容分块写入,自动转移所有权
|
||||
write_smart(
|
||||
title="项目报告",
|
||||
content="# 很长很长的内容..." * 1000, # 10000字符
|
||||
)
|
||||
# 结果:自动分为5块写入,所有权转移给用户,文档完整
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,300 @@
|
||||
---
|
||||
name: feishu-smart-doc-writer
|
||||
description: |
|
||||
Feishu/Lark Smart Document Writer - 飞书智能文档写入器.
|
||||
|
||||
Core Features / 核心功能:
|
||||
1. Smart Chunk Writing / 智能分块写入 - Solve API limit blank doc issues / 解决长文档API限制导致的空白问题
|
||||
2. Auto Ownership Transfer / 自动转移所有权 - Transfer to user after creation / 创建文档后自动转移给用户
|
||||
3. Auto Index Management / 自动索引管理 - Update local index with search support / 自动更新本地文档索引,支持搜索
|
||||
4. First-time Guide / 首次使用引导 - Auto guide for OpenID config / 自动引导配置OpenID
|
||||
---
|
||||
|
||||
# Feishu Smart Doc Writer v1.4.1
|
||||
|
||||
## 🚀 Core Features / 核心功能
|
||||
|
||||
### 1. Smart Document Creation / 智能文档创建
|
||||
- **Auto-chunk Writing / 自动分块**: Split long content into chunks to avoid API limit blank docs / 长内容自动分割成小块,避免API限制导致的空白文档
|
||||
- **Auto Ownership Transfer / 自动转移所有权**: Transfer to user using OpenID after creation / 创建后自动使用 OpenID 转移给用户
|
||||
- **Auto Index Update / 自动索引更新**: Add doc info to local index `memory/feishu-docs-index.md` / 文档信息自动添加到本地索引
|
||||
- **Smart Categorization / 智能分类**: Auto-tag based on content (AI Tech, E-commerce, Health, etc.) / 根据内容自动打标签(AI技术、电商、健康运动等)
|
||||
|
||||
### 2. Document Management / 文档管理
|
||||
- **Search Documents / 搜索文档**: Search local index by keywords / 按关键词搜索本地索引
|
||||
- **List Documents / 列出文档**: Filter by tags and status / 按标签、状态筛选文档列表
|
||||
- **Append Content / 追加内容**: Append to existing docs (auto-chunk) / 向现有文档追加内容(自动分块)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Tools / 工具列表
|
||||
|
||||
### write_smart - Smart Document Creation / 智能创建文档
|
||||
Create document with auto-chunk writing, ownership transfer, and index update.
|
||||
创建文档,自动完成分块写入、所有权转移、索引更新。
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Document Title / 文档标题",
|
||||
"content": "Content (long content supported) / 文档内容(支持长内容)",
|
||||
"folder_token": "Optional folder token / 可选的文件夹token"
|
||||
}
|
||||
```
|
||||
|
||||
**Returns / 返回:**
|
||||
```json
|
||||
{
|
||||
"doc_url": "https://feishu.cn/docx/xxx",
|
||||
"doc_token": "xxx",
|
||||
"chunks_count": 3,
|
||||
"owner_transferred": true,
|
||||
"index_updated": true
|
||||
}
|
||||
```
|
||||
|
||||
### append_smart - Append Content / 追加内容
|
||||
Append content to existing document with auto-chunk.
|
||||
向现有文档追加内容(自动分块)。
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_url": "https://feishu.cn/docx/xxx",
|
||||
"content": "Content to append / 要追加的内容"
|
||||
}
|
||||
```
|
||||
|
||||
### search_docs - Search Documents / 搜索文档
|
||||
Search documents in local index.
|
||||
搜索本地索引中的文档。
|
||||
|
||||
```json
|
||||
{
|
||||
"keyword": "Search keyword / 搜索关键词"
|
||||
}
|
||||
```
|
||||
|
||||
**Returns / 返回:**
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"name": "Document Name / 文档名",
|
||||
"link": "https://...",
|
||||
"summary": "Summary / 摘要",
|
||||
"tags": "AI Tech, OpenClaw / AI技术, OpenClaw"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### list_docs - List Documents / 列出文档
|
||||
List all documents with optional filters.
|
||||
列出所有文档,支持筛选。
|
||||
|
||||
```json
|
||||
{
|
||||
"tag": "AI Tech / AI技术",
|
||||
"status": "Completed / 已完成"
|
||||
}
|
||||
```
|
||||
|
||||
### transfer_ownership - Transfer Ownership / 转移所有权
|
||||
Manually transfer document ownership (usually auto-handled by write_smart).
|
||||
手动转移文档所有权(通常不需要,write_smart 已自动处理)。
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_url": "https://feishu.cn/docx/xxx",
|
||||
"owner_openid": "ou_xxxxxxxx"
|
||||
}
|
||||
```
|
||||
|
||||
**Note / 注意:** Only provide OpenID, tenant_access_token is auto-obtained by Skill.
|
||||
只需要提供 OpenID,tenant_access_token 由 Skill 自动获取。
|
||||
|
||||
### configure - Configure OpenID / 配置 OpenID
|
||||
Configure OpenID for first-time use.
|
||||
首次使用时配置 OpenID。
|
||||
|
||||
```json
|
||||
{
|
||||
"openid": "ou_xxxxxxxx",
|
||||
"permission_checked": true
|
||||
}
|
||||
```
|
||||
|
||||
### get_config_status - Get Config Status / 查看配置状态
|
||||
View current configuration status.
|
||||
查看当前配置状态。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start / 快速开始
|
||||
|
||||
### First-time Setup (3 Steps) / 首次使用(3步配置)
|
||||
|
||||
**Step 1 / 第1步: Call write_smart / 调用 write_smart**
|
||||
```
|
||||
/feishu-smart-doc-writer write_smart
|
||||
title: Test Document / 测试文档
|
||||
content: This is a test document. / 这是一个测试文档内容
|
||||
```
|
||||
|
||||
**Step 2 / 第2步: Get OpenID / 获取 OpenID**
|
||||
If not configured, follow the guide:
|
||||
如果未配置,会显示引导:
|
||||
1. Login / 登录 https://open.feishu.cn
|
||||
2. Go to / 进入 Application → Permission Management / 应用 → 权限管理 → Search / 搜索 `im:message`
|
||||
3. Click / 点击【API】Send Message / 发送消息 → Go to API Debug Console / 前往API调试台
|
||||
4. Click / 点击"Quick Copy open_id" / 快速复制 open_id", select your account / 选择你的账号, copy / 复制
|
||||
|
||||
**Step 3 / 第3步: Configure and Enable Permissions / 配置并开通权限**
|
||||
```
|
||||
/feishu-smart-doc-writer configure
|
||||
openid: ou_your_openid / ou_你的OpenID
|
||||
permission_checked: true
|
||||
```
|
||||
|
||||
Then go to Permission Management / 然后到权限管理:
|
||||
1. Search / 搜索 `docs:permission.member:transfer`
|
||||
2. Click / 点击"Enable / 开通"
|
||||
3. **Important / 重要**: Click / 点击"Publish / 发布" button to publish new version / 按钮发布新版本
|
||||
|
||||
After setup, future document creation will automatically:
|
||||
配置完成后,以后创建文档会自动:
|
||||
- ✅ Chunk write content / 分块写入内容
|
||||
- ✅ Transfer ownership to you / 转移所有权给你
|
||||
- ✅ Update local index / 更新本地索引
|
||||
|
||||
---
|
||||
|
||||
## 📊 Index Management / 索引管理
|
||||
|
||||
### Auto Index Workflow / 自动索引流程
|
||||
```
|
||||
write_smart creates document / 创建文档
|
||||
↓
|
||||
Write content (auto-chunk) / 写入内容(自动分块)
|
||||
↓
|
||||
Transfer ownership / 转移所有权
|
||||
↓
|
||||
Auto update index → memory/feishu-docs-index.md / 自动更新索引
|
||||
↓
|
||||
Done! / 完成!
|
||||
```
|
||||
|
||||
### Auto-categorization Tags / 自动分类标签
|
||||
Auto-identified based on content / 根据内容自动识别:
|
||||
- **AI Tech / AI技术** - AI, artificial intelligence / 人工智能, model / 模型, GPT, LLM
|
||||
- **OpenClaw** - OpenClaw, skill, agent
|
||||
- **Feishu Docs / 飞书文档** - Feishu / 飞书, document / 文档, feishu
|
||||
- **E-commerce / 电商** - e-commerce / 电商, TikTok, Alibaba / 阿里巴巴
|
||||
- **Health & Sports / 健康运动** - Garmin, Strava, cycling / 骑行, health / 健康
|
||||
- **Daily Archive / 每日归档** - conversation / 对话, archive / 归档, chat history / 聊天记录
|
||||
|
||||
### Index File Location / 索引文件位置
|
||||
`memory/feishu-docs-index.md`
|
||||
|
||||
Format / 格式: Markdown table with / Markdown 表格,包含 index / 序号, name / 名称, type / 类型, link / 链接, summary / 摘要, status / 状态, tags / 标签, owner / 所有者
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Usage Examples / 使用示例
|
||||
|
||||
### Example 1 / 示例1: Create Tech Document / 创建技术文档
|
||||
```
|
||||
/feishu-smart-doc-writer write_smart
|
||||
title: AI Tech Research Report / AI技术调研报告
|
||||
content: # AI Overview / AI技术概述\n\nAI is... / 人工智能(AI)是...
|
||||
```
|
||||
|
||||
Result / 结果:
|
||||
- Document created successfully / 文档创建成功
|
||||
- Auto-tagged "AI Tech / AI技术" / 自动打上"AI技术"标签
|
||||
- Index updated / 索引已更新
|
||||
|
||||
### Example 2 / 示例2: Search Documents / 搜索文档
|
||||
```
|
||||
/feishu-smart-doc-writer search_docs
|
||||
keyword: AI Tech / AI技术
|
||||
```
|
||||
|
||||
### Example 3 / 示例3: List All Tech Documents / 列出所有技术文档
|
||||
```
|
||||
/feishu-smart-doc-writer list_docs
|
||||
tag: AI Tech / AI技术
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration / 配置说明
|
||||
|
||||
### User Config File / 用户配置文件
|
||||
Location / 位置: `skills/feishu-smart-doc-writer/user_config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"owner_openid": "ou_5b921cba0fd6e7c885276a02d730ec19",
|
||||
"permission_noted": true,
|
||||
"first_time": false
|
||||
}
|
||||
```
|
||||
|
||||
### Required Permissions / 必需权限
|
||||
- `docx:document:create` - Create document / 创建文档
|
||||
- `docx:document:write` - Write content / 写入内容
|
||||
- `docs:permission.member:transfer` - Transfer ownership ⚠️ Critical / 转移所有权 ⚠️ 关键权限
|
||||
|
||||
---
|
||||
|
||||
## 📝 Version History / 版本历史
|
||||
|
||||
### v1.4.1 (2026-02-23)
|
||||
- ✅ Fixed description inconsistency between skill.json and package.json / 修复 skill.json 和 package.json 描述不一致问题
|
||||
- ✅ Unified all file versions to v1.4.1 / 统一所有文件版本号为 v1.4.1
|
||||
- ✅ Verified all 7 tools properly declared / 确认所有 7 个工具正确声明
|
||||
|
||||
### v1.4.0 (2026-02-23)
|
||||
- ✅ Added auto index management (index_manager.py) / 新增自动索引管理(index_manager.py)
|
||||
- ✅ Added search_docs tool (search local index) / 新增 search_docs 工具(搜索本地索引)
|
||||
- ✅ Added list_docs tool (list documents) / 新增 list_docs 工具(列出文档)
|
||||
- ✅ Smart auto-categorization tags / 智能自动分类标签
|
||||
- ✅ Fixed ownership transfer (independent API calls, no ctx dependency) / 修复所有权转移(独立 API 调用,不依赖 ctx)
|
||||
|
||||
### v1.2.0
|
||||
- ✅ Auto-chunk writing / 自动分块写入
|
||||
- ✅ Auto ownership transfer / 自动转移所有权
|
||||
- ✅ First-time user guide / 首次使用引导
|
||||
|
||||
### v1.1.0
|
||||
- ✅ Basic document creation and append / 基础文档创建和追加
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting / 故障排除
|
||||
|
||||
### "open_id is not exist" Error / 错误
|
||||
**Cause / 原因**: Used user_id instead of openid / 使用了 user_id 而不是 openid
|
||||
**Solution / 解决**: Use openid starting with `ou_` / 使用以 `ou_` 开头的 openid
|
||||
|
||||
### "Permission Denied" Error / "权限不足" 错误
|
||||
**Cause / 原因**: `docs:permission.member:transfer` not enabled or app not published / 未开通权限,或未发布应用
|
||||
**Solution / 解决**:
|
||||
1. Permission Management → Search `docs:permission.member:transfer` → Enable / 权限管理 → 搜索 → 开通
|
||||
2. Click "Publish" button to publish new version (Critical!) / 点击"发布"按钮(关键!)
|
||||
|
||||
### Index Not Updated / 索引未更新
|
||||
**Check / 检查**:
|
||||
1. Check if `memory/feishu-docs-index.md` exists / 查看文件是否存在
|
||||
2. Check `index_updated` field in write_smart return / 检查返回字段
|
||||
3. Check error logs / 查看错误日志
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support / 支持
|
||||
|
||||
If issues, please check / 如有问题,请检查:
|
||||
1. OpenID format correct (starts with ou_) / 格式是否正确(ou_ 开头)
|
||||
2. Permissions enabled and published / 权限是否已开通并发布
|
||||
3. Index file path correct / 索引文件路径是否正确
|
||||
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Feishu Smart Doc Writer
|
||||
飞书智能文档写入器 - 自动分段、分批写入
|
||||
支持首次使用自动引导配置
|
||||
|
||||
核心功能:
|
||||
1. 智能分块写入 - 解决飞书API字数限制导致的空白文档
|
||||
2. 自动转移所有权 - 创建文档后自动转移给用户
|
||||
3. 首次使用引导 - 自动询问OpenID并配置
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
# 配置文件路径
|
||||
CONFIG_PATH = os.path.expanduser("~/.openclaw/workspace/skills/feishu-smart-doc-writer/user_config.json")
|
||||
|
||||
@dataclass
|
||||
class UserConfig:
|
||||
"""用户配置"""
|
||||
owner_openid: str = ""
|
||||
permission_noted: bool = False # 用户是否已确认权限
|
||||
first_time: bool = True # 是否首次使用
|
||||
|
||||
def save(self):
|
||||
"""保存配置"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(asdict(self), f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"保存配置失败: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> 'UserConfig':
|
||||
"""加载配置"""
|
||||
try:
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return cls(**data)
|
||||
except Exception as e:
|
||||
print(f"加载配置失败: {e}")
|
||||
return cls()
|
||||
|
||||
|
||||
# 引导消息模板
|
||||
FIRST_TIME_GUIDE = """👋 **欢迎使用 Feishu Smart Doc Writer!**
|
||||
|
||||
本 Skill 可以帮助你:
|
||||
✅ **智能分块写入** - 解决长文档写入时因API限制导致的空白问题
|
||||
✅ **自动转移所有权** - 创建文档后自动转移给你,拥有完全控制权
|
||||
|
||||
---
|
||||
|
||||
## 🔧 首次使用配置
|
||||
|
||||
### 第1步:获取你的 OpenID
|
||||
|
||||
**详细步骤(精确路径):**
|
||||
|
||||
1. **登录飞书开放平台**
|
||||
- 网址:https://open.feishu.cn
|
||||
|
||||
2. **进入权限管理并前往调试台**
|
||||
- 进入你的**相关应用**
|
||||
- 点击 **"权限管理"**
|
||||
- 搜索权限:`im:message`
|
||||
- 鼠标移动到 **"相关API事件"**
|
||||
- 选择:**【API】发送消息**
|
||||
- 点击右下角:**"前往API调试台"**
|
||||
|
||||
3. **找到 "快速复制 open_id"**
|
||||
- 在页面中找到 **蓝色文字** "快速复制 open_id"
|
||||
- 点击这个链接
|
||||
|
||||
4. **选择用户并复制**
|
||||
- 在弹出的选择框中,**选择你的账号**
|
||||
- 点击 **"复制"** 按钮
|
||||
- 得到格式如:`ou_5b921cba0fd6e7c885276a02d730ec19`
|
||||
|
||||
💡 **提示**:OpenID 是以 `ou_` 开头的一串字符,不是数字ID
|
||||
|
||||
---
|
||||
|
||||
### 第2步:开通并发布权限
|
||||
|
||||
⚠️ **重要:需要开通权限并发布应用新版本**
|
||||
|
||||
**开通权限步骤:**
|
||||
|
||||
1. **进入权限管理**
|
||||
- 登录 https://open.feishu.cn
|
||||
- 进入你的应用
|
||||
- 点击左侧菜单 **"权限管理"**
|
||||
|
||||
2. **搜索并开通权限**
|
||||
- 在搜索框输入:`docs:permission.member:transfer`
|
||||
- 找到权限 **"转移云文档的所有权"**
|
||||
- 点击 **"开通"** 按钮
|
||||
|
||||
3. **发布新版本(关键!)**
|
||||
- 开通后,点击页面右上角的 **"发布"** 按钮
|
||||
- 等待发布完成(显示"已发布"状态)
|
||||
- ⚠️ **不发布的话,权限不会生效!**
|
||||
|
||||
---
|
||||
|
||||
## 💬 请回复配置信息
|
||||
|
||||
请按以下格式回复:
|
||||
|
||||
```
|
||||
配置OpenID:ou_你的OpenID
|
||||
权限已开通并发布:是
|
||||
```
|
||||
|
||||
例如:
|
||||
```
|
||||
配置OpenID:ou_5b921cba0fd6e7c885276a02d730ec19
|
||||
权限已开通并发布:是
|
||||
```
|
||||
|
||||
配置完成后,本 Skill 将自动保存配置,之后创建文档会自动转移所有权给你!
|
||||
"""
|
||||
|
||||
|
||||
async def write_smart(ctx, args: dict) -> dict:
|
||||
"""
|
||||
智能创建飞书文档(自动分块 + 自动转移所有权)
|
||||
|
||||
首次使用时会自动引导配置。
|
||||
|
||||
Args:
|
||||
ctx: OpenClaw 上下文
|
||||
args: 包含 title, content, folder_token, chunk_size, show_progress
|
||||
|
||||
Returns:
|
||||
{"doc_url": "...", "doc_token": "...", "chunks_count": N, "owner_transferred": True/False}
|
||||
"""
|
||||
# 加载用户配置
|
||||
config = UserConfig.load()
|
||||
|
||||
# 首次使用或未完成配置,显示引导
|
||||
if config.first_time or not config.owner_openid:
|
||||
return {
|
||||
"doc_url": None,
|
||||
"doc_token": None,
|
||||
"chunks_count": 0,
|
||||
"owner_transferred": False,
|
||||
"need_config": True,
|
||||
"message": FIRST_TIME_GUIDE
|
||||
}
|
||||
|
||||
# 已配置,正常执行
|
||||
title = args.get("title")
|
||||
content = args.get("content", "")
|
||||
folder_token = args.get("folder_token")
|
||||
chunk_size = args.get("chunk_size", 2000)
|
||||
show_progress = args.get("show_progress", True)
|
||||
|
||||
if not title:
|
||||
raise ValueError("必须提供 title 参数")
|
||||
|
||||
from .feishu_smart_doc_writer import FeishuDocWriter, ChunkConfig
|
||||
|
||||
chunk_config = ChunkConfig(
|
||||
chunk_size=chunk_size,
|
||||
show_progress=show_progress
|
||||
)
|
||||
writer = FeishuDocWriter(ctx, chunk_config)
|
||||
|
||||
try:
|
||||
# 使用配置的 owner_openid 自动转移
|
||||
result = await writer.write_document_with_transfer(
|
||||
title=title,
|
||||
content=content,
|
||||
folder_token=folder_token,
|
||||
owner_openid=config.owner_openid
|
||||
)
|
||||
|
||||
transfer_msg = ",所有权已转移" if result.get("owner_transferred") else ""
|
||||
|
||||
return {
|
||||
"doc_url": result["doc_url"],
|
||||
"doc_token": result["doc_token"],
|
||||
"chunks_count": result["chunks_count"],
|
||||
"owner_transferred": result["owner_transferred"],
|
||||
"need_config": False,
|
||||
"message": f"✅ 文档创建成功,共分 {result['chunks_count']} 块写入{transfer_msg}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"doc_url": None,
|
||||
"doc_token": None,
|
||||
"chunks_count": 0,
|
||||
"owner_transferred": False,
|
||||
"need_config": False,
|
||||
"message": f"❌ 创建失败: {e}"
|
||||
}
|
||||
|
||||
|
||||
async def configure(ctx, args: dict) -> dict:
|
||||
"""
|
||||
配置 Skill
|
||||
|
||||
用户首次使用时,通过此工具配置 OpenID。
|
||||
|
||||
Args:
|
||||
ctx: OpenClaw 上下文
|
||||
args: 包含 openid, permission_checked
|
||||
|
||||
Returns:
|
||||
{"success": True/False, "message": "..."}
|
||||
"""
|
||||
openid = args.get("openid", "").strip()
|
||||
permission_checked = args.get("permission_checked", False)
|
||||
|
||||
# 验证 OpenID 格式
|
||||
if not openid:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "❌ 请提供 OpenID"
|
||||
}
|
||||
|
||||
if not openid.startswith("ou_"):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "❌ OpenID 格式错误,应以 'ou_' 开头,请检查"
|
||||
}
|
||||
|
||||
# 保存配置
|
||||
config = UserConfig()
|
||||
config.owner_openid = openid
|
||||
config.permission_noted = permission_checked
|
||||
config.first_time = False
|
||||
|
||||
if config.save():
|
||||
return {
|
||||
"success": True,
|
||||
"openid": openid,
|
||||
"message": f"✅ 配置成功!\n\n你的 OpenID:{openid}\n\n配置已保存,现在可以使用 write_smart 创建文档,所有权会自动转移给你。"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "❌ 配置保存失败"
|
||||
}
|
||||
|
||||
|
||||
async def append_smart(ctx, args: dict) -> dict:
|
||||
"""
|
||||
智能追加内容到飞书文档(自动分块)
|
||||
|
||||
Args:
|
||||
ctx: OpenClaw 上下文
|
||||
args: 包含 doc_url, content, chunk_size, show_progress
|
||||
|
||||
Returns:
|
||||
{"success": True/False, "chunks_count": N}
|
||||
"""
|
||||
doc_url = args.get("doc_url")
|
||||
content = args.get("content", "")
|
||||
chunk_size = args.get("chunk_size", 2000)
|
||||
show_progress = args.get("show_progress", True)
|
||||
|
||||
if not doc_url:
|
||||
raise ValueError("必须提供 doc_url 参数")
|
||||
|
||||
from .feishu_smart_doc_writer import FeishuDocWriter, ChunkConfig, ContentChunker
|
||||
|
||||
config = ChunkConfig(
|
||||
chunk_size=chunk_size,
|
||||
show_progress=show_progress
|
||||
)
|
||||
writer = FeishuDocWriter(ctx, config)
|
||||
|
||||
try:
|
||||
success = await writer.append_to_document(doc_url, content)
|
||||
|
||||
# 计算分块数
|
||||
chunks = ContentChunker(config).chunk_content(content)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"chunks_count": len(chunks),
|
||||
"message": f"{'✅' if success else '❌'} 追加 {'成功' if success else '失败'},共分 {len(chunks)} 块"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"chunks_count": 0,
|
||||
"message": f"❌ 追加失败: {e}"
|
||||
}
|
||||
|
||||
|
||||
async def transfer_ownership(ctx, args: dict) -> dict:
|
||||
"""
|
||||
转移文档所有权
|
||||
|
||||
Args:
|
||||
ctx: OpenClaw 上下文
|
||||
args: 包含 doc_url, owner_openid
|
||||
|
||||
Returns:
|
||||
{"success": True/False, "message": "..."}
|
||||
"""
|
||||
doc_url = args.get("doc_url")
|
||||
owner_openid = args.get("owner_openid")
|
||||
|
||||
if not doc_url or not owner_openid:
|
||||
raise ValueError("必须提供 doc_url 和 owner_openid 参数")
|
||||
|
||||
from .feishu_smart_doc_writer import FeishuDocWriter, ChunkConfig
|
||||
|
||||
config = ChunkConfig(show_progress=False)
|
||||
writer = FeishuDocWriter(ctx, config)
|
||||
|
||||
try:
|
||||
success = await writer.transfer_ownership(doc_url, owner_openid)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": f"{'✅' if success else '❌'} 所有权转移{'成功' if success else '失败'}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"❌ 转移失败: {e}"
|
||||
}
|
||||
|
||||
|
||||
async def get_config_status(ctx, args: dict) -> dict:
|
||||
"""
|
||||
获取当前配置状态
|
||||
|
||||
Returns:
|
||||
{"configured": True/False, "openid": "...", "message": "..."}
|
||||
"""
|
||||
config = UserConfig.load()
|
||||
|
||||
if config.owner_openid:
|
||||
return {
|
||||
"configured": True,
|
||||
"openid": config.owner_openid,
|
||||
"first_time": config.first_time,
|
||||
"message": f"✅ 已配置\nOpenID: {config.owner_openid}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"configured": False,
|
||||
"openid": None,
|
||||
"first_time": config.first_time,
|
||||
"message": "⚠️ 未配置\n请使用 configure 工具进行配置"
|
||||
}
|
||||
|
||||
|
||||
async def search_docs(ctx, args: dict) -> dict:
|
||||
"""
|
||||
搜索本地索引中的文档
|
||||
|
||||
Args:
|
||||
ctx: OpenClaw 上下文
|
||||
args: 包含 keyword, search_in(可选)
|
||||
|
||||
Returns:
|
||||
{"results": [...], "count": N, "message": "..."}
|
||||
"""
|
||||
keyword = args.get("keyword", "").strip()
|
||||
search_in = args.get("search_in", ["name", "summary", "tags"])
|
||||
|
||||
if not keyword:
|
||||
return {
|
||||
"results": [],
|
||||
"count": 0,
|
||||
"message": "❌ 请提供搜索关键词"
|
||||
}
|
||||
|
||||
try:
|
||||
from .index_manager import IndexManager
|
||||
|
||||
manager = IndexManager()
|
||||
results = manager.search_docs(keyword, search_in)
|
||||
|
||||
# 格式化结果
|
||||
formatted_results = []
|
||||
for doc in results:
|
||||
formatted_results.append({
|
||||
"name": doc.get("name", ""),
|
||||
"type": doc.get("type", ""),
|
||||
"link": doc.get("link", ""),
|
||||
"summary": doc.get("summary", ""),
|
||||
"status": doc.get("status", ""),
|
||||
"tags": doc.get("tags", ""),
|
||||
"updated": doc.get("updated", "")
|
||||
})
|
||||
|
||||
return {
|
||||
"results": formatted_results,
|
||||
"count": len(formatted_results),
|
||||
"message": f"✅ 找到 {len(formatted_results)} 个结果" if formatted_results else f"⚠️ 未找到包含 '{keyword}' 的文档"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"results": [],
|
||||
"count": 0,
|
||||
"message": f"❌ 搜索失败: {e}"
|
||||
}
|
||||
|
||||
|
||||
async def list_docs(ctx, args: dict) -> dict:
|
||||
"""
|
||||
列出所有文档(支持筛选)
|
||||
|
||||
Args:
|
||||
ctx: OpenClaw 上下文
|
||||
args: 包含 tag(可选), status(可选), limit(可选)
|
||||
|
||||
Returns:
|
||||
{"results": [...], "count": N, "message": "..."}
|
||||
"""
|
||||
tag = args.get("tag")
|
||||
status = args.get("status")
|
||||
limit = args.get("limit", 50)
|
||||
|
||||
try:
|
||||
from .index_manager import IndexManager
|
||||
|
||||
manager = IndexManager()
|
||||
results = manager.list_docs(tag=tag, status=status, limit=limit)
|
||||
|
||||
# 格式化结果
|
||||
formatted_results = []
|
||||
for doc in results:
|
||||
formatted_results.append({
|
||||
"name": doc.get("name", ""),
|
||||
"type": doc.get("type", ""),
|
||||
"link": doc.get("link", ""),
|
||||
"summary": doc.get("summary", ""),
|
||||
"status": doc.get("status", ""),
|
||||
"tags": doc.get("tags", ""),
|
||||
"updated": doc.get("updated", "")
|
||||
})
|
||||
|
||||
# 构建消息
|
||||
filter_desc = []
|
||||
if tag:
|
||||
filter_desc.append(f"标签 '{tag}'")
|
||||
if status:
|
||||
filter_desc.append(f"状态 '{status}'")
|
||||
|
||||
filter_text = ",".join(filter_desc) if filter_desc else "全部"
|
||||
|
||||
return {
|
||||
"results": formatted_results,
|
||||
"count": len(formatted_results),
|
||||
"message": f"✅ {filter_text}文档共 {len(formatted_results)} 个"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"results": [],
|
||||
"count": 0,
|
||||
"message": f"❌ 列出文档失败: {e}"
|
||||
}
|
||||
|
||||
|
||||
# 版本信息
|
||||
__version__ = "1.3.0"
|
||||
__all__ = [
|
||||
"write_smart",
|
||||
"append_smart",
|
||||
"transfer_ownership",
|
||||
"configure",
|
||||
"get_config_status",
|
||||
"search_docs",
|
||||
"list_docs",
|
||||
"UserConfig",
|
||||
"FIRST_TIME_GUIDE"
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn75sh5zhpf0spta18sqc7bd4s81dvr9",
|
||||
"slug": "feishu-smart-doc-writer",
|
||||
"version": "1.4.1",
|
||||
"publishedAt": 1771834474198
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"openid": "ou_5b921cba0fd6e7c885276a02d730ec19", "permission_checked": true}
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
使用示例 - 展示如何在 OpenClaw Skill 中使用 feishu_smart_doc_writer
|
||||
"""
|
||||
|
||||
# 示例 1: 在 Skill 中写入长文档
|
||||
async def example_write_long_doc(ctx):
|
||||
"""
|
||||
在 OpenClaw Skill 中写入长文档
|
||||
|
||||
ctx 是 OpenClaw 提供的上下文对象
|
||||
"""
|
||||
from feishu_smart_doc_writer import FeishuDocWriter, ChunkConfig
|
||||
|
||||
# 创建写入器,传入 ctx
|
||||
config = ChunkConfig(
|
||||
chunk_size=2000, # 每块2000字符
|
||||
show_progress=True, # 显示进度
|
||||
max_retries=3 # 失败重试3次
|
||||
)
|
||||
writer = FeishuDocWriter(ctx, config)
|
||||
|
||||
# 准备长内容
|
||||
long_content = """
|
||||
# 项目策划文档
|
||||
|
||||
## 一、项目背景
|
||||
|
||||
这是项目的详细背景介绍...
|
||||
|
||||
## 二、技术方案
|
||||
|
||||
| 技术 | 说明 | 优势 |
|
||||
|------|------|------|
|
||||
| Python | 编程语言 | 生态丰富 |
|
||||
| OpenClaw | 自动化框架 | 功能强大 |
|
||||
|
||||
## 三、实施计划
|
||||
|
||||
1. 第一阶段...
|
||||
2. 第二阶段...
|
||||
3. 第三阶段...
|
||||
|
||||
""" * 100 # 重复100次,生成约50000字符的长内容
|
||||
|
||||
# 写入文档
|
||||
doc_url = await writer.write_document(
|
||||
title="项目策划 - 完整版",
|
||||
content=long_content,
|
||||
folder_token=None # 可选:指定文件夹
|
||||
)
|
||||
|
||||
print(f"✅ 文档创建成功: {doc_url}")
|
||||
return doc_url
|
||||
|
||||
|
||||
# 示例 2: 追加内容到现有文档
|
||||
async def example_append_to_doc(ctx, doc_url):
|
||||
"""
|
||||
向已有文档追加内容
|
||||
"""
|
||||
from feishu_smart_doc_writer import FeishuDocWriter
|
||||
|
||||
writer = FeishuDocWriter(ctx)
|
||||
|
||||
# 追加内容
|
||||
append_content = """
|
||||
## 四、补充说明
|
||||
|
||||
这是后来补充的内容...
|
||||
""" * 50
|
||||
|
||||
success = await writer.append_to_document(
|
||||
doc_url=doc_url,
|
||||
content=append_content
|
||||
)
|
||||
|
||||
if success:
|
||||
print("✅ 内容追加成功")
|
||||
else:
|
||||
print("❌ 内容追加失败")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
# 示例 3: 在 Skill 定义中使用
|
||||
SKILL_DEFINITION = {
|
||||
"name": "my_skill",
|
||||
"tools": [
|
||||
{
|
||||
"name": "create_project_doc",
|
||||
"description": "创建项目文档",
|
||||
"handler": "create_project_doc_handler"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async def create_project_doc_handler(ctx, args):
|
||||
"""
|
||||
Skill 工具处理函数
|
||||
"""
|
||||
from feishu_smart_doc_writer import FeishuDocWriter
|
||||
|
||||
title = args.get("title", "未命名文档")
|
||||
content = args.get("content", "")
|
||||
|
||||
writer = FeishuDocWriter(ctx)
|
||||
doc_url = await writer.write_document(title, content)
|
||||
|
||||
return {
|
||||
"doc_url": doc_url,
|
||||
"message": "文档创建成功"
|
||||
}
|
||||
|
||||
|
||||
# 示例 4: 直接使用同步函数(简化版)
|
||||
def example_sync_usage(ctx):
|
||||
"""
|
||||
同步方式使用(在不需要async的环境中)
|
||||
"""
|
||||
from feishu_smart_doc_writer import write_document_sync
|
||||
|
||||
doc_url = write_document_sync(
|
||||
ctx=ctx,
|
||||
title="同步创建的文档",
|
||||
content="# 标题\n\n大量内容..." * 1000
|
||||
)
|
||||
|
||||
return doc_url
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Feishu Smart Doc Writer 使用示例")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("在 OpenClaw Skill 中使用:")
|
||||
print()
|
||||
print("1. 导入:")
|
||||
print(" from feishu_smart_doc_writer import FeishuDocWriter")
|
||||
print()
|
||||
print("2. 创建写入器:")
|
||||
print(" writer = FeishuDocWriter(ctx)")
|
||||
print()
|
||||
print("3. 写入长文档:")
|
||||
print(" doc_url = await writer.write_document(title, content)")
|
||||
print()
|
||||
print("4. 追加内容:")
|
||||
print(" await writer.append_to_document(doc_url, content)")
|
||||
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Feishu Smart Doc Writer - 改进版
|
||||
自动分块写入 + 自动转移所有权 + 自动更新索引
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 导入索引管理器
|
||||
try:
|
||||
from .index_manager import IndexManager, add_doc_to_index
|
||||
except ImportError:
|
||||
from index_manager import IndexManager, add_doc_to_index
|
||||
|
||||
@dataclass
|
||||
class ChunkConfig:
|
||||
"""分块配置"""
|
||||
chunk_size: int = 2000 # 每块最大字符数
|
||||
max_retries: int = 3 # 最大重试次数
|
||||
retry_delay: float = 1.0 # 重试间隔(秒)
|
||||
show_progress: bool = True # 显示进度
|
||||
convert_tables: bool = True # 转换表格为文本
|
||||
|
||||
|
||||
class ContentChunker:
|
||||
"""内容分块器"""
|
||||
|
||||
def __init__(self, config: ChunkConfig = None):
|
||||
self.config = config or ChunkConfig()
|
||||
|
||||
def chunk_content(self, content: str) -> List[str]:
|
||||
"""
|
||||
将长内容分割成多个小块
|
||||
策略:按段落分割,如果段落超过限制,按句子分割
|
||||
"""
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
|
||||
# 先处理表格
|
||||
if self.config.convert_tables:
|
||||
content = self._convert_tables(content)
|
||||
|
||||
# 按段落分割
|
||||
paragraphs = self._split_paragraphs(content)
|
||||
|
||||
for para in paragraphs:
|
||||
# 如果当前块加上新段落会超限
|
||||
if len(current_chunk) + len(para) > self.config.chunk_size:
|
||||
# 保存当前块
|
||||
if current_chunk.strip():
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
# 如果单个段落就超限,需要进一步分割
|
||||
if len(para) > self.config.chunk_size:
|
||||
sub_chunks = self._split_large_paragraph(para)
|
||||
chunks.extend(sub_chunks)
|
||||
current_chunk = ""
|
||||
else:
|
||||
current_chunk = para
|
||||
else:
|
||||
current_chunk += "\n\n" + para if current_chunk else para
|
||||
|
||||
# 保存最后一块
|
||||
if current_chunk.strip():
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
return chunks
|
||||
|
||||
def _convert_tables(self, content: str) -> str:
|
||||
"""将Markdown表格转换为文本列表"""
|
||||
table_pattern = r'\|[^\n]+\|\n\|[-:| ]+\|\n((?:\|[^\n]+\|\n)+)'
|
||||
|
||||
def convert_table(match):
|
||||
table_text = match.group(0)
|
||||
lines = table_text.strip().split('\n')
|
||||
|
||||
# 提取表头
|
||||
header = [cell.strip() for cell in lines[0].split('|')[1:-1]]
|
||||
|
||||
# 提取数据行
|
||||
result = ["【表格内容】"]
|
||||
for line in lines[2:]: # 跳过表头和分隔线
|
||||
cells = [cell.strip() for cell in line.split('|')[1:-1]]
|
||||
if cells and any(cells): # 确保不是空行
|
||||
row_text = ", ".join([f"{h}: {c}" for h, c in zip(header, cells)])
|
||||
result.append(f"- {row_text}")
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
return re.sub(table_pattern, convert_table, content)
|
||||
|
||||
def _split_paragraphs(self, content: str) -> List[str]:
|
||||
"""按段落分割,保留标题结构"""
|
||||
lines = content.split('\n')
|
||||
paragraphs = []
|
||||
current_para = ""
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped:
|
||||
if current_para.strip():
|
||||
paragraphs.append(current_para.strip())
|
||||
current_para = ""
|
||||
continue
|
||||
|
||||
# 如果是标题,单独成段
|
||||
if stripped.startswith('#'):
|
||||
if current_para.strip():
|
||||
paragraphs.append(current_para.strip())
|
||||
current_para = ""
|
||||
paragraphs.append(stripped)
|
||||
else:
|
||||
current_para += line + "\n"
|
||||
|
||||
if current_para.strip():
|
||||
paragraphs.append(current_para.strip())
|
||||
|
||||
return paragraphs
|
||||
|
||||
def _split_large_paragraph(self, para: str) -> List[str]:
|
||||
"""分割大段落(按句子)"""
|
||||
chunks = []
|
||||
sentences = re.split(r'([。!?.\n])', para)
|
||||
current = ""
|
||||
|
||||
for i in range(0, len(sentences), 2):
|
||||
sentence = sentences[i]
|
||||
if i + 1 < len(sentences):
|
||||
sentence += sentences[i + 1] # 加上标点
|
||||
|
||||
if len(current) + len(sentence) > self.config.chunk_size:
|
||||
if current.strip():
|
||||
chunks.append(current.strip())
|
||||
current = sentence
|
||||
else:
|
||||
current += sentence
|
||||
|
||||
if current.strip():
|
||||
chunks.append(current.strip())
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
class FeishuDocWriter:
|
||||
"""
|
||||
飞书文档智能写入器
|
||||
使用 OpenClaw 官方工具调用方式
|
||||
"""
|
||||
|
||||
def __init__(self, ctx=None, config: ChunkConfig = None):
|
||||
"""
|
||||
初始化
|
||||
|
||||
Args:
|
||||
ctx: OpenClaw 上下文对象(在 Skill 中传入)
|
||||
config: 分块配置
|
||||
"""
|
||||
self.ctx = ctx
|
||||
self.config = config or ChunkConfig()
|
||||
self.chunker = ContentChunker(config)
|
||||
|
||||
async def write_document(self, title: str, content: str, folder_token: str = None) -> str:
|
||||
"""
|
||||
创建新文档并写入内容(自动分块)
|
||||
|
||||
Args:
|
||||
title: 文档标题
|
||||
content: 文档内容(支持长内容,自动分块)
|
||||
folder_token: 可选的文件夹token
|
||||
|
||||
Returns:
|
||||
文档URL
|
||||
"""
|
||||
if not self.ctx:
|
||||
raise ValueError("需要提供 OpenClaw 上下文对象 (ctx)")
|
||||
|
||||
# 第一步:创建空文档(只传标题)
|
||||
doc_token = await self._create_empty_doc(title, folder_token)
|
||||
doc_url = f"https://feishu.cn/docx/{doc_token}"
|
||||
|
||||
if self.config.show_progress:
|
||||
print(f"✅ 文档创建成功: {doc_url}")
|
||||
|
||||
# 第二步:分批追加内容
|
||||
success = await self._write_content_in_chunks(doc_token, content)
|
||||
|
||||
if not success:
|
||||
raise Exception("写入内容失败")
|
||||
|
||||
return doc_url
|
||||
|
||||
async def append_to_document(self, doc_url: str, content: str) -> bool:
|
||||
"""
|
||||
追加内容到现有文档(自动分块)
|
||||
|
||||
Args:
|
||||
doc_url: 文档URL
|
||||
content: 要追加的内容
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
if not self.ctx:
|
||||
raise ValueError("需要提供 OpenClaw 上下文对象 (ctx)")
|
||||
|
||||
doc_token = self._extract_token_from_url(doc_url)
|
||||
return await self._write_content_in_chunks(doc_token, content)
|
||||
|
||||
async def _create_empty_doc(self, title: str, folder_token: str = None) -> str:
|
||||
"""创建空文档,只传标题"""
|
||||
try:
|
||||
# 使用 OpenClaw 官方工具调用方式
|
||||
result = await self.ctx.invoke_tool("feishu_doc.create", {
|
||||
"title": title,
|
||||
"folder_token": folder_token
|
||||
})
|
||||
|
||||
# 提取 doc_token
|
||||
if isinstance(result, dict):
|
||||
doc_token = result.get("document_id") or result.get("doc_token")
|
||||
if doc_token:
|
||||
return doc_token
|
||||
|
||||
# 如果是字符串,尝试提取
|
||||
if isinstance(result, str):
|
||||
import re
|
||||
match = re.search(r'docx/([a-zA-Z0-9]+)', result)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return result
|
||||
|
||||
raise Exception(f"无法解析文档token: {result}")
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"创建文档失败: {e}")
|
||||
|
||||
async def _write_content_in_chunks(self, doc_token: str, content: str) -> bool:
|
||||
"""分批写入内容"""
|
||||
chunks = self.chunker.chunk_content(content)
|
||||
|
||||
if self.config.show_progress:
|
||||
print(f"📝 内容已分割为 {len(chunks)} 块,开始写入...")
|
||||
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
if self.config.show_progress:
|
||||
print(f" 写入第 {i}/{len(chunks)} 块 ({len(chunk)} 字符)...")
|
||||
|
||||
success = await self._append_chunk_with_retry(doc_token, chunk)
|
||||
|
||||
if not success:
|
||||
print(f"❌ 第 {i} 块写入失败")
|
||||
return False
|
||||
|
||||
# 添加小延迟,避免API限流
|
||||
if i < len(chunks):
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if self.config.show_progress:
|
||||
print(f"✅ 全部 {len(chunks)} 块写入完成")
|
||||
|
||||
return True
|
||||
|
||||
async def _append_chunk_with_retry(self, doc_token: str, chunk: str) -> bool:
|
||||
"""带重试的追加内容"""
|
||||
for attempt in range(self.config.max_retries):
|
||||
try:
|
||||
return await self._append_chunk(doc_token, chunk)
|
||||
except Exception as e:
|
||||
if self.config.show_progress:
|
||||
print(f" 尝试 {attempt + 1}/{self.config.max_retries} 失败: {e}")
|
||||
if attempt < self.config.max_retries - 1:
|
||||
await asyncio.sleep(self.config.retry_delay * (attempt + 1))
|
||||
else:
|
||||
return False
|
||||
return False
|
||||
|
||||
async def _append_chunk(self, doc_token: str, chunk: str) -> bool:
|
||||
"""追加单块内容"""
|
||||
try:
|
||||
# 使用 OpenClaw 官方工具调用方式
|
||||
await self.ctx.invoke_tool("feishu_doc.append", {
|
||||
"doc_token": doc_token,
|
||||
"content": chunk
|
||||
})
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception(f"API调用失败: {e}")
|
||||
|
||||
def _extract_token_from_url(self, url: str) -> str:
|
||||
"""从URL中提取doc_token"""
|
||||
import re
|
||||
match = re.search(r'docx/([a-zA-Z0-9]+)', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
raise ValueError(f"无法从URL提取token: {url}")
|
||||
|
||||
async def _get_tenant_access_token(self) -> str:
|
||||
"""获取飞书 tenant_access_token"""
|
||||
import aiohttp
|
||||
import json
|
||||
import os
|
||||
|
||||
# 尝试从 OpenClaw 配置读取 App ID 和 Secret
|
||||
app_id, app_secret = None, None
|
||||
|
||||
# 方法1: 尝试从环境变量读取
|
||||
app_id = os.environ.get("FEISHU_APP_ID")
|
||||
app_secret = os.environ.get("FEISHU_APP_SECRET")
|
||||
|
||||
# 方法2: 尝试从 OpenClaw 配置文件读取
|
||||
if not app_id or not app_secret:
|
||||
try:
|
||||
config_paths = [
|
||||
os.path.expanduser("~/.openclaw/openclaw.json"),
|
||||
os.path.expanduser("~/.openclaw/config.json"),
|
||||
]
|
||||
for config_path in config_paths:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
feishu_config = config.get("channels", {}).get("feishu", {})
|
||||
app_id = feishu_config.get("appId")
|
||||
app_secret = feishu_config.get("appSecret")
|
||||
if app_id and app_secret:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not app_id or not app_secret:
|
||||
return ""
|
||||
|
||||
# 调用飞书 API 获取 token
|
||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
payload = {
|
||||
"app_id": app_id,
|
||||
"app_secret": app_secret
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=payload) as resp:
|
||||
result = await resp.json()
|
||||
if result.get("code") == 0:
|
||||
return result.get("tenant_access_token", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
async def transfer_ownership(self, doc_url: str, owner_openid: str) -> bool:
|
||||
"""
|
||||
转移文档所有权 - 直接调用飞书 API
|
||||
|
||||
API端点: POST /drive/v1/permissions/{token}/members/transfer_owner?type=docx
|
||||
|
||||
Args:
|
||||
doc_url: 文档URL
|
||||
owner_openid: 新所有者的openid (例如: ou_xxxxxxxx)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
import aiohttp
|
||||
import json
|
||||
|
||||
doc_token = self._extract_token_from_url(doc_url)
|
||||
|
||||
# 获取 tenant_access_token
|
||||
token = await self._get_tenant_access_token()
|
||||
if not token:
|
||||
if self.config.show_progress:
|
||||
print(f"⚠️ 无法获取 tenant_access_token")
|
||||
return False
|
||||
|
||||
# 调用飞书 API 转移所有权
|
||||
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{doc_token}/members/transfer_owner?type=docx"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"member_type": "openid",
|
||||
"member_id": owner_openid
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=payload) as resp:
|
||||
result = await resp.json()
|
||||
|
||||
if result.get("code") == 0:
|
||||
if self.config.show_progress:
|
||||
print(f"✅ 文档所有权已转移给 {owner_openid}")
|
||||
return True
|
||||
else:
|
||||
error_msg = result.get("msg", "未知错误")
|
||||
if self.config.show_progress:
|
||||
print(f"⚠️ 所有权转移失败: {error_msg}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
if self.config.show_progress:
|
||||
print(f"⚠️ 所有权转移失败: {e}")
|
||||
return False
|
||||
|
||||
async def write_document_with_transfer(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
folder_token: str = None,
|
||||
owner_openid: str = None
|
||||
) -> dict:
|
||||
"""
|
||||
创建文档并写入内容,完成后自动转移所有权并更新本地索引
|
||||
|
||||
Args:
|
||||
title: 文档标题
|
||||
content: 文档内容
|
||||
folder_token: 可选的文件夹token
|
||||
owner_openid: 新所有者的openid,如果提供则自动转移所有权
|
||||
|
||||
Returns:
|
||||
{
|
||||
"doc_url": "...",
|
||||
"doc_token": "...",
|
||||
"chunks_count": N,
|
||||
"owner_transferred": True/False,
|
||||
"index_updated": True/False
|
||||
}
|
||||
"""
|
||||
# 1. 创建并写入文档
|
||||
doc_url = await self.write_document(title, content, folder_token)
|
||||
|
||||
# 2. 提取 doc_token
|
||||
doc_token = self._extract_token_from_url(doc_url)
|
||||
|
||||
# 3. 计算分块数
|
||||
chunks = self.chunker.chunk_content(content)
|
||||
|
||||
# 4. 转移所有权(如果提供了 owner_openid)
|
||||
owner_transferred = False
|
||||
if owner_openid:
|
||||
owner_transferred = await self.transfer_ownership(doc_url, owner_openid)
|
||||
|
||||
# 5. 【关键】自动更新本地索引
|
||||
index_updated = False
|
||||
try:
|
||||
# 生成摘要(取前100字)
|
||||
summary = content[:100].replace('\n', ' ') + "..." if len(content) > 100 else content
|
||||
|
||||
# 自动分类标签
|
||||
tags = self._auto_classify_content(content, title)
|
||||
|
||||
# 更新索引
|
||||
index_updated = add_doc_to_index(
|
||||
name=title,
|
||||
url=doc_url,
|
||||
token=doc_token,
|
||||
summary=summary,
|
||||
tags=tags,
|
||||
owner=owner_openid or ""
|
||||
)
|
||||
|
||||
if self.config.show_progress and index_updated:
|
||||
print(f"✅ 文档索引已更新")
|
||||
elif self.config.show_progress:
|
||||
print(f"⚠️ 文档索引更新失败(不影响文档创建)")
|
||||
|
||||
except Exception as e:
|
||||
if self.config.show_progress:
|
||||
print(f"⚠️ 索引更新失败: {e}(不影响文档创建)")
|
||||
|
||||
return {
|
||||
"doc_url": doc_url,
|
||||
"doc_token": doc_token,
|
||||
"chunks_count": len(chunks),
|
||||
"owner_transferred": owner_transferred,
|
||||
"index_updated": index_updated
|
||||
}
|
||||
|
||||
def _auto_classify_content(self, content: str, title: str) -> List[str]:
|
||||
"""根据内容自动分类"""
|
||||
tags = []
|
||||
text = (title + " " + content).lower()
|
||||
|
||||
# 关键词映射到标签
|
||||
if any(k in text for k in ["ai", "人工智能", "模型", "gpt", "llm"]):
|
||||
tags.append("AI技术")
|
||||
if any(k in text for k in ["openclaw", "skill", "agent"]):
|
||||
tags.append("OpenClaw")
|
||||
if any(k in text for k in ["飞书", "文档", "feishu", "docx"]):
|
||||
tags.append("飞书文档")
|
||||
if any(k in text for k in ["电商", "tiktok", "alibaba", "玩具"]):
|
||||
tags.append("电商")
|
||||
if any(k in text for k in ["garmin", "strava", "骑行", "健康", "运动"]):
|
||||
tags.append("健康运动")
|
||||
if any(k in text for k in ["对话", "归档", "聊天记录"]):
|
||||
tags.append("每日归档")
|
||||
|
||||
# 如果没有匹配到特定标签,添加通用标签
|
||||
if not tags:
|
||||
tags.append("其他")
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
# 同步包装函数(方便非异步环境使用)
|
||||
def write_document_sync(ctx, title: str, content: str, folder_token: str = None, config: ChunkConfig = None) -> str:
|
||||
"""同步方式写入文档"""
|
||||
writer = FeishuDocWriter(ctx, config)
|
||||
return asyncio.run(writer.write_document(title, content, folder_token))
|
||||
|
||||
|
||||
def write_document_with_transfer_sync(
|
||||
ctx,
|
||||
title: str,
|
||||
content: str,
|
||||
folder_token: str = None,
|
||||
owner_openid: str = None,
|
||||
config: ChunkConfig = None
|
||||
) -> dict:
|
||||
"""同步方式写入文档并转移所有权"""
|
||||
writer = FeishuDocWriter(ctx, config)
|
||||
return asyncio.run(writer.write_document_with_transfer(title, content, folder_token, owner_openid))
|
||||
|
||||
|
||||
def append_to_document_sync(ctx, doc_url: str, content: str, config: ChunkConfig = None) -> bool:
|
||||
"""同步方式追加文档"""
|
||||
writer = FeishuDocWriter(ctx, config)
|
||||
return asyncio.run(writer.append_to_document(doc_url, content))
|
||||
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
Feishu 文档索引管理器
|
||||
负责管理 memory/feishu-docs-index.md 文件
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
|
||||
|
||||
class IndexManager:
|
||||
"""飞书文档索引管理器"""
|
||||
|
||||
DEFAULT_INDEX_PATH = os.path.expanduser("~/.openclaw/workspace/memory/feishu-docs-index.md")
|
||||
|
||||
def __init__(self, index_path: str = None):
|
||||
self.index_path = index_path or self.DEFAULT_INDEX_PATH
|
||||
self._ensure_index_exists()
|
||||
|
||||
def _ensure_index_exists(self):
|
||||
"""确保索引文件存在"""
|
||||
if not os.path.exists(self.index_path):
|
||||
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
|
||||
self._create_empty_index()
|
||||
|
||||
def _create_empty_index(self):
|
||||
"""创建空索引文件"""
|
||||
content = """# 飞书云文档索引
|
||||
|
||||
**用途:** 快速定位和管理所有飞书云文档
|
||||
|
||||
---
|
||||
|
||||
## 📊 文档列表
|
||||
|
||||
| 序号 | 文档名 | 类型 | 链接 | 内容摘要 | 状态 | 最后更新 | 标签 | 所有者 |
|
||||
|------|--------|------|------|----------|------|----------|------|--------|
|
||||
|
||||
---
|
||||
|
||||
## 📂 按类型分类
|
||||
|
||||
### 项目管理
|
||||
|
||||
### 技术文档
|
||||
|
||||
### 每日归档
|
||||
|
||||
### 更新记录
|
||||
|
||||
### 数据分析
|
||||
|
||||
---
|
||||
|
||||
## 🔍 快速查找
|
||||
|
||||
*暂无关键词索引*
|
||||
|
||||
---
|
||||
|
||||
## 📝 使用说明
|
||||
|
||||
**添加新文档时:**
|
||||
1. 复制表格中的一行
|
||||
2. 填写所有字段
|
||||
3. 更新分类索引
|
||||
|
||||
**查找文档时:**
|
||||
1. 先搜索本文档中的关键词
|
||||
2. 找到对应链接
|
||||
3. 用 `feishu_doc` 工具读取内容
|
||||
|
||||
---
|
||||
*创建时间:{date}*
|
||||
*最后更新:{date}*
|
||||
""".format(date=datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
with open(self.index_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
def add_or_update_doc(self, name: str, doc_type: str, url: str, token: str,
|
||||
summary: str = "", status: str = "已完成",
|
||||
tags: List[str] = None, owner: str = "") -> bool:
|
||||
"""
|
||||
添加或更新文档到索引
|
||||
|
||||
Args:
|
||||
name: 文档名称
|
||||
doc_type: 文档类型 (docx, sheet, bitable 等)
|
||||
url: 文档链接
|
||||
token: 文档token
|
||||
summary: 内容摘要
|
||||
status: 文档状态
|
||||
tags: 标签列表
|
||||
owner: 所有者
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
# 读取现有索引
|
||||
with open(self.index_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 检查文档是否已存在(通过token判断)
|
||||
existing_index = self._find_doc_index(content, token)
|
||||
|
||||
# 获取当前序号
|
||||
if existing_index:
|
||||
doc_index = existing_index
|
||||
else:
|
||||
doc_index = self._get_next_index(content)
|
||||
|
||||
# 准备标签字符串
|
||||
tags_str = ", ".join(tags) if tags else ""
|
||||
|
||||
# 准备表格行
|
||||
now = datetime.now().strftime("%Y-%m-%d")
|
||||
new_row = f"| {doc_index} | {name} | {doc_type} | {url} | {summary} | {status} | {now} | {tags_str} | {owner} |"
|
||||
|
||||
# 更新表格
|
||||
if existing_index:
|
||||
# 更新现有行
|
||||
content = self._replace_table_row(content, token, new_row)
|
||||
else:
|
||||
# 添加新行
|
||||
content = self._insert_table_row(content, new_row)
|
||||
|
||||
# 更新分类(如果新文档)
|
||||
if not existing_index and tags:
|
||||
content = self._update_categories(content, name, tags)
|
||||
|
||||
# 更新关键词索引
|
||||
if not existing_index:
|
||||
content = self._update_keywords(content, name, summary, tags)
|
||||
|
||||
# 更新最后更新时间
|
||||
content = self._update_last_modified(content)
|
||||
|
||||
# 写回文件
|
||||
with open(self.index_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"更新索引失败: {e}")
|
||||
return False
|
||||
|
||||
def search_docs(self, keyword: str, search_in: List[str] = None) -> List[Dict]:
|
||||
"""
|
||||
搜索文档
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词
|
||||
search_in: 在哪些字段中搜索 (name, summary, tags)
|
||||
|
||||
Returns:
|
||||
匹配的文档列表
|
||||
"""
|
||||
if search_in is None:
|
||||
search_in = ["name", "summary", "tags"]
|
||||
|
||||
results = []
|
||||
docs = self._parse_table()
|
||||
|
||||
keyword_lower = keyword.lower()
|
||||
|
||||
for doc in docs:
|
||||
match = False
|
||||
if "name" in search_in and keyword_lower in doc.get("name", "").lower():
|
||||
match = True
|
||||
if "summary" in search_in and keyword_lower in doc.get("summary", "").lower():
|
||||
match = True
|
||||
if "tags" in search_in and keyword_lower in doc.get("tags", "").lower():
|
||||
match = True
|
||||
|
||||
if match:
|
||||
results.append(doc)
|
||||
|
||||
return results
|
||||
|
||||
def list_docs(self, tag: str = None, status: str = None, limit: int = None) -> List[Dict]:
|
||||
"""
|
||||
列出文档
|
||||
|
||||
Args:
|
||||
tag: 按标签筛选
|
||||
status: 按状态筛选
|
||||
limit: 限制数量
|
||||
|
||||
Returns:
|
||||
文档列表
|
||||
"""
|
||||
docs = self._parse_table()
|
||||
|
||||
# 筛选
|
||||
if tag:
|
||||
docs = [d for d in docs if tag in d.get("tags", "")]
|
||||
|
||||
if status:
|
||||
docs = [d for d in docs if d.get("status") == status]
|
||||
|
||||
# 限制数量
|
||||
if limit:
|
||||
docs = docs[:limit]
|
||||
|
||||
return docs
|
||||
|
||||
def get_doc_by_token(self, token: str) -> Optional[Dict]:
|
||||
"""通过token获取文档信息"""
|
||||
docs = self._parse_table()
|
||||
for doc in docs:
|
||||
if token in doc.get("link", ""):
|
||||
return doc
|
||||
return None
|
||||
|
||||
def _parse_table(self) -> List[Dict]:
|
||||
"""解析索引表格"""
|
||||
docs = []
|
||||
|
||||
try:
|
||||
with open(self.index_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 找到表格部分
|
||||
lines = content.split('\n')
|
||||
in_table = False
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('| 序号 '):
|
||||
in_table = True
|
||||
continue
|
||||
if in_table and line.startswith('|---'):
|
||||
continue
|
||||
if in_table and line.startswith('|') and not line.startswith('|------'):
|
||||
# 解析表格行
|
||||
parts = [p.strip() for p in line.split('|')[1:-1]]
|
||||
if len(parts) >= 8:
|
||||
# 兼容8列表格格式(序号、文档名、类型、链接、摘要、状态、更新时间、备注)
|
||||
docs.append({
|
||||
"index": parts[0],
|
||||
"name": parts[1],
|
||||
"type": parts[2],
|
||||
"link": parts[3],
|
||||
"summary": parts[4],
|
||||
"status": parts[5],
|
||||
"updated": parts[6],
|
||||
"tags": parts[7], # 使用"备注"列作为标签
|
||||
"owner": "" # 所有者信息暂时为空
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"解析索引失败: {e}")
|
||||
|
||||
return docs
|
||||
|
||||
def _find_doc_index(self, content: str, token: str) -> Optional[str]:
|
||||
"""查找文档是否已存在,返回序号"""
|
||||
pattern = r'\| (\d+) \| [^|]+ \| [^|]+ \| [^/]+/docx/' + re.escape(token) + r'[^|]* \|'
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def _get_next_index(self, content: str) -> int:
|
||||
"""获取下一个序号"""
|
||||
pattern = r'\| (\d+) \|'
|
||||
matches = re.findall(pattern, content)
|
||||
if matches:
|
||||
return max([int(m) for m in matches]) + 1
|
||||
return 1
|
||||
|
||||
def _replace_table_row(self, content: str, token: str, new_row: str) -> str:
|
||||
"""替换表格中的某一行"""
|
||||
pattern = r'(\| \d+ \| [^|]+ \| [^|]+ \| [^/]+/docx/' + re.escape(token) + r'[^|]* \|[^\n]+)'
|
||||
return re.sub(pattern, new_row, content)
|
||||
|
||||
def _insert_table_row(self, content: str, new_row: str) -> str:
|
||||
"""插入新行到表格"""
|
||||
# 在表格头部后插入
|
||||
lines = content.split('\n')
|
||||
insert_index = -1
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('| 序号 '):
|
||||
insert_index = i + 2 # 跳过表头和分隔线
|
||||
break
|
||||
|
||||
if insert_index > 0:
|
||||
lines.insert(insert_index, new_row)
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _update_categories(self, content: str, name: str, tags: List[str]) -> str:
|
||||
"""更新分类列表"""
|
||||
# 简单实现:在对应分类下添加文档名
|
||||
# 这里可以扩展更复杂的逻辑
|
||||
return content
|
||||
|
||||
def _update_keywords(self, content: str, name: str, summary: str, tags: List[str]) -> str:
|
||||
"""更新关键词索引"""
|
||||
# 提取关键词
|
||||
keywords = []
|
||||
if tags:
|
||||
keywords.extend(tags)
|
||||
|
||||
# 这里可以添加更智能的关键词提取
|
||||
# 暂时保持简单
|
||||
|
||||
return content
|
||||
|
||||
def _update_last_modified(self, content: str) -> str:
|
||||
"""更新最后修改时间"""
|
||||
now = datetime.now().strftime("%Y-%m-%d")
|
||||
pattern = r'\*最后更新:[^*]+\*'
|
||||
replacement = f'*最后更新:{now}*'
|
||||
return re.sub(pattern, replacement, content)
|
||||
|
||||
|
||||
# 便捷函数
|
||||
def add_doc_to_index(name: str, url: str, token: str, summary: str = "",
|
||||
tags: List[str] = None, owner: str = "") -> bool:
|
||||
"""便捷函数:添加文档到索引"""
|
||||
manager = IndexManager()
|
||||
return manager.add_or_update_doc(
|
||||
name=name,
|
||||
doc_type="docx",
|
||||
url=url,
|
||||
token=token,
|
||||
summary=summary,
|
||||
status="已完成",
|
||||
tags=tags,
|
||||
owner=owner
|
||||
)
|
||||
|
||||
|
||||
def search_docs(keyword: str) -> List[Dict]:
|
||||
"""便捷函数:搜索文档"""
|
||||
manager = IndexManager()
|
||||
return manager.search_docs(keyword)
|
||||
|
||||
|
||||
def list_all_docs(tag: str = None) -> List[Dict]:
|
||||
"""便捷函数:列出所有文档"""
|
||||
manager = IndexManager()
|
||||
return manager.list_docs(tag=tag)
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "feishu-smart-doc-writer",
|
||||
"version": "1.4.1",
|
||||
"description": "Feishu/Lark Smart Document Writer v1.4.1 - 飞书智能文档写入器. Auto-chunk writing, auto ownership transfer, document index management with search/list. Config OpenID on first use.",
|
||||
"author": "OpenClaw User",
|
||||
"license": "MIT",
|
||||
"tags": [
|
||||
"feishu",
|
||||
"lark",
|
||||
"飞书",
|
||||
"document",
|
||||
"docx",
|
||||
"writer",
|
||||
"chunk",
|
||||
"smart",
|
||||
"ownership-transfer",
|
||||
"文档",
|
||||
"写入"
|
||||
],
|
||||
"homepage": "https://github.com/openclaw/openclaw",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"keywords": [
|
||||
"feishu",
|
||||
"lark",
|
||||
"飞书",
|
||||
"document",
|
||||
"writer",
|
||||
"chunk",
|
||||
"split",
|
||||
"api-limit",
|
||||
"ownership-transfer",
|
||||
"文档写入",
|
||||
"长文档"
|
||||
],
|
||||
"openclaw": {
|
||||
"minVersion": "2026.2.0",
|
||||
"tools": [
|
||||
"write_smart",
|
||||
"append_smart",
|
||||
"search_docs",
|
||||
"list_docs",
|
||||
"transfer_ownership",
|
||||
"configure",
|
||||
"get_config_status"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"name": "feishu-smart-doc-writer",
|
||||
"version": "1.4.1",
|
||||
"description": "Feishu/Lark Smart Document Writer v1.4.1 - 飞书智能文档写入器. Auto-chunk writing, auto ownership transfer, document index management with search/list. Config OpenID on first use.",
|
||||
"author": "OpenClaw",
|
||||
"license": "MIT",
|
||||
"tools": [
|
||||
{
|
||||
"name": "write_smart",
|
||||
"description": "Smart create Feishu/Lark doc with auto-chunk writing and ownership transfer. Guides OpenID config on first use. 智能创建飞书文档,自动分块写入,自动转移所有权。首次使用时引导配置OpenID。",
|
||||
"handler": "feishu_smart_doc_writer.write_smart",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Document title / 文档标题"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Document content (long content auto-chunked) / 文档内容(支持长内容,会自动分块处理)"
|
||||
},
|
||||
"folder_token": {
|
||||
"type": "string",
|
||||
"description": "Optional folder token / 可选的文件夹token"
|
||||
},
|
||||
"chunk_size": {
|
||||
"type": "integer",
|
||||
"description": "Max chars per chunk, default 2000 / 每块最大字符数,默认2000",
|
||||
"default": 2000
|
||||
},
|
||||
"show_progress": {
|
||||
"type": "boolean",
|
||||
"description": "Show progress / 是否显示写入进度",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["title", "content"]
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"doc_url": {"type": "string"},
|
||||
"doc_token": {"type": "string"},
|
||||
"chunks_count": {"type": "integer"},
|
||||
"owner_transferred": {"type": "boolean"},
|
||||
"need_config": {"type": "boolean", "description": "Need config / 是否需要配置"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "configure",
|
||||
"description": "Configure Skill. Provide OpenID and confirm permission on first use. 配置Skill。首次使用时提供OpenID并确认权限已开通。",
|
||||
"handler": "feishu_smart_doc_writer.configure",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"openid": {
|
||||
"type": "string",
|
||||
"description": "Your Feishu/Lark OpenID (format: ou_xxxxxxxx) / 你的飞书OpenID(格式:ou_xxxxxxxx)"
|
||||
},
|
||||
"permission_checked": {
|
||||
"type": "boolean",
|
||||
"description": "Confirmed docs:permission.member:transfer permission granted / 是否已确认开通docs:permission.member:transfer权限",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["openid"]
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {"type": "boolean"},
|
||||
"openid": {"type": "string"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "append_smart",
|
||||
"description": "Smart append content to Feishu/Lark doc with auto-chunk. 智能追加内容到飞书文档,自动分块。",
|
||||
"handler": "feishu_smart_doc_writer.append_smart",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"doc_url": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"chunk_size": {"type": "integer", "default": 2000},
|
||||
"show_progress": {"type": "boolean", "default": true}
|
||||
},
|
||||
"required": ["doc_url", "content"]
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {"type": "boolean"},
|
||||
"chunks_count": {"type": "integer"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "transfer_ownership",
|
||||
"description": "Transfer Feishu/Lark doc ownership. Requires docs:permission.member:transfer permission. 转移飞书文档所有权。需要docs:permission.member:transfer权限。",
|
||||
"handler": "feishu_smart_doc_writer.transfer_ownership",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"doc_url": {"type": "string"},
|
||||
"owner_openid": {"type": "string"}
|
||||
},
|
||||
"required": ["doc_url", "owner_openid"]
|
||||
},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {"type": "boolean"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_config_status",
|
||||
"description": "Get current config status to check if OpenID is configured. 获取当前配置状态,查看是否已配置OpenID。",
|
||||
"handler": "feishu_smart_doc_writer.get_config_status",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"configured": {"type": "boolean"},
|
||||
"openid": {"type": "string"},
|
||||
"first_time": {"type": "boolean"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search_docs",
|
||||
"description": "Search local document index by keywords. Searches in document name, summary, and tags. 搜索本地文档索引,支持关键词搜索。",
|
||||
"handler": "index_manager.search_docs",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": "Search keyword / 搜索关键词"
|
||||
}
|
||||
},
|
||||
"required": ["keyword"]
|
||||
},
|
||||
"returns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"link": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"tags": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_docs",
|
||||
"description": "List all Feishu documents from local index. Optionally filter by tag. 列出所有飞书文档,可按标签筛选。",
|
||||
"handler": "index_manager.list_all_docs",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Optional tag filter / 可选的标签筛选"
|
||||
}
|
||||
}
|
||||
},
|
||||
"returns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"link": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"tags": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"dependencies": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"owner_openid": "ou_5b921cba0fd6e7c885276a02d730ec19",
|
||||
"permission_noted": true,
|
||||
"first_time": false
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** https://skills.sh/
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Search for Skills
|
||||
|
||||
Run the find command with a relevant query:
|
||||
|
||||
```bash
|
||||
npx skills find [query]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
The command will return results like:
|
||||
|
||||
```
|
||||
Install with npx skills add <owner/repo@skill>
|
||||
|
||||
vercel-labs/agent-skills@vercel-react-best-practices
|
||||
└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices
|
||||
```
|
||||
|
||||
### Step 3: Present Options to the User
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The install command they can run
|
||||
3. A link to learn more at skills.sh
|
||||
|
||||
Example response:
|
||||
|
||||
```
|
||||
I found a skill that might help! The "vercel-react-best-practices" skill provides
|
||||
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||
|
||||
To install it:
|
||||
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
|
||||
|
||||
Learn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices
|
||||
```
|
||||
|
||||
### Step 4: Offer to Install
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add <owner/repo@skill> -g -y
|
||||
```
|
||||
|
||||
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||
|
||||
## Common Skill Categories
|
||||
|
||||
When searching, consider these common categories:
|
||||
|
||||
| Category | Example Queries |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||
| Testing | testing, jest, playwright, e2e |
|
||||
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||
| Documentation | docs, readme, changelog, api-docs |
|
||||
| Code Quality | review, lint, refactor, best-practices |
|
||||
| Design | ui, ux, design-system, accessibility |
|
||||
| Productivity | workflow, automation, git |
|
||||
|
||||
## Tips for Effective Searches
|
||||
|
||||
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
If no relevant skills exist:
|
||||
|
||||
1. Acknowledge that no existing skill was found
|
||||
2. Offer to help with the task directly using your general capabilities
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
I searched for skills related to "xyz" but didn't find any matches.
|
||||
I can still help you with this task directly! Would you like me to proceed?
|
||||
|
||||
If this is something you do often, you could create your own skill:
|
||||
npx skills init my-xyz-skill
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn77ajmmqw3cgnc3ay1x3e0ccd805hsw",
|
||||
"slug": "find-skills",
|
||||
"version": "0.1.0",
|
||||
"publishedAt": 1769698710765
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: funasr-transcribe
|
||||
description: 本地音频转录工具,使用阿里 FunASR 模型进行语音识别。支持中文、英文等多种语言,无需 API 费用,完全本地运行。适用于音频文件转写(.wav, .ogg, .mp3 等)、会议记录、语音笔记整理等场景。
|
||||
---
|
||||
|
||||
# FunASR 语音转录
|
||||
|
||||
本地、免费、高效的语音识别工具,基于阿里巴巴 FunASR 模型。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 安装 FunASR
|
||||
bash ~/.openclaw/workspace/skills/funasr-transcribe/scripts/install.sh
|
||||
|
||||
# 2. 转录音频
|
||||
bash ~/.openclaw/workspace/skills/funasr-transcribe/scripts/transcribe.sh /path/to/audio.ogg
|
||||
```
|
||||
|
||||
## 安装 FunASR
|
||||
|
||||
首次使用需要安装 FunASR 环境(虚拟环境 + 依赖):
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/workspace/skills/funasr-transcribe/scripts/install.sh
|
||||
```
|
||||
|
||||
安装脚本会:
|
||||
- 创建 Python 虚拟环境 `~/.openclaw/workspace/funasr_env`
|
||||
- 安装 FunASR、torch、torchaudio、modelscope 等依赖
|
||||
- 安装完成后,首次转录会自动下载模型文件
|
||||
|
||||
**安装时间**:约 5-10 分钟(取决于网络速度)
|
||||
|
||||
**系统要求**:
|
||||
- Python 3.7+
|
||||
- 约 4GB 磁盘空间(虚拟环境 + 模型)
|
||||
- 推荐 8GB+ 内存
|
||||
|
||||
## 转录音频
|
||||
|
||||
安装完成后,转录音频:
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/workspace/skills/funasr-transcribe/scripts/transcribe.sh /path/to/audio.ogg
|
||||
```
|
||||
|
||||
**支持的格式**:`.wav`, `.ogg`, `.mp3`, `.flac`, `.m4a` 等
|
||||
|
||||
**输出**:
|
||||
- 同目录下生成 `<audio_filename>.txt`
|
||||
- 包含转录文本(带标点)
|
||||
|
||||
**性能**:
|
||||
- CPU 推理:rtf 约 0.05-0.2(1 秒音频约需 0.05-0.2 秒)
|
||||
- 首次转录需下载模型(约 1-2GB),后续直接使用缓存
|
||||
|
||||
## 技术细节
|
||||
|
||||
FunASR 使用以下模型组合:
|
||||
- **ASR 模型**:`damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-pytorch`(中文优化)
|
||||
- **VAD 模型**:`damo/speech_fsmn_vad_zh-cn-16k-common-pytorch`(语音活动检测)
|
||||
- **标点模型**:`damo/punc_ct-transformer_zh-cn-common-vocab272727-pytorch`(标点恢复)
|
||||
|
||||
**语言支持**:
|
||||
- 中文(普通话 + 方言)
|
||||
- 英文
|
||||
- 中英混合
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 首次转录很慢?**
|
||||
A: 首次运行会自动下载模型文件(约 1-2GB),后续转录会快很多。
|
||||
|
||||
**Q: 可以用 GPU 吗?**
|
||||
A: 可以。编辑 `scripts/transcribe.py`,将 `device="cpu"` 改为 `device="cuda:0"`,并安装对应的 CUDA 版本依赖。
|
||||
|
||||
**Q: 转录准确率如何?**
|
||||
A: FunASR 在中文场景下表现优异,通常优于 OpenAI Whisper。建议测试后评估效果。
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn73y7erceybm87h6618deeva58296sk",
|
||||
"slug": "funasr-transcribe-skill",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1772877767626
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# FunASR 安装脚本
|
||||
# 用途:创建虚拟环境并安装 FunASR 及其依赖
|
||||
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
VENV_DIR="$HOME/.openclaw/workspace/funasr_env"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "=========================================="
|
||||
echo "FunASR 安装脚本"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 检查 Python
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "❌ 错误:未找到 python3"
|
||||
echo "请先安装 Python 3.7+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
||||
echo "✓ Python 版本: $PYTHON_VERSION"
|
||||
|
||||
# 创建虚拟环境
|
||||
if [ -d "$VENV_DIR" ]; then
|
||||
echo "⚠️ 虚拟环境已存在: $VENV_DIR"
|
||||
read -p "是否重新安装?(y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "安装已取消"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "创建虚拟环境: $VENV_DIR"
|
||||
python3 -m venv "$VENV_DIR"
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# 升级 pip
|
||||
echo ""
|
||||
echo "升级 pip..."
|
||||
pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 安装依赖
|
||||
echo ""
|
||||
echo "安装 FunASR 及依赖(这需要几分钟)..."
|
||||
pip install funasr modelscope huggingface_hub torch torchaudio \
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 验证安装
|
||||
echo ""
|
||||
echo "验证安装..."
|
||||
python3 -c "from funasr import AutoModel; print('✓ FunASR 安装成功')" || {
|
||||
echo "❌ FunASR 安装失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 完成
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✓ 安装完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "现在可以使用转录功能:"
|
||||
echo " bash $SCRIPT_DIR/transcribe.sh /path/to/audio.ogg"
|
||||
echo ""
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FunASR 音频转录脚本"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
try:
|
||||
from funasr import AutoModel
|
||||
except ImportError:
|
||||
print("❌ 错误:未找到 funasr 模块")
|
||||
print("")
|
||||
print("请先安装 FunASR:")
|
||||
print(" bash ~/.openclaw/workspace/skills/funasr-transcribe/scripts/install.sh")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def transcribe_audio(audio_path):
|
||||
"""使用 FunASR 转录音频"""
|
||||
print(f"正在处理音频: {audio_path}")
|
||||
print("首次运行会自动下载模型,可能需要几分钟...")
|
||||
|
||||
# 加载模型
|
||||
model = AutoModel(
|
||||
model="damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
|
||||
vad_model="damo/speech_fsmn_vad_zh-cn-16k-common-pytorch",
|
||||
punc_model="damo/punc_ct-transformer_zh-cn-common-vocab272727-pytorch",
|
||||
device="cpu" # 使用 CPU,如果有 GPU 可以改为 "cuda:0"
|
||||
)
|
||||
|
||||
# 进行语音识别
|
||||
res = model.generate(
|
||||
input=audio_path,
|
||||
batch_size_s=300
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 transcribe.py <audio_file>")
|
||||
sys.exit(1)
|
||||
|
||||
audio_path = sys.argv[1]
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(audio_path):
|
||||
print(f"❌ 错误:文件不存在: {audio_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# 转录
|
||||
try:
|
||||
result = transcribe_audio(audio_path)
|
||||
except Exception as e:
|
||||
print(f"❌ 转录失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 输出结果
|
||||
text = ""
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
text = result[0].get("text", "")
|
||||
|
||||
if not text:
|
||||
print("⚠️ 未识别到文本")
|
||||
sys.exit(0)
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("转录结果:")
|
||||
print("="*50)
|
||||
print(text)
|
||||
|
||||
# 保存到文件
|
||||
output_path = os.path.splitext(audio_path)[0] + ".txt"
|
||||
try:
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
print("")
|
||||
print(f"✓ 已保存到: {output_path}")
|
||||
except Exception as e:
|
||||
print(f"\n⚠️ 保存文件失败: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# FunASR 音频转录脚本
|
||||
# 用途:将音频文件转录为文本
|
||||
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
VENV_DIR="$HOME/.openclaw/workspace/funasr_env"
|
||||
TRANSCRIBE_PY="$HOME/.openclaw/workspace/skills/funasr-transcribe-skill/scripts/transcribe.py"
|
||||
|
||||
# 检查参数
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "用法: $0 <audio_file>"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 /path/to/audio.ogg"
|
||||
echo " $0 recording.wav"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AUDIO_FILE="$1"
|
||||
|
||||
# 检查文件是否存在
|
||||
if [ ! -f "$AUDIO_FILE" ]; then
|
||||
echo "❌ 错误:文件不存在: $AUDIO_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查虚拟环境
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "❌ 错误:FunASR 未安装"
|
||||
echo ""
|
||||
echo "请先运行安装脚本:"
|
||||
echo " bash ~/.openclaw/workspace/skills/funasr-transcribe/scripts/install.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 激活虚拟环境并转录
|
||||
source "$VENV_DIR/bin/activate"
|
||||
python3 "$TRANSCRIBE_PY" "$AUDIO_FILE"
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: github
|
||||
description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries."
|
||||
---
|
||||
|
||||
# GitHub Skill
|
||||
|
||||
Use the `gh` CLI to interact with GitHub. Always specify `--repo owner/repo` when not in a git directory, or use URLs directly.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Check CI status on a PR:
|
||||
```bash
|
||||
gh pr checks 55 --repo owner/repo
|
||||
```
|
||||
|
||||
List recent workflow runs:
|
||||
```bash
|
||||
gh run list --repo owner/repo --limit 10
|
||||
```
|
||||
|
||||
View a run and see which steps failed:
|
||||
```bash
|
||||
gh run view <run-id> --repo owner/repo
|
||||
```
|
||||
|
||||
View logs for failed steps only:
|
||||
```bash
|
||||
gh run view <run-id> --repo owner/repo --log-failed
|
||||
```
|
||||
|
||||
## API for Advanced Queries
|
||||
|
||||
The `gh api` command is useful for accessing data not available through other subcommands.
|
||||
|
||||
Get PR with specific fields:
|
||||
```bash
|
||||
gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login'
|
||||
```
|
||||
|
||||
## JSON Output
|
||||
|
||||
Most commands support `--json` for structured output. You can use `--jq` to filter:
|
||||
|
||||
```bash
|
||||
gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"'
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "github",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1767545344344
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
---
|
||||
name: "marketing-strategy-pmm"
|
||||
description: Product marketing skill for positioning, GTM strategy, competitive intelligence, and product launches. Use when the user asks about product positioning, go-to-market planning, competitive analysis, target audience definition, ICP definition, market research, launch plans, or sales enablement. Covers April Dunford positioning, ICP definition, competitive battlecards, launch playbooks, and international market entry. Produces deliverables including positioning statements, battlecard documents, launch plans, and go-to-market strategies.
|
||||
triggers:
|
||||
- product marketing
|
||||
- PMM
|
||||
- positioning
|
||||
- GTM strategy
|
||||
- go-to-market
|
||||
- competitive analysis
|
||||
- battlecard
|
||||
- product launch
|
||||
- market entry
|
||||
- sales enablement
|
||||
- win loss analysis
|
||||
---
|
||||
|
||||
# Marketing Strategy & PMM
|
||||
|
||||
Product marketing patterns for positioning, GTM strategy, and competitive intelligence.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [ICP Definition Workflow](#icp-definition-workflow)
|
||||
- [Positioning Development](#positioning-development)
|
||||
- [Competitive Intelligence](#competitive-intelligence)
|
||||
- [Product Launch Planning](#product-launch-planning)
|
||||
- [Sales Enablement](#sales-enablement)
|
||||
- [International Expansion](#international-expansion)
|
||||
- [Reference Documentation](#reference-documentation)
|
||||
|
||||
---
|
||||
|
||||
## ICP Definition Workflow
|
||||
|
||||
Define ideal customer profile for targeting:
|
||||
|
||||
1. Analyze existing customers (top 20% by LTV)
|
||||
2. Identify common firmographics (size, industry, revenue)
|
||||
3. Map technographics (tools, maturity, integrations)
|
||||
4. Document psychographics (pain level, motivation, risk tolerance)
|
||||
5. Define 3-5 buyer personas (economic, technical, user)
|
||||
6. Validate against sales cycle and churn data
|
||||
7. Score prospects A/B/C/D based on ICP fit
|
||||
8. **Validation:** A-fit customers have lowest churn and fastest close
|
||||
|
||||
### Firmographics Template
|
||||
|
||||
| Dimension | Target Range | Rationale |
|
||||
|-----------|--------------|-----------|
|
||||
| Employees | 50-5000 | Series A sweet spot |
|
||||
| Revenue | $5M-$500M | Budget available |
|
||||
| Industry | SaaS, Tech, Services | Product fit |
|
||||
| Geography | US, UK, DACH | Market priority |
|
||||
| Funding | Seed to Growth | Willing to adopt |
|
||||
|
||||
### Buyer Personas
|
||||
|
||||
| Persona | Title | Goals | Messaging |
|
||||
|---------|-------|-------|-----------|
|
||||
| Economic Buyer | VP, Director, Head of [Department] | ROI, team productivity, cost reduction | Business outcomes, ROI, case studies |
|
||||
| Technical Buyer | Engineer, Architect, Tech Lead | Technical fit, easy integration | Architecture, security, documentation |
|
||||
| User/Champion | Manager, Team Lead, Power User | Makes job easier, quick wins | UX, ease of use, time savings |
|
||||
|
||||
### ICP Validation Checklist
|
||||
|
||||
- [ ] 5+ paying customers match this profile
|
||||
- [ ] Fastest sales cycles (< median)
|
||||
- [ ] Highest LTV (> median)
|
||||
- [ ] Lowest churn (< 5% annual)
|
||||
- [ ] Strong product engagement
|
||||
- [ ] Willing to do case studies
|
||||
|
||||
---
|
||||
|
||||
## Positioning Development
|
||||
|
||||
Develop positioning using April Dunford methodology:
|
||||
|
||||
1. List competitive alternatives (direct, adjacent, status quo)
|
||||
2. Isolate unique attributes (features only you have)
|
||||
3. Map attributes to customer value (why it matters)
|
||||
4. Define best-fit customers (who cares most)
|
||||
5. Choose market category (head-to-head, niche, new category)
|
||||
6. Layer on relevant trends (timing justification)
|
||||
7. Test with 10+ customer interviews
|
||||
8. **Validation:** 7+ customers describe value unprompted
|
||||
|
||||
### Positioning Statement Template
|
||||
|
||||
```
|
||||
FOR [target customer]
|
||||
WHO [statement of need]
|
||||
THE [product] IS A [category]
|
||||
THAT [key benefit]
|
||||
UNLIKE [competitive alternative]
|
||||
OUR PRODUCT [primary differentiation]
|
||||
```
|
||||
|
||||
### Value Proposition Formula
|
||||
|
||||
Template: `[Product] helps [Target Customer] [Achieve Goal] by [Unique Approach]`
|
||||
|
||||
Example: "Acme helps mid-market SaaS teams ship 2x faster by automating project workflows with AI"
|
||||
|
||||
### Messaging Hierarchy
|
||||
|
||||
| Level | Content | Example |
|
||||
|-------|---------|---------|
|
||||
| Headline | 5-7 words | "Ship faster with AI automation" |
|
||||
| Subhead | 1 sentence | "Automate workflows so teams focus on what matters" |
|
||||
| Benefits | 3-4 bullets | Speed, quality, collaboration, cost |
|
||||
| Features | Supporting evidence | AI automation → 10 hrs/week saved |
|
||||
| Proof | Social proof | Customer logos, stats, case studies |
|
||||
|
||||
---
|
||||
|
||||
## Competitive Intelligence
|
||||
|
||||
Build competitive knowledge base:
|
||||
|
||||
1. Identify tier 1 (direct), tier 2 (adjacent), tier 3 (status quo)
|
||||
2. Sign up for competitor products (hands-on evaluation)
|
||||
3. Monitor competitor websites, pricing, messaging
|
||||
4. Analyze sales call recordings for competitor mentions
|
||||
5. Read G2/Capterra reviews (pros and cons)
|
||||
6. Track competitor job postings (roadmap signals)
|
||||
7. Update battlecards monthly
|
||||
8. **Validation:** Sales team uses battlecards in 80%+ competitive deals
|
||||
|
||||
### Competitive Tier Structure
|
||||
|
||||
| Tier | Definition | Examples |
|
||||
|------|------------|----------|
|
||||
| 1 | Direct competitor, same category | [Competitor A, B] |
|
||||
| 2 | Adjacent solution, overlapping use case | [Alt Solution C, D] |
|
||||
| 3 | Status quo (what they do today) | Spreadsheets, manual, in-house |
|
||||
|
||||
### Battlecard Template
|
||||
|
||||
```
|
||||
COMPETITOR: [Name]
|
||||
OVERVIEW: Founded [year], Funding [stage], Size [employees]
|
||||
|
||||
POSITIONING:
|
||||
- They say: "[Their claim]"
|
||||
- Reality: [Your assessment]
|
||||
|
||||
STRENGTHS:
|
||||
1. [What they do well]
|
||||
2. [What they do well]
|
||||
|
||||
WEAKNESSES:
|
||||
1. [Where they fall short]
|
||||
2. [Where they fall short]
|
||||
|
||||
OUR ADVANTAGES:
|
||||
1. [Your advantage + evidence]
|
||||
2. [Your advantage + evidence]
|
||||
|
||||
WHEN WE WIN:
|
||||
- [Scenario where you win]
|
||||
|
||||
WHEN WE LOSE:
|
||||
- [Scenario where they win]
|
||||
|
||||
TALK TRACK:
|
||||
Objection: "[Common objection]"
|
||||
Response: "[Your response]"
|
||||
```
|
||||
|
||||
### Win/Loss Analysis
|
||||
|
||||
Track monthly:
|
||||
- Win rate by competitor
|
||||
- Top win reasons (product fit, ease of use, price)
|
||||
- Top loss reasons (missing feature, price, relationship)
|
||||
- Action items for product, sales, marketing
|
||||
|
||||
---
|
||||
|
||||
## Product Launch Planning
|
||||
|
||||
Plan launches by tier:
|
||||
|
||||
| Tier | Scope | Prep Time | Budget |
|
||||
|------|-------|-----------|--------|
|
||||
| 1 | New product, major feature | 6-8 weeks | $50-100k |
|
||||
| 2 | Significant feature, integration | 3-4 weeks | $10-25k |
|
||||
| 3 | Small improvement | 1 week | <$5k |
|
||||
|
||||
### Tier 1 Launch Workflow
|
||||
|
||||
Execute major product launch:
|
||||
|
||||
1. Kickoff meeting with Product, Marketing, Sales, CS
|
||||
2. Define goals (pipeline $, MQLs, press coverage)
|
||||
3. Develop positioning and messaging
|
||||
4. Create sales enablement (deck, demo, battlecard)
|
||||
5. Build campaign assets (landing page, emails, ads)
|
||||
6. Train sales and CS teams
|
||||
7. Execute launch day (press, email, ads, outbound)
|
||||
8. Monitor and optimize for 30 days
|
||||
9. **Validation:** Pipeline on track to goal by week 2
|
||||
|
||||
### Launch Day Checklist
|
||||
|
||||
- [ ] Press release distributed
|
||||
- [ ] Email announcement sent
|
||||
- [ ] Social media posts live
|
||||
- [ ] Paid ads at full budget
|
||||
- [ ] Sales outbound blitz launched
|
||||
- [ ] In-app notification active
|
||||
- [ ] Metrics monitored every 2 hours
|
||||
|
||||
### Launch Metrics
|
||||
|
||||
| Metric | Leading (Daily) | Lagging (Weekly) |
|
||||
|--------|-----------------|------------------|
|
||||
| Traffic | Landing page visitors | - |
|
||||
| Engagement | Demo requests, signups | Feature adoption % |
|
||||
| Pipeline | MQLs generated | SQLs, pipeline $ |
|
||||
| Revenue | - | Deals closed, revenue |
|
||||
|
||||
---
|
||||
|
||||
## Sales Enablement
|
||||
|
||||
Equip sales team with PMM assets:
|
||||
|
||||
1. Create sales deck (15-20 slides, visual-first)
|
||||
2. Build one-pagers (product, competitive, case study)
|
||||
3. Develop demo script (30-45 min with discovery)
|
||||
4. Write email templates (outreach, follow-up, closing)
|
||||
5. Create ROI calculator (input costs, output savings)
|
||||
6. Conduct monthly enablement calls
|
||||
7. Deliver quarterly training (positioning, competitive)
|
||||
8. **Validation:** Sales uses assets in 80%+ of opportunities
|
||||
|
||||
### Sales Deck Structure
|
||||
|
||||
| Slide | Content |
|
||||
|-------|---------|
|
||||
| 1-2 | Title, agenda |
|
||||
| 3-4 | Company intro, problem statement |
|
||||
| 5-7 | Solution, key benefits, demo |
|
||||
| 8-10 | Differentiation, case study, pricing |
|
||||
| 11-12 | Implementation, support, next steps |
|
||||
|
||||
### Demo Flow
|
||||
|
||||
```
|
||||
1. Intro (2 min): Who we are, agenda
|
||||
2. Discovery (5 min): Their needs, pain points
|
||||
3. Demo (20 min): Product focused on their use case
|
||||
4. Q&A (10 min): Objection handling
|
||||
5. Next steps (3 min): Trial, POC, proposal
|
||||
```
|
||||
|
||||
### Sales-Marketing Handoff
|
||||
|
||||
| Handoff | Frequency | Content |
|
||||
|---------|-----------|---------|
|
||||
| Weekly sync | 30 min | Win/loss, competitive, new assets |
|
||||
| Monthly enablement | 60 min | Product updates, training |
|
||||
| Quarterly review | Half-day | Results, strategy, planning |
|
||||
|
||||
---
|
||||
|
||||
## International Expansion
|
||||
|
||||
Enter new markets systematically:
|
||||
|
||||
1. Validate market demand (inbound leads, TAM analysis)
|
||||
2. Localize website, pricing, legal
|
||||
3. Establish sales coverage (hire or agency)
|
||||
4. Adapt messaging for cultural fit
|
||||
5. Build local partnerships and references
|
||||
6. Launch localized campaigns
|
||||
7. Monitor CAC and conversion by market
|
||||
8. **Validation:** 3+ paying customers from market in first 90 days
|
||||
|
||||
### Market Priority (Series A)
|
||||
|
||||
| Market | Timeline | Budget % | Target ARR |
|
||||
|--------|----------|----------|------------|
|
||||
| US | Months 1-6 | 50% | $1M |
|
||||
| UK | Months 4-9 | 20% | $500k |
|
||||
| DACH | Months 7-12 | 15% | $300k |
|
||||
| France | Months 10-15 | 10% | $200k |
|
||||
| Canada | Months 7-12 | 5% | $100k |
|
||||
|
||||
### Localization Checklist
|
||||
|
||||
- [ ] Website translation (professional, not machine)
|
||||
- [ ] Currency and pricing localized
|
||||
- [ ] Local phone number and address
|
||||
- [ ] Legal compliance (GDPR, PIPEDA)
|
||||
- [ ] Local payment methods
|
||||
- [ ] Sales coverage during local hours
|
||||
- [ ] Local case studies and references
|
||||
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
### Positioning Frameworks
|
||||
|
||||
`references/positioning-frameworks.md` contains:
|
||||
|
||||
- April Dunford 5-step positioning process
|
||||
- Geoffrey Moore positioning statement template
|
||||
- Positioning validation interview protocol
|
||||
- Competitive positioning map construction
|
||||
|
||||
### Launch Checklists
|
||||
|
||||
`references/launch-checklists.md` contains:
|
||||
|
||||
- Tier 1/2/3 launch checklists
|
||||
- Week-by-week launch timeline
|
||||
- Launch day runbook
|
||||
- Post-launch metrics dashboard
|
||||
|
||||
### International GTM
|
||||
|
||||
`references/international-gtm.md` contains:
|
||||
|
||||
- US, UK, DACH, France, Canada playbooks
|
||||
- Market-specific channel mix and messaging
|
||||
- Localization requirements per market
|
||||
- Entry timeline and budget allocation
|
||||
|
||||
### Messaging Templates
|
||||
|
||||
`references/messaging-templates.md` contains:
|
||||
|
||||
- Value proposition formulas
|
||||
- Persona-specific messaging
|
||||
- Competitive response scripts
|
||||
- Objection handling templates
|
||||
- Channel-specific copy (landing pages, emails, ads)
|
||||
|
||||
---
|
||||
|
||||
## PMM KPIs
|
||||
|
||||
| Metric | Target | Measurement |
|
||||
|--------|--------|-------------|
|
||||
| Product adoption | >40% in 90 days | Feature usage after launch |
|
||||
| Win rate | >30% competitive | Deals won vs. competitors |
|
||||
| Sales velocity | -20% YoY | Days from SQL to close |
|
||||
| Deal size | +25% YoY | Average contract value |
|
||||
| Launch pipeline | 3:1 ROMI | Pipeline $ : marketing spend |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### PMM Monthly Rhythm
|
||||
|
||||
| Week | Focus |
|
||||
|------|-------|
|
||||
| 1 | Review metrics, update battlecards |
|
||||
| 2 | Create assets, publish content |
|
||||
| 3 | Support launches, optimize campaigns |
|
||||
| 4 | Monthly report, plan next month |
|
||||
|
||||
## Proactive Triggers
|
||||
|
||||
- **No documented positioning** → Without clear positioning, all marketing is guesswork.
|
||||
- **Messaging differs across channels** → Inconsistent story confuses buyers.
|
||||
- **No ICP defined** → Selling to everyone means selling to no one.
|
||||
- **Competitor repositioning** → Market shift detected. Review your positioning.
|
||||
|
||||
## Output Artifacts
|
||||
|
||||
| When you ask for... | You get... |
|
||||
|---------------------|------------|
|
||||
| "Position my product" | Positioning framework (April Dunford method) with output |
|
||||
| "GTM strategy" | Go-to-market plan with channels, messaging, and timeline |
|
||||
| "Competitive positioning" | Positioning map with competitive gaps and opportunities |
|
||||
|
||||
## Communication
|
||||
|
||||
All output passes quality verification:
|
||||
- Self-verify: source attribution, assumption audit, confidence scoring
|
||||
- Output format: Bottom Line → What (with confidence) → Why → How to Act
|
||||
- Results only. Every finding tagged: 🟢 verified, 🟡 medium, 🔴 assumed.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **marketing-context**: For capturing foundational positioning. PMM builds on this.
|
||||
- **launch-strategy**: For executing product launches planned by PMM.
|
||||
- **competitive-intel** (C-Suite): For strategic competitive intelligence.
|
||||
- **cmo-advisor** (C-Suite): For marketing budget and growth model decisions.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7f2gr00xy51fj1nx2y64ckjs800mhn",
|
||||
"slug": "marketing-strategy-pmm",
|
||||
"version": "2.1.1",
|
||||
"publishedAt": 1773070262187
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
# International GTM Playbooks
|
||||
|
||||
Market-by-market expansion guides for US, UK, DACH, France, and Canada.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Market Prioritization](#market-prioritization)
|
||||
- [US Market Entry](#us-market-entry)
|
||||
- [UK Market Entry](#uk-market-entry)
|
||||
- [DACH Market Entry](#dach-market-entry)
|
||||
- [France Market Entry](#france-market-entry)
|
||||
- [Canada Market Entry](#canada-market-entry)
|
||||
- [Localization Checklist](#localization-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Market Prioritization
|
||||
|
||||
### Expansion Sequence (Series A)
|
||||
|
||||
| Phase | Market | Timeline | Budget % | Target ARR |
|
||||
|-------|--------|----------|----------|------------|
|
||||
| 1 | US | Months 1-6 | 50% | $1M |
|
||||
| 2 | UK | Months 4-9 | 20% | $500k |
|
||||
| 3 | DACH | Months 7-12 | 15% | $300k |
|
||||
| 4 | France | Months 10-15 | 10% | $200k |
|
||||
| 5 | Canada | Months 7-12 | 5% | $100k |
|
||||
|
||||
### Market Readiness Checklist
|
||||
|
||||
Enter market when ALL true:
|
||||
|
||||
- [ ] Product ready for market (localization if needed)
|
||||
- [ ] Legal/compliance requirements met
|
||||
- [ ] Pricing localized (currency, taxes)
|
||||
- [ ] Sales capacity available (hire or agency)
|
||||
- [ ] Marketing budget allocated
|
||||
- [ ] Support coverage during local hours
|
||||
- [ ] **Validation:** 3+ inbound leads from market in last 90 days
|
||||
|
||||
---
|
||||
|
||||
## US Market Entry
|
||||
|
||||
### Market Characteristics
|
||||
|
||||
| Factor | US Approach |
|
||||
|--------|-------------|
|
||||
| Buying cycle | Fast (30-60 days average) |
|
||||
| Decision process | Individual empowerment, less consensus |
|
||||
| Pricing sensitivity | Value-focused, willing to pay premium |
|
||||
| Communication | Direct, results-oriented |
|
||||
| Relationship | Transaction > relationship (initially) |
|
||||
|
||||
### Entry Strategy
|
||||
|
||||
**Months 1-2: Foundation**
|
||||
|
||||
1. Establish US presence:
|
||||
- US phone number (toll-free)
|
||||
- US address (virtual office acceptable)
|
||||
- USD pricing on website
|
||||
- US case studies (even if from beta users)
|
||||
|
||||
2. Hire US sales:
|
||||
- Option A: US-based SDR/AE (expensive but effective)
|
||||
- Option B: US sales agency (lower risk, shared commission)
|
||||
- Option C: Remote sales trained on US hours
|
||||
|
||||
3. Launch paid campaigns:
|
||||
- Google Ads (high-intent keywords)
|
||||
- LinkedIn (B2B targeting)
|
||||
- Budget: 50% of marketing spend
|
||||
|
||||
**Months 3-6: Scale**
|
||||
|
||||
1. Optimize channels based on CAC data
|
||||
2. Build US partner ecosystem:
|
||||
- Integration partners (Salesforce, HubSpot)
|
||||
- Resellers/VARs (for Enterprise)
|
||||
- Industry associations
|
||||
3. Attend US conferences (SaaStr, industry events)
|
||||
4. **Validation:** $1M pipeline from US sources
|
||||
|
||||
### US Channel Mix
|
||||
|
||||
| Channel | Budget % | Expected CPL | Notes |
|
||||
|---------|----------|--------------|-------|
|
||||
| Google Ads | 35% | $100-200 | High intent, competitive |
|
||||
| LinkedIn | 30% | $150-250 | B2B targeting |
|
||||
| SEO/Content | 20% | $50 (long-term) | Invest early |
|
||||
| Partnerships | 15% | Variable | Co-marketing |
|
||||
|
||||
### US Messaging
|
||||
|
||||
- Lead with ROI and business outcomes
|
||||
- Use $ impact metrics prominently
|
||||
- Reference US customers (logos matter)
|
||||
- Emphasize speed and efficiency
|
||||
- Include G2/Capterra ratings
|
||||
|
||||
---
|
||||
|
||||
## UK Market Entry
|
||||
|
||||
### Market Characteristics
|
||||
|
||||
| Factor | UK Approach |
|
||||
|--------|-------------|
|
||||
| Buying cycle | Medium (45-90 days) |
|
||||
| Decision process | Committee involvement |
|
||||
| Pricing sensitivity | Value-conscious, compare options |
|
||||
| Communication | Professional, less aggressive than US |
|
||||
| Relationship | Balance transaction and relationship |
|
||||
|
||||
### Entry Strategy
|
||||
|
||||
**Months 4-6: Setup**
|
||||
|
||||
1. Localization:
|
||||
- GBP pricing
|
||||
- UK spellings (colour, organisation)
|
||||
- UK phone number
|
||||
- GDPR compliance (essential)
|
||||
|
||||
2. Sales coverage:
|
||||
- Hire UK-based rep OR
|
||||
- Partner with UK sales agency
|
||||
- Ensure coverage during GMT hours
|
||||
|
||||
3. Content localization:
|
||||
- UK case studies
|
||||
- UK-relevant industry references
|
||||
- Local competitor positioning
|
||||
|
||||
**Months 7-9: Growth**
|
||||
|
||||
1. Build UK partnerships:
|
||||
- UK tech community (TechNation, etc.)
|
||||
- London-based VCs and accelerators
|
||||
- UK industry associations
|
||||
|
||||
2. Attend UK events:
|
||||
- London Tech Week
|
||||
- Industry-specific conferences
|
||||
|
||||
3. **Validation:** $500k pipeline from UK sources
|
||||
|
||||
### UK Channel Mix
|
||||
|
||||
| Channel | Budget % | Expected CPL | Notes |
|
||||
|---------|----------|--------------|-------|
|
||||
| LinkedIn | 35% | $120-200 | Strong B2B presence |
|
||||
| Google UK | 30% | $80-150 | Less competitive than US |
|
||||
| SEO/Content | 20% | $40 | UK-targeted keywords |
|
||||
| Partnerships | 15% | Variable | Local credibility |
|
||||
|
||||
### UK Messaging
|
||||
|
||||
- More formal than US (avoid hyperbole)
|
||||
- Emphasize data security and GDPR
|
||||
- Reference UK/EU customers
|
||||
- Understated claims (prove with data)
|
||||
- Acknowledge local presence/support
|
||||
|
||||
---
|
||||
|
||||
## DACH Market Entry
|
||||
|
||||
### Market Characteristics
|
||||
|
||||
| Factor | DACH Approach |
|
||||
|--------|---------------|
|
||||
| Buying cycle | Long (90-180 days) |
|
||||
| Decision process | Consensus-driven, thorough evaluation |
|
||||
| Pricing sensitivity | Quality over price, long-term view |
|
||||
| Communication | Formal, detailed, precise |
|
||||
| Relationship | Trust built over time, essential |
|
||||
|
||||
### Entry Strategy
|
||||
|
||||
**Months 7-9: Foundation**
|
||||
|
||||
1. Full localization:
|
||||
- German translation (website, product UI)
|
||||
- EUR pricing with German VAT handling
|
||||
- German phone number and address
|
||||
- GDPR compliance (strict enforcement)
|
||||
- Data residency option (EU data centers)
|
||||
|
||||
2. German-speaking sales:
|
||||
- Hire German-speaking sales rep
|
||||
- Native speaker critical (not just fluent)
|
||||
- Based in Germany preferred
|
||||
|
||||
3. Content in German:
|
||||
- Translate key pages and materials
|
||||
- Create German case studies
|
||||
- German blog content
|
||||
|
||||
**Months 10-12: Growth**
|
||||
|
||||
1. Build local credibility:
|
||||
- German customer testimonials
|
||||
- German partner ecosystem
|
||||
- Industry certifications (ISO, etc.)
|
||||
|
||||
2. Attend German events:
|
||||
- CeBIT/Hannover Messe
|
||||
- Industry conferences
|
||||
|
||||
3. **Validation:** $300k pipeline from DACH sources
|
||||
|
||||
### DACH Channel Mix
|
||||
|
||||
| Channel | Budget % | Expected CPL | Notes |
|
||||
|---------|----------|--------------|-------|
|
||||
| LinkedIn | 40% | $150-250 | Strong professional network |
|
||||
| Google DE | 25% | $100-180 | German keywords |
|
||||
| SEO (German) | 20% | $60 | Long-term investment |
|
||||
| Partnerships | 15% | Variable | Critical for trust |
|
||||
|
||||
### DACH Messaging
|
||||
|
||||
- Formal tone (Sie, not du)
|
||||
- Emphasize security, compliance, privacy
|
||||
- Detailed specifications and documentation
|
||||
- Reference German/EU customers
|
||||
- Include certifications (ISO, SOC 2)
|
||||
- Show long-term commitment to market
|
||||
|
||||
---
|
||||
|
||||
## France Market Entry
|
||||
|
||||
### Market Characteristics
|
||||
|
||||
| Factor | France Approach |
|
||||
|--------|-----------------|
|
||||
| Buying cycle | Long (90-180 days) |
|
||||
| Decision process | Hierarchical, formal process |
|
||||
| Pricing sensitivity | Value-focused, negotiation expected |
|
||||
| Communication | Formal, relationship-focused |
|
||||
| Relationship | Critical, business built on trust |
|
||||
|
||||
### Entry Strategy
|
||||
|
||||
**Months 10-12: Foundation**
|
||||
|
||||
1. Full French localization:
|
||||
- French translation (professional, not machine)
|
||||
- EUR pricing with French VAT
|
||||
- French phone number
|
||||
- GDPR + French regulations
|
||||
|
||||
2. French-speaking team:
|
||||
- Native French speaker for sales
|
||||
- French support coverage
|
||||
- Paris presence (even virtual)
|
||||
|
||||
**Months 13-15: Growth**
|
||||
|
||||
1. Build local ecosystem:
|
||||
- French tech community (La French Tech)
|
||||
- French partners and integrators
|
||||
- Industry associations
|
||||
|
||||
2. Attend French events:
|
||||
- VivaTech (Paris)
|
||||
- Industry conferences
|
||||
|
||||
3. **Validation:** $200k pipeline from France
|
||||
|
||||
### France Channel Mix
|
||||
|
||||
| Channel | Budget % | Expected CPL | Notes |
|
||||
|---------|----------|--------------|-------|
|
||||
| LinkedIn | 35% | $130-220 | Professional network |
|
||||
| Google FR | 30% | $90-160 | French keywords |
|
||||
| SEO (French) | 20% | $50 | French content strategy |
|
||||
| Partnerships | 15% | Variable | Local partners essential |
|
||||
|
||||
### France Messaging
|
||||
|
||||
- Formal and professional
|
||||
- French language throughout (no English fallback)
|
||||
- Reference French/EU customers
|
||||
- Emphasize local support and presence
|
||||
- Highlight innovation and modernity
|
||||
- Respect cultural nuances
|
||||
|
||||
---
|
||||
|
||||
## Canada Market Entry
|
||||
|
||||
### Market Characteristics
|
||||
|
||||
| Factor | Canada Approach |
|
||||
|--------|-----------------|
|
||||
| Buying cycle | Medium (45-75 days) |
|
||||
| Decision process | Similar to US, slightly more conservative |
|
||||
| Pricing sensitivity | Value-conscious, compare to US prices |
|
||||
| Communication | Professional, friendly, less aggressive |
|
||||
| Language | English (except Quebec - French required) |
|
||||
|
||||
### Entry Strategy
|
||||
|
||||
**Months 7-9: Foundation**
|
||||
|
||||
1. Minimal localization:
|
||||
- CAD pricing
|
||||
- Canadian phone number (optional)
|
||||
- PIPEDA compliance
|
||||
|
||||
2. Sales coverage:
|
||||
- Leverage US sales team (similar hours)
|
||||
- Consider Toronto-based rep for growth
|
||||
|
||||
3. Quebec consideration:
|
||||
- French required for Quebec market
|
||||
- Can delay or skip initially
|
||||
|
||||
**Months 10-12: Growth**
|
||||
|
||||
1. Canadian partnerships:
|
||||
- Canadian tech community
|
||||
- Toronto/Vancouver startup ecosystem
|
||||
- Industry associations
|
||||
|
||||
2. **Validation:** $100k pipeline from Canada
|
||||
|
||||
### Canada Channel Mix
|
||||
|
||||
| Channel | Budget % | Expected CPL | Notes |
|
||||
|---------|----------|--------------|-------|
|
||||
| Google CA | 35% | $80-150 | Canadian targeting |
|
||||
| LinkedIn | 30% | $100-180 | B2B focus |
|
||||
| SEO | 20% | $40 | Canadian content |
|
||||
| Partnerships | 15% | Variable | Local credibility |
|
||||
|
||||
---
|
||||
|
||||
## Localization Checklist
|
||||
|
||||
### Per-Market Checklist
|
||||
|
||||
**Website**
|
||||
|
||||
- [ ] Language translation (professional, not machine)
|
||||
- [ ] Currency localization (display + checkout)
|
||||
- [ ] Phone number (local format)
|
||||
- [ ] Address (local presence)
|
||||
- [ ] Legal pages (privacy, terms in local language)
|
||||
- [ ] hreflang tags configured correctly
|
||||
|
||||
**Product**
|
||||
|
||||
- [ ] UI translation (if required for market)
|
||||
- [ ] Date/time format (DD/MM/YYYY vs MM/DD/YYYY)
|
||||
- [ ] Number format (1,000 vs 1.000)
|
||||
- [ ] Currency in product
|
||||
|
||||
**Payment**
|
||||
|
||||
- [ ] Local currency accepted
|
||||
- [ ] VAT/tax handling
|
||||
- [ ] Local payment methods (SEPA, iDEAL, etc.)
|
||||
- [ ] Invoicing in local format
|
||||
|
||||
**Legal**
|
||||
|
||||
- [ ] GDPR compliance (EU markets)
|
||||
- [ ] PIPEDA compliance (Canada)
|
||||
- [ ] Local data protection laws
|
||||
- [ ] Terms of service localized
|
||||
- [ ] Privacy policy localized
|
||||
|
||||
**Sales**
|
||||
|
||||
- [ ] Local sales coverage (rep or agency)
|
||||
- [ ] Localized sales materials
|
||||
- [ ] Local pricing and quoting
|
||||
- [ ] Local references and case studies
|
||||
|
||||
**Support**
|
||||
|
||||
- [ ] Coverage during local business hours
|
||||
- [ ] Language support (phone, chat, email)
|
||||
- [ ] Localized documentation
|
||||
- [ ] Local SLA commitments
|
||||
|
||||
**Marketing**
|
||||
|
||||
- [ ] Localized campaigns
|
||||
- [ ] Local content (blog, guides)
|
||||
- [ ] Local social media presence
|
||||
- [ ] Local event participation
|
||||
|
||||
**Validation:** Native speaker review of ALL localized content before launch
|
||||
@@ -0,0 +1,333 @@
|
||||
# Launch Checklists
|
||||
|
||||
GTM launch playbooks for Tier 1, 2, and 3 product releases.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Launch Tier Definitions](#launch-tier-definitions)
|
||||
- [Tier 1 Major Launch](#tier-1-major-launch)
|
||||
- [Tier 2 Standard Launch](#tier-2-standard-launch)
|
||||
- [Tier 3 Minor Launch](#tier-3-minor-launch)
|
||||
- [Launch Metrics Dashboard](#launch-metrics-dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Launch Tier Definitions
|
||||
|
||||
| Tier | Scope | Prep Time | Budget | Audience |
|
||||
|------|-------|-----------|--------|----------|
|
||||
| 1 | New product, major feature | 6-8 weeks | $50-100k | All prospects + press |
|
||||
| 2 | Significant feature, integration | 3-4 weeks | $10-25k | Customers + select prospects |
|
||||
| 3 | Small feature, improvement | 1 week | <$5k | Existing customers |
|
||||
|
||||
**Tier Selection Criteria:**
|
||||
|
||||
```
|
||||
Tier 1 if ANY true:
|
||||
- [ ] Net-new product line
|
||||
- [ ] Revenue impact > $500k pipeline
|
||||
- [ ] Press coverage expected
|
||||
- [ ] Competitive response anticipated
|
||||
|
||||
Tier 2 if ANY true:
|
||||
- [ ] Major feature request (top 10 customer ask)
|
||||
- [ ] New integration with strategic partner
|
||||
- [ ] Pricing or packaging change
|
||||
|
||||
Tier 3 otherwise:
|
||||
- [ ] Bug fixes
|
||||
- [ ] UI improvements
|
||||
- [ ] Minor enhancements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 Major Launch
|
||||
|
||||
### Phase 1: Foundation (Weeks -8 to -5)
|
||||
|
||||
**Week -8: Kickoff**
|
||||
|
||||
- [ ] Schedule kickoff meeting (Product, Marketing, Sales, CS)
|
||||
- [ ] Define launch goals:
|
||||
- Pipeline target: $______
|
||||
- MQL target: ______
|
||||
- Press hits target: ______
|
||||
- Adoption target: ______% in 90 days
|
||||
- [ ] Assign roles (RACI matrix):
|
||||
- PMM: Launch lead, positioning, messaging
|
||||
- Product: Feature readiness, demo environment
|
||||
- Demand Gen: Campaigns, paid ads, email
|
||||
- Content: Blog posts, case studies, videos
|
||||
- Sales: Enablement, outbound campaign
|
||||
- [ ] Create project timeline in Asana/Monday/Notion
|
||||
- [ ] **Validation:** All stakeholders confirm goals and timeline
|
||||
|
||||
**Week -7: Strategy**
|
||||
|
||||
- [ ] Develop positioning and messaging (see positioning-frameworks.md)
|
||||
- [ ] Create GTM channel plan:
|
||||
- Owned: Email, blog, social, webinar
|
||||
- Paid: LinkedIn ads, Google ads
|
||||
- Earned: Press, influencers, partners
|
||||
- [ ] Define target segments (ICP, personas)
|
||||
- [ ] Allocate budget by channel
|
||||
- [ ] Draft press release (embargo date set)
|
||||
|
||||
**Week -6: Content**
|
||||
|
||||
- [ ] Build landing page (product page, demo request form)
|
||||
- [ ] Write blog post announcement
|
||||
- [ ] Create sales deck updates (5-10 new slides)
|
||||
- [ ] Design social media graphics (5+ variants)
|
||||
- [ ] Produce demo video (3-5 minutes)
|
||||
- [ ] Draft email sequences (announcement, nurture)
|
||||
|
||||
**Week -5: Enablement**
|
||||
|
||||
- [ ] Create sales battlecard (competitive positioning)
|
||||
- [ ] Write demo script (new feature walkthrough)
|
||||
- [ ] Build FAQ document (top 20 questions)
|
||||
- [ ] Develop objection handling guide
|
||||
- [ ] Schedule sales training session
|
||||
- [ ] Recruit beta customers for testimonials
|
||||
- [ ] **Validation:** Sales team can demo feature confidently
|
||||
|
||||
### Phase 2: Preparation (Weeks -4 to -1)
|
||||
|
||||
**Week -4: Launch Prep**
|
||||
|
||||
- [ ] Set up HubSpot campaign (UTMs, attribution)
|
||||
- [ ] Launch teaser campaign (social, email hints)
|
||||
- [ ] Pitch press and analysts (NDA briefings)
|
||||
- [ ] Create webinar registration page
|
||||
- [ ] Finalize partner co-marketing plans
|
||||
- [ ] QA all landing pages and forms
|
||||
|
||||
**Week -3: Ramp Up**
|
||||
|
||||
- [ ] Activate paid ads (LinkedIn, Google) at 50% budget
|
||||
- [ ] A/B test landing page headlines
|
||||
- [ ] Send pre-launch email to VIP customers
|
||||
- [ ] Conduct sales training (2-hour session)
|
||||
- [ ] Confirm webinar speakers and content
|
||||
- [ ] Prepare launch day runbook
|
||||
|
||||
**Week -2: Final Prep**
|
||||
|
||||
- [ ] Increase paid ad spend to 75%
|
||||
- [ ] Send webinar reminder emails
|
||||
- [ ] Finalize press embargo lift time
|
||||
- [ ] Complete dry run (website, forms, CRM workflow)
|
||||
- [ ] Create launch day social posts (scheduled)
|
||||
- [ ] Brief customer success team
|
||||
|
||||
**Week -1: Pre-Launch**
|
||||
|
||||
- [ ] Final approval on all assets
|
||||
- [ ] Send VIP preview to top 10 customers
|
||||
- [ ] Confirm press embargo release
|
||||
- [ ] Sales team ready (trained, quotas set)
|
||||
- [ ] CS team ready (docs updated, chat staffed)
|
||||
- [ ] Test all systems one final time
|
||||
- [ ] **Validation:** All checklist items green
|
||||
|
||||
### Phase 3: Launch (Weeks 1-4)
|
||||
|
||||
**Launch Day**
|
||||
|
||||
- [ ] Press release distribution (wire + direct pitch)
|
||||
- [ ] Email blast to full database
|
||||
- [ ] Social media posts (LinkedIn, Twitter, Facebook)
|
||||
- [ ] Paid ads at 100% budget
|
||||
- [ ] Sales outbound blitz (top 100 accounts)
|
||||
- [ ] In-app announcement to existing users
|
||||
- [ ] Monitor metrics every 2 hours:
|
||||
- Traffic, signups, demo requests
|
||||
- Press pickup, social engagement
|
||||
- Sales pipeline created
|
||||
|
||||
**Days 2-7**
|
||||
|
||||
- [ ] Daily metrics review (conversion rates, funnel)
|
||||
- [ ] A/B test optimizations based on data
|
||||
- [ ] Sales follow-up (<4 hour SLA on leads)
|
||||
- [ ] Respond to press and analyst inquiries
|
||||
- [ ] Host webinar (Day 3 or 4)
|
||||
- [ ] Post customer testimonials
|
||||
- [ ] Adjust paid ads (pause underperformers)
|
||||
|
||||
**Week 2-4**
|
||||
|
||||
- [ ] Publish post-launch blog content
|
||||
- [ ] Create customer case study from early adopters
|
||||
- [ ] Conduct win/loss interviews (5+ deals)
|
||||
- [ ] Optimize converting channels (+20% budget)
|
||||
- [ ] Pause non-converting channels
|
||||
- [ ] Weekly launch status report to executives
|
||||
- [ ] **Validation:** Pipeline on track to goal
|
||||
|
||||
### Phase 4: Post-Launch (Weeks 5-12)
|
||||
|
||||
**Month 2**
|
||||
|
||||
- [ ] Launch retrospective meeting
|
||||
- [ ] Document learnings (what worked, what didn't)
|
||||
- [ ] Scale winning channels
|
||||
- [ ] Expand to new segments if successful
|
||||
- [ ] Update positioning based on customer feedback
|
||||
- [ ] Plan sustaining campaigns
|
||||
|
||||
**Month 3**
|
||||
|
||||
- [ ] Final launch report (vs. goals)
|
||||
- [ ] Calculate ROI (pipeline / spend)
|
||||
- [ ] Publish additional case studies
|
||||
- [ ] Integrate learnings into next launch plan
|
||||
- [ ] Archive launch assets for reuse
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 Standard Launch
|
||||
|
||||
### Timeline: 4 Weeks
|
||||
|
||||
**Week -4 to -3: Preparation**
|
||||
|
||||
- [ ] Define feature and target audience
|
||||
- [ ] Create positioning and key messages
|
||||
- [ ] Build landing page or product page update
|
||||
- [ ] Write blog post announcement
|
||||
- [ ] Update sales deck (2-3 slides)
|
||||
- [ ] Create email announcement
|
||||
- [ ] Brief sales team (30-min call)
|
||||
|
||||
**Week -2 to -1: Setup**
|
||||
|
||||
- [ ] Set up HubSpot campaign tracking
|
||||
- [ ] Schedule social posts
|
||||
- [ ] Set up paid ads (limited budget)
|
||||
- [ ] QA landing pages and forms
|
||||
- [ ] Notify customer success team
|
||||
|
||||
**Launch Week**
|
||||
|
||||
- [ ] Send email announcement
|
||||
- [ ] Publish blog post
|
||||
- [ ] Post on social media
|
||||
- [ ] In-app notification to users
|
||||
- [ ] Sales mention in active deals
|
||||
- [ ] Monitor initial metrics
|
||||
|
||||
**Week +1 to +2: Follow-up**
|
||||
|
||||
- [ ] Analyze launch metrics
|
||||
- [ ] Optimize based on data
|
||||
- [ ] Collect customer feedback
|
||||
- [ ] Document learnings
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 Minor Launch
|
||||
|
||||
### Timeline: 1 Week
|
||||
|
||||
**Day -5 to -3: Prep**
|
||||
|
||||
- [ ] Write changelog entry
|
||||
- [ ] Update support documentation
|
||||
- [ ] Create in-app notification copy
|
||||
- [ ] Brief CS team
|
||||
|
||||
**Day -2 to -1: Review**
|
||||
|
||||
- [ ] QA feature in staging
|
||||
- [ ] Approve changelog copy
|
||||
- [ ] Schedule in-app notification
|
||||
|
||||
**Launch Day**
|
||||
|
||||
- [ ] Deploy feature
|
||||
- [ ] Trigger in-app notification
|
||||
- [ ] Publish changelog
|
||||
- [ ] Update support docs (if needed)
|
||||
|
||||
**Day +1 to +3: Monitor**
|
||||
|
||||
- [ ] Check for support tickets
|
||||
- [ ] Monitor feature adoption
|
||||
- [ ] Address any issues
|
||||
|
||||
---
|
||||
|
||||
## Launch Metrics Dashboard
|
||||
|
||||
### Leading Indicators (Track Daily)
|
||||
|
||||
| Metric | Target | Day 1 | Day 3 | Day 7 |
|
||||
|--------|--------|-------|-------|-------|
|
||||
| Landing page visitors | 5,000 | | | |
|
||||
| Demo requests | 100 | | | |
|
||||
| Free trial signups | 200 | | | |
|
||||
| MQLs generated | 150 | | | |
|
||||
| Pipeline created ($) | $500k | | | |
|
||||
|
||||
### Lagging Indicators (Track Weekly)
|
||||
|
||||
| Metric | Target | Week 1 | Week 2 | Week 4 |
|
||||
|--------|--------|--------|--------|--------|
|
||||
| SQLs generated | 30 | | | |
|
||||
| Demos completed | 50 | | | |
|
||||
| Deals closed (#) | 5 | | | |
|
||||
| Revenue ($) | $100k | | | |
|
||||
| Feature adoption (%) | 40% | | | |
|
||||
|
||||
### Channel Performance
|
||||
|
||||
| Channel | Spend | MQLs | CPL | Pipeline | ROI |
|
||||
|---------|-------|------|-----|----------|-----|
|
||||
| LinkedIn Ads | $10k | | | | |
|
||||
| Google Ads | $5k | | | | |
|
||||
| Email | $0 | | | | |
|
||||
| Organic | $0 | | | | |
|
||||
| Webinar | $2k | | | | |
|
||||
| **Total** | **$17k** | | | | |
|
||||
|
||||
### Post-Launch Report Template
|
||||
|
||||
```
|
||||
LAUNCH: [Product/Feature Name]
|
||||
DATE: [Launch Date]
|
||||
OWNER: [PMM Name]
|
||||
|
||||
EXECUTIVE SUMMARY:
|
||||
- Goal: $500k pipeline in 30 days
|
||||
- Actual: $[X] pipeline (X% of goal)
|
||||
- Status: ✅ On Track / ⚠️ Behind / ❌ Missed
|
||||
|
||||
KEY RESULTS:
|
||||
| Metric | Goal | Actual | % of Goal |
|
||||
|--------------|---------|---------|-----------|
|
||||
| MQLs | 150 | | |
|
||||
| SQLs | 30 | | |
|
||||
| Pipeline | $500k | | |
|
||||
| Feature Adoption | 40% | | |
|
||||
|
||||
TOP PERFORMING:
|
||||
1. [Channel/Tactic] - [Result]
|
||||
2. [Channel/Tactic] - [Result]
|
||||
|
||||
UNDERPERFORMING:
|
||||
1. [Channel/Tactic] - [Result] - [Action taken]
|
||||
|
||||
LEARNINGS:
|
||||
1. [What worked and why]
|
||||
2. [What didn't work and why]
|
||||
3. [What we'd do differently]
|
||||
|
||||
NEXT STEPS:
|
||||
1. [Action item] - Owner - Due date
|
||||
2. [Action item] - Owner - Due date
|
||||
```
|
||||
@@ -0,0 +1,446 @@
|
||||
# Messaging Templates
|
||||
|
||||
Ready-to-use messaging frameworks for different personas and contexts.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Value Proposition Templates](#value-proposition-templates)
|
||||
- [Persona-Specific Messaging](#persona-specific-messaging)
|
||||
- [Competitive Messaging](#competitive-messaging)
|
||||
- [Channel-Specific Copy](#channel-specific-copy)
|
||||
- [Objection Handling Scripts](#objection-handling-scripts)
|
||||
|
||||
---
|
||||
|
||||
## Value Proposition Templates
|
||||
|
||||
### One-Liner Formula
|
||||
|
||||
Template: `[Product] helps [Target Customer] [Achieve Goal] by [Unique Approach]`
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
B2B SaaS:
|
||||
"Acme helps mid-market SaaS teams ship 2x faster by automating
|
||||
project workflows with AI."
|
||||
|
||||
Enterprise:
|
||||
"Acme helps Fortune 500 companies reduce operational costs by 40%
|
||||
through intelligent process automation."
|
||||
|
||||
SMB:
|
||||
"Acme helps small businesses save 10 hours per week by automating
|
||||
their daily tasks."
|
||||
```
|
||||
|
||||
### Elevator Pitch (30 Seconds)
|
||||
|
||||
Template:
|
||||
```
|
||||
You know how [target customer] struggles with [pain point]?
|
||||
|
||||
[Product] is a [category] that [key differentiator].
|
||||
|
||||
Unlike [alternatives], we [unique value].
|
||||
|
||||
Our customers see [specific outcome] within [timeframe].
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
You know how engineering teams struggle with slow code reviews
|
||||
that delay releases?
|
||||
|
||||
Acme is an AI code review platform that catches bugs before
|
||||
they reach production.
|
||||
|
||||
Unlike manual reviews, we analyze every PR in under 2 minutes
|
||||
with 95% accuracy.
|
||||
|
||||
Our customers ship 40% faster within their first month.
|
||||
```
|
||||
|
||||
### Messaging Hierarchy
|
||||
|
||||
```
|
||||
LEVEL 1: HEADLINE (5-7 words)
|
||||
"Ship faster with AI-powered automation"
|
||||
|
||||
LEVEL 2: SUBHEAD (1 sentence)
|
||||
"Acme automates your workflows so your team can focus on what matters."
|
||||
|
||||
LEVEL 3: KEY BENEFITS (3-4 bullets)
|
||||
• Save 10+ hours per week on manual tasks
|
||||
• Reduce errors by 80% with AI validation
|
||||
• Deploy changes 3x faster with automated testing
|
||||
• Scale operations without adding headcount
|
||||
|
||||
LEVEL 4: FEATURES → VALUE
|
||||
• AI Automation → Eliminates repetitive work → Save $50k/year
|
||||
• Real-time Sync → No version conflicts → 50% fewer errors
|
||||
• Integrations → Connect existing tools → 2-hour setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Persona-Specific Messaging
|
||||
|
||||
### Economic Buyer (VP/Director/C-Level)
|
||||
|
||||
**Primary concerns:** ROI, business outcomes, risk mitigation
|
||||
|
||||
**Messaging principles:**
|
||||
- Lead with business impact ($, %, time)
|
||||
- Show ROI within 6-12 months
|
||||
- Reference similar companies
|
||||
- Address risk (security, implementation)
|
||||
|
||||
**Template:**
|
||||
|
||||
```
|
||||
HEADLINE: [Business outcome] in [timeframe]
|
||||
|
||||
OPENING:
|
||||
"[Role at similar company] was spending [hours/dollars] on [problem].
|
||||
After implementing [Product], they achieved [specific result]."
|
||||
|
||||
KEY POINTS:
|
||||
• [Metric] improvement in [area] (e.g., "40% reduction in manual work")
|
||||
• ROI: [X]x return within [timeframe]
|
||||
• Implementation: [timeframe] with [level] of effort
|
||||
• Risk: [How you mitigate concerns]
|
||||
|
||||
CTA: "See how [similar company] achieved [result] →"
|
||||
```
|
||||
|
||||
**Example email:**
|
||||
|
||||
```
|
||||
Subject: How Stripe reduced deployment time by 60%
|
||||
|
||||
Hi [Name],
|
||||
|
||||
The VP of Engineering at a company similar to yours was spending
|
||||
40 hours per week on code review bottlenecks.
|
||||
|
||||
After implementing Acme, they:
|
||||
• Reduced review time by 60%
|
||||
• Caught 3x more bugs before production
|
||||
• Shipped new features 2 weeks faster
|
||||
|
||||
Would a 15-minute call to explore if similar results are possible
|
||||
for [Company] make sense?
|
||||
```
|
||||
|
||||
### Technical Buyer (Engineer/Architect)
|
||||
|
||||
**Primary concerns:** Technical fit, security, integration, vendor lock-in
|
||||
|
||||
**Messaging principles:**
|
||||
- Lead with technical capabilities
|
||||
- Show architecture and security details
|
||||
- Demonstrate easy integration
|
||||
- Provide technical documentation
|
||||
|
||||
**Template:**
|
||||
|
||||
```
|
||||
HEADLINE: [Technical capability] for [their stack]
|
||||
|
||||
OPENING:
|
||||
"Built for [their technology environment] with [key technical feature]."
|
||||
|
||||
KEY POINTS:
|
||||
• Architecture: [how it works technically]
|
||||
• Security: [certifications, compliance, encryption]
|
||||
• Integration: [specific integrations with their tools]
|
||||
• Performance: [benchmarks, latency, uptime]
|
||||
|
||||
CTA: "Read the technical whitepaper →" or "See the API docs →"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Subject: SOC 2 Type II compliant with 99.99% uptime
|
||||
|
||||
Hi [Name],
|
||||
|
||||
I noticed [Company] uses Kubernetes for container orchestration.
|
||||
|
||||
Acme integrates natively with K8s with:
|
||||
• Single-line Helm chart deployment
|
||||
• mTLS encryption for all traffic
|
||||
• SOC 2 Type II + GDPR compliant
|
||||
• 99.99% uptime SLA with $10k credit guarantee
|
||||
|
||||
Here's our architecture diagram: [link]
|
||||
|
||||
Worth a quick technical review?
|
||||
```
|
||||
|
||||
### End User (Manager/Individual Contributor)
|
||||
|
||||
**Primary concerns:** Ease of use, daily workflow, learning curve
|
||||
|
||||
**Messaging principles:**
|
||||
- Lead with time savings
|
||||
- Show product in action (demo, screenshots)
|
||||
- Emphasize simplicity
|
||||
- Include peer testimonials
|
||||
|
||||
**Template:**
|
||||
|
||||
```
|
||||
HEADLINE: [Daily benefit] in [time to value]
|
||||
|
||||
OPENING:
|
||||
"Imagine [desired outcome] without [pain point]."
|
||||
|
||||
KEY POINTS:
|
||||
• Get started in [timeframe] (no training required)
|
||||
• Save [hours] every [timeframe]
|
||||
• [Feature] makes [task] effortless
|
||||
• Loved by [peer companies/roles]
|
||||
|
||||
CTA: "Try free for 14 days →"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Subject: Spend less time in meetings, more time building
|
||||
|
||||
Hi [Name],
|
||||
|
||||
What if your weekly status meetings could run themselves?
|
||||
|
||||
Acme automatically:
|
||||
• Collects updates from your team (no nagging)
|
||||
• Creates visual progress reports (no spreadsheets)
|
||||
• Flags blockers before they become problems
|
||||
|
||||
Teams like [Company A] and [Company B] love it.
|
||||
|
||||
Start your free trial: [link]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Competitive Messaging
|
||||
|
||||
### "Why Us vs. Competitor A" Framework
|
||||
|
||||
```
|
||||
OPENING (acknowledge competition):
|
||||
"Both [Product] and [Competitor A] help teams with [general category].
|
||||
Here's what sets us apart:"
|
||||
|
||||
DIFFERENTIATORS (3-4 key points):
|
||||
|
||||
1. [Your advantage] vs. [Their limitation]
|
||||
"Our AI catches 95% of bugs vs. their rule-based 60% coverage"
|
||||
|
||||
2. [Your advantage] vs. [Their limitation]
|
||||
"Get started in 2 hours vs. their 2-week implementation"
|
||||
|
||||
3. [Your advantage] vs. [Their limitation]
|
||||
"$50/user vs. their $150/user at scale"
|
||||
|
||||
PROOF POINT:
|
||||
"[Customer] switched from [Competitor A] to us and saw [result]"
|
||||
|
||||
CTA:
|
||||
"See a side-by-side comparison →"
|
||||
```
|
||||
|
||||
### Competitive Positioning Statements
|
||||
|
||||
**When they're the market leader:**
|
||||
|
||||
```
|
||||
"[Competitor] built the category, but it was designed for [old paradigm].
|
||||
[Product] is purpose-built for [new reality] with [key differentiators]."
|
||||
```
|
||||
|
||||
**When they're cheaper:**
|
||||
|
||||
```
|
||||
"[Competitor] costs less upfront, but teams spend [X hours] working
|
||||
around limitations. [Product] pays for itself in [timeframe] through
|
||||
[specific efficiency gains]."
|
||||
```
|
||||
|
||||
**When they have more features:**
|
||||
|
||||
```
|
||||
"[Competitor] tries to do everything. [Product] focuses on doing
|
||||
[core use case] exceptionally well. Our customers tell us they only
|
||||
use 20% of [Competitor's] features anyway."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channel-Specific Copy
|
||||
|
||||
### Landing Page
|
||||
|
||||
**Above the fold:**
|
||||
```
|
||||
[HEADLINE - 5-7 words, benefit-focused]
|
||||
Ship faster with AI-powered automation
|
||||
|
||||
[SUBHEAD - 1 sentence expanding on value]
|
||||
Acme automates your workflows so your team can focus on what matters.
|
||||
|
||||
[CTA - Action-oriented]
|
||||
Start Free Trial | Book Demo
|
||||
```
|
||||
|
||||
**Social proof bar:**
|
||||
```
|
||||
Trusted by 5,000+ teams including [Logo] [Logo] [Logo] [Logo]
|
||||
```
|
||||
|
||||
### Email Subject Lines
|
||||
|
||||
**High performers:**
|
||||
- "How [Similar Company] achieved [result]"
|
||||
- "[Name], quick question about [their challenge]"
|
||||
- "Re: [topic they care about]" (for follow-ups)
|
||||
- "[Specific number]% improvement in [metric]"
|
||||
|
||||
**Avoid:**
|
||||
- "Quick sync?"
|
||||
- "Following up..."
|
||||
- "Just checking in"
|
||||
- ALL CAPS or excessive punctuation!!!
|
||||
|
||||
### LinkedIn Ads
|
||||
|
||||
**Format: Single image or carousel**
|
||||
|
||||
```
|
||||
HEADLINE (70 chars max):
|
||||
"Cut code review time by 60%"
|
||||
|
||||
BODY (150 chars recommended):
|
||||
"AI-powered code reviews that catch bugs before production.
|
||||
Trusted by engineering teams at Stripe and Shopify.
|
||||
Try free →"
|
||||
|
||||
CTA: Learn More / Try Free / Get Demo
|
||||
```
|
||||
|
||||
### Google Ads
|
||||
|
||||
**Search ad format:**
|
||||
|
||||
```
|
||||
Headline 1 (30 chars): AI Code Review Platform
|
||||
Headline 2 (30 chars): Ship 40% Faster
|
||||
Headline 3 (30 chars): Free 14-Day Trial
|
||||
|
||||
Description (90 chars):
|
||||
Catch bugs before production. Trusted by 5,000+ teams.
|
||||
Start your free trial today.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Objection Handling Scripts
|
||||
|
||||
### Price Objection
|
||||
|
||||
**"It's too expensive"**
|
||||
|
||||
```
|
||||
ACKNOWLEDGE: "I understand budget is a concern."
|
||||
|
||||
REFRAME: "Let me share how our customers think about it...
|
||||
[Customer] was spending [X hours/dollars] on [problem] every month.
|
||||
After implementing [Product], they saved [Y hours/dollars], paying
|
||||
for the solution in [timeframe]."
|
||||
|
||||
QUESTION: "What would it be worth to your team to [achieve outcome]?"
|
||||
|
||||
ALTERNATIVE: "We also offer [smaller plan/annual discount] that might
|
||||
work for your current budget. Would that help?"
|
||||
```
|
||||
|
||||
### Competitor Objection
|
||||
|
||||
**"We're looking at [Competitor A] too"**
|
||||
|
||||
```
|
||||
ACKNOWLEDGE: "That's smart to evaluate options. [Competitor A] is
|
||||
a solid product."
|
||||
|
||||
DIFFERENTIATE: "The main differences customers tell us about:
|
||||
1. [Your advantage] - [Competitor] doesn't offer this
|
||||
2. [Your advantage] - Their approach is [different/older]
|
||||
3. [Price/support/speed] - We're typically [X] better here"
|
||||
|
||||
PROOF: "[Customer] evaluated both and chose us because [reason]."
|
||||
|
||||
QUESTION: "What are the 2-3 things that matter most to you in
|
||||
this decision?"
|
||||
```
|
||||
|
||||
### Timing Objection
|
||||
|
||||
**"Not the right time"**
|
||||
|
||||
```
|
||||
ACKNOWLEDGE: "I completely understand. Timing is everything."
|
||||
|
||||
EXPLORE: "Out of curiosity, what would need to change for this
|
||||
to become a priority?"
|
||||
|
||||
FUTURE: "Would it make sense to schedule a brief call in [timeframe]
|
||||
to revisit? I can share relevant updates without any pressure."
|
||||
|
||||
VALUE ADD: "In the meantime, I'll send over [relevant content] that
|
||||
might be useful for when you're ready."
|
||||
```
|
||||
|
||||
### Authority Objection
|
||||
|
||||
**"I need to check with my team/boss"**
|
||||
|
||||
```
|
||||
ACKNOWLEDGE: "Of course, that makes sense."
|
||||
|
||||
SUPPORT: "What information would be most helpful for that conversation?
|
||||
I can put together a one-pager with key points."
|
||||
|
||||
OFFER: "Would it help if I joined a brief call with [stakeholder]
|
||||
to answer any technical/business questions directly?"
|
||||
|
||||
TIMELINE: "When do you think you'll have that conversation?
|
||||
I can follow up with any additional materials beforehand."
|
||||
```
|
||||
|
||||
### Technical Objection
|
||||
|
||||
**"Will this integrate with our stack?"**
|
||||
|
||||
```
|
||||
ACKNOWLEDGE: "Great question - integration is critical."
|
||||
|
||||
CONFIRM: "What are the main tools you need to connect with?
|
||||
[Listen and take notes]"
|
||||
|
||||
ANSWER: "We have native integrations with [tools]. For [tool],
|
||||
we use [API/webhook/Zapier]. Here's our integration docs: [link]"
|
||||
|
||||
PROOF: "[Similar company] uses a similar stack and got integrated
|
||||
in [timeframe]."
|
||||
|
||||
DEMO: "Want me to show you exactly how the integration works
|
||||
in a quick demo?"
|
||||
```
|
||||
@@ -0,0 +1,279 @@
|
||||
# Positioning Frameworks
|
||||
|
||||
Strategic positioning methodologies for B2B SaaS products.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [April Dunford Positioning](#april-dunford-positioning)
|
||||
- [Geoffrey Moore Positioning](#geoffrey-moore-positioning)
|
||||
- [Positioning Validation](#positioning-validation)
|
||||
- [Competitive Positioning Map](#competitive-positioning-map)
|
||||
|
||||
---
|
||||
|
||||
## April Dunford Positioning
|
||||
|
||||
### The 5-Step Process
|
||||
|
||||
Execute positioning using April Dunford's "Obviously Awesome" methodology:
|
||||
|
||||
1. List competitive alternatives (what customers would use instead)
|
||||
2. Isolate unique attributes (features only you have)
|
||||
3. Map attributes to value (why each attribute matters)
|
||||
4. Define best-fit customers (who cares most about this value)
|
||||
5. Choose market category (where you compete)
|
||||
6. **Validation:** Best-fit customers articulate your value unprompted
|
||||
|
||||
### Step 1: Competitive Alternatives
|
||||
|
||||
Document what customers do without your product:
|
||||
|
||||
| Alternative Type | Examples | How They Solve It |
|
||||
|------------------|----------|-------------------|
|
||||
| Direct competitor | Competitor A, B | Same category, different approach |
|
||||
| Adjacent solution | Spreadsheets, email | Manual workaround |
|
||||
| Build in-house | Custom development | Internal solution |
|
||||
| Do nothing | Ignore problem | Accept status quo |
|
||||
|
||||
**Interview Questions:**
|
||||
- "Before using us, how did you handle this?"
|
||||
- "What alternatives did you evaluate?"
|
||||
- "What would you switch to if we disappeared?"
|
||||
|
||||
### Step 2: Unique Attributes
|
||||
|
||||
Identify capabilities competitors lack:
|
||||
|
||||
```
|
||||
Attribute Audit:
|
||||
1. Feature: [Real-time collaboration]
|
||||
- Competitor A: No (async only)
|
||||
- Competitor B: Partial (limited to 5 users)
|
||||
- You: Yes (unlimited users, 50ms sync)
|
||||
→ Unique: Yes
|
||||
|
||||
2. Feature: [AI automation]
|
||||
- Competitor A: No
|
||||
- Competitor B: No
|
||||
- You: Yes (3 AI models)
|
||||
→ Unique: Yes
|
||||
|
||||
3. Feature: [Integrations]
|
||||
- Competitor A: 500+
|
||||
- Competitor B: 200+
|
||||
- You: 100
|
||||
→ Unique: No (table stakes)
|
||||
```
|
||||
|
||||
### Step 3: Attribute-Value Mapping
|
||||
|
||||
Connect features to business outcomes:
|
||||
|
||||
| Attribute | Value Enabled | Customer Outcome |
|
||||
|-----------|--------------|------------------|
|
||||
| Real-time sync | No version conflicts | 50% fewer errors |
|
||||
| AI automation | Eliminates manual work | Save 10 hrs/week |
|
||||
| One-click deploy | Faster releases | Ship 2x faster |
|
||||
|
||||
**Value Statement Formula:**
|
||||
`[Feature] enables [Value] so customers achieve [Outcome]`
|
||||
|
||||
### Step 4: Best-Fit Customers
|
||||
|
||||
Define who values your unique attributes most:
|
||||
|
||||
```
|
||||
Best-Fit Profile:
|
||||
- Company size: 200-2000 employees
|
||||
- Industry: SaaS, Professional Services
|
||||
- Pain: Distributed teams, collaboration bottlenecks
|
||||
- Evidence:
|
||||
- Fastest sales cycles (45 days vs. 75 avg)
|
||||
- Lowest churn (3% vs. 8% avg)
|
||||
- Highest NPS (65 vs. 45 avg)
|
||||
```
|
||||
|
||||
### Step 5: Market Category
|
||||
|
||||
Choose competitive frame:
|
||||
|
||||
| Strategy | When to Use | Risk Level |
|
||||
|----------|-------------|------------|
|
||||
| Head-to-head | Strong product, big budget | Medium |
|
||||
| Niche domination | Unique for segment | Low |
|
||||
| Category creation | True innovation, deep pockets | High |
|
||||
|
||||
**Decision Framework:**
|
||||
- Can you win head-to-head? → Head-to-head
|
||||
- Can you dominate a niche? → Niche
|
||||
- Is the market undefined? → Category creation
|
||||
|
||||
---
|
||||
|
||||
## Geoffrey Moore Positioning
|
||||
|
||||
### Crossing the Chasm Framework
|
||||
|
||||
Position for technology adoption lifecycle:
|
||||
|
||||
```
|
||||
Technology Adoption Curve:
|
||||
Innovators (2.5%) → Early Adopters (13.5%) → Early Majority (34%)
|
||||
↑
|
||||
THE CHASM
|
||||
```
|
||||
|
||||
### Positioning Statement Template
|
||||
|
||||
```
|
||||
FOR [target customer]
|
||||
WHO [statement of need or opportunity]
|
||||
THE [product name] IS A [product category]
|
||||
THAT [key benefit/reason to buy]
|
||||
UNLIKE [primary competitive alternative]
|
||||
OUR PRODUCT [primary differentiation]
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
FOR mid-market SaaS companies with distributed engineering teams
|
||||
WHO struggle with coordination across time zones
|
||||
THE Acme Platform IS A real-time collaboration workspace
|
||||
THAT eliminates version conflicts and communication delays
|
||||
UNLIKE Slack and email which create information silos
|
||||
OUR PRODUCT provides unified project context with AI-powered summaries
|
||||
```
|
||||
|
||||
### Whole Product Concept
|
||||
|
||||
Define complete solution for target segment:
|
||||
|
||||
| Layer | Components | Your Coverage |
|
||||
|-------|------------|---------------|
|
||||
| Generic | Core product | 100% |
|
||||
| Expected | Basic integrations, support | 90% |
|
||||
| Augmented | Training, consulting, custom work | 60% |
|
||||
| Potential | Future roadmap, ecosystem | 30% |
|
||||
|
||||
**Gap Analysis:**
|
||||
- What's missing for complete solution?
|
||||
- Which partners can fill gaps?
|
||||
- What must you build vs. buy vs. partner?
|
||||
|
||||
---
|
||||
|
||||
## Positioning Validation
|
||||
|
||||
### Customer Interview Protocol
|
||||
|
||||
Validate positioning with target customers:
|
||||
|
||||
1. Schedule 15-20 minute calls with 10+ target customers
|
||||
2. Ask open-ended questions (no leading)
|
||||
3. Document exact language used
|
||||
4. Look for patterns across interviews
|
||||
5. **Validation:** 7+ of 10 describe value similarly
|
||||
|
||||
**Interview Script:**
|
||||
|
||||
```
|
||||
Opening (2 min):
|
||||
"Thanks for your time. I want to understand how you think about
|
||||
[product category] and your experience with our product."
|
||||
|
||||
Questions (10 min):
|
||||
1. "How would you describe [Product] to a colleague?"
|
||||
2. "What problem does [Product] solve for you?"
|
||||
3. "What alternatives did you consider?"
|
||||
4. "Why did you choose us over [alternative]?"
|
||||
5. "What would make you stop using us?"
|
||||
|
||||
Closing (3 min):
|
||||
"Is there anything else you'd like to share?"
|
||||
```
|
||||
|
||||
### Quantitative Validation
|
||||
|
||||
Test messaging through A/B experiments:
|
||||
|
||||
| Test | Control | Variant | Winner Criteria |
|
||||
|------|---------|---------|-----------------|
|
||||
| Landing page headline | Old positioning | New positioning | +20% conversion |
|
||||
| Ad copy | Feature-focused | Value-focused | +15% CTR |
|
||||
| Email subject | Generic | Personalized | +25% open rate |
|
||||
|
||||
**Sample Size Calculator:**
|
||||
- Baseline conversion: 3%
|
||||
- Minimum detectable effect: 20% relative lift
|
||||
- Statistical power: 80%
|
||||
- Required sample: ~2,500 per variant
|
||||
|
||||
---
|
||||
|
||||
## Competitive Positioning Map
|
||||
|
||||
### 2x2 Matrix Construction
|
||||
|
||||
Create visual positioning map:
|
||||
|
||||
```
|
||||
HIGH PRICE
|
||||
│
|
||||
Enterprise │ Premium
|
||||
(Salesforce) │ (You?)
|
||||
│
|
||||
────────────────────┼──────────────────
|
||||
LOW │ HIGH
|
||||
EASE OF USE │ EASE OF USE
|
||||
│
|
||||
Legacy │ Self-Serve
|
||||
(Oracle) │ (Notion)
|
||||
│
|
||||
LOW PRICE
|
||||
```
|
||||
|
||||
### Axis Selection
|
||||
|
||||
Choose dimensions that highlight your advantage:
|
||||
|
||||
| Good Axes | Why |
|
||||
|-----------|-----|
|
||||
| Ease of use vs. Power | If you're easiest to use |
|
||||
| Speed vs. Accuracy | If you're fastest |
|
||||
| Price vs. Features | If you're best value |
|
||||
| Specialization vs. Breadth | If you own a niche |
|
||||
|
||||
| Bad Axes | Why |
|
||||
|----------|-----|
|
||||
| Quality vs. Price | Everyone claims quality |
|
||||
| Innovation vs. Stability | Subjective, hard to prove |
|
||||
| Customer vs. Product focus | Not differentiating |
|
||||
|
||||
### Positioning Map Template
|
||||
|
||||
```
|
||||
Market Category: [Your Category]
|
||||
Date: [Month Year]
|
||||
|
||||
Axes:
|
||||
- X-axis: [Dimension 1] (Low → High)
|
||||
- Y-axis: [Dimension 2] (Low → High)
|
||||
|
||||
Quadrants:
|
||||
- Top-left: [Quadrant description]
|
||||
- Top-right: [Quadrant description] ← Your target
|
||||
- Bottom-left: [Quadrant description]
|
||||
- Bottom-right: [Quadrant description]
|
||||
|
||||
Competitors:
|
||||
1. [Competitor A]: Position (X, Y), Why
|
||||
2. [Competitor B]: Position (X, Y), Why
|
||||
3. [You]: Position (X, Y), Why you win
|
||||
|
||||
Strategic Implications:
|
||||
- Attack: [How to position against Competitor A]
|
||||
- Defend: [How to protect against Competitor B]
|
||||
- Differentiate: [Your unique positioning claim]
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: nano-banana-pro
|
||||
description: Generate/edit images with Nano Banana Pro (Gemini 3 Pro Image). Use for image create/modify requests incl. edits. Supports text-to-image + image-to-image; 1K/2K/4K; use --input-image.
|
||||
---
|
||||
|
||||
# Nano Banana Pro Image Generation & Editing
|
||||
|
||||
Generate new images or edit existing ones using Google's Nano Banana Pro API (Gemini 3 Pro Image).
|
||||
|
||||
## Usage
|
||||
|
||||
Run the script using absolute path (do NOT cd to skill directory first):
|
||||
|
||||
**Generate new image:**
|
||||
```bash
|
||||
uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "your image description" --filename "output-name.png" [--resolution 1K|2K|4K] [--api-key KEY]
|
||||
```
|
||||
|
||||
**Edit existing image:**
|
||||
```bash
|
||||
uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "editing instructions" --filename "output-name.png" --input-image "path/to/input.png" [--resolution 1K|2K|4K] [--api-key KEY]
|
||||
```
|
||||
|
||||
**Important:** Always run from the user's current working directory so images are saved where the user is working, not in the skill directory.
|
||||
|
||||
## Default Workflow (draft → iterate → final)
|
||||
|
||||
Goal: fast iteration without burning time on 4K until the prompt is correct.
|
||||
|
||||
- Draft (1K): quick feedback loop
|
||||
- `uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "<draft prompt>" --filename "yyyy-mm-dd-hh-mm-ss-draft.png" --resolution 1K`
|
||||
- Iterate: adjust prompt in small diffs; keep filename new per run
|
||||
- If editing: keep the same `--input-image` for every iteration until you’re happy.
|
||||
- Final (4K): only when prompt is locked
|
||||
- `uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "<final prompt>" --filename "yyyy-mm-dd-hh-mm-ss-final.png" --resolution 4K`
|
||||
|
||||
## Resolution Options
|
||||
|
||||
The Gemini 3 Pro Image API supports three resolutions (uppercase K required):
|
||||
|
||||
- **1K** (default) - ~1024px resolution
|
||||
- **2K** - ~2048px resolution
|
||||
- **4K** - ~4096px resolution
|
||||
|
||||
Map user requests to API parameters:
|
||||
- No mention of resolution → `1K`
|
||||
- "low resolution", "1080", "1080p", "1K" → `1K`
|
||||
- "2K", "2048", "normal", "medium resolution" → `2K`
|
||||
- "high resolution", "high-res", "hi-res", "4K", "ultra" → `4K`
|
||||
|
||||
## API Key
|
||||
|
||||
The script checks for API key in this order:
|
||||
1. `--api-key` argument (use if user provided key in chat)
|
||||
2. `GEMINI_API_KEY` environment variable
|
||||
|
||||
If neither is available, the script exits with an error message.
|
||||
|
||||
## Preflight + Common Failures (fast fixes)
|
||||
|
||||
- Preflight:
|
||||
- `command -v uv` (must exist)
|
||||
- `test -n \"$GEMINI_API_KEY\"` (or pass `--api-key`)
|
||||
- If editing: `test -f \"path/to/input.png\"`
|
||||
|
||||
- Common failures:
|
||||
- `Error: No API key provided.` → set `GEMINI_API_KEY` or pass `--api-key`
|
||||
- `Error loading input image:` → wrong path / unreadable file; verify `--input-image` points to a real image
|
||||
- “quota/permission/403” style API errors → wrong key, no access, or quota exceeded; try a different key/account
|
||||
|
||||
## Filename Generation
|
||||
|
||||
Generate filenames with the pattern: `yyyy-mm-dd-hh-mm-ss-name.png`
|
||||
|
||||
**Format:** `{timestamp}-{descriptive-name}.png`
|
||||
- Timestamp: Current date/time in format `yyyy-mm-dd-hh-mm-ss` (24-hour format)
|
||||
- Name: Descriptive lowercase text with hyphens
|
||||
- Keep the descriptive part concise (1-5 words typically)
|
||||
- Use context from user's prompt or conversation
|
||||
- If unclear, use random identifier (e.g., `x9k2`, `a7b3`)
|
||||
|
||||
Examples:
|
||||
- Prompt "A serene Japanese garden" → `2025-11-23-14-23-05-japanese-garden.png`
|
||||
- Prompt "sunset over mountains" → `2025-11-23-15-30-12-sunset-mountains.png`
|
||||
- Prompt "create an image of a robot" → `2025-11-23-16-45-33-robot.png`
|
||||
- Unclear context → `2025-11-23-17-12-48-x9k2.png`
|
||||
|
||||
## Image Editing
|
||||
|
||||
When the user wants to modify an existing image:
|
||||
1. Check if they provide an image path or reference an image in the current directory
|
||||
2. Use `--input-image` parameter with the path to the image
|
||||
3. The prompt should contain editing instructions (e.g., "make the sky more dramatic", "remove the person", "change to cartoon style")
|
||||
4. Common editing tasks: add/remove elements, change style, adjust colors, blur background, etc.
|
||||
|
||||
## Prompt Handling
|
||||
|
||||
**For generation:** Pass user's image description as-is to `--prompt`. Only rework if clearly insufficient.
|
||||
|
||||
**For editing:** Pass editing instructions in `--prompt` (e.g., "add a rainbow in the sky", "make it look like a watercolor painting")
|
||||
|
||||
Preserve user's creative intent in both cases.
|
||||
|
||||
## Prompt Templates (high hit-rate)
|
||||
|
||||
Use templates when the user is vague or when edits must be precise.
|
||||
|
||||
- Generation template:
|
||||
- “Create an image of: <subject>. Style: <style>. Composition: <camera/shot>. Lighting: <lighting>. Background: <background>. Color palette: <palette>. Avoid: <list>.”
|
||||
|
||||
- Editing template (preserve everything else):
|
||||
- “Change ONLY: <single change>. Keep identical: subject, composition/crop, pose, lighting, color palette, background, text, and overall style. Do not add new objects. If text exists, keep it unchanged.”
|
||||
|
||||
## Output
|
||||
|
||||
- Saves PNG to current directory (or specified path if filename includes directory)
|
||||
- Script outputs the full path to the generated image
|
||||
- **Do not read the image back** - just inform the user of the saved path
|
||||
|
||||
## Examples
|
||||
|
||||
**Generate new image:**
|
||||
```bash
|
||||
uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "A serene Japanese garden with cherry blossoms" --filename "2025-11-23-14-23-05-japanese-garden.png" --resolution 4K
|
||||
```
|
||||
|
||||
**Edit existing image:**
|
||||
```bash
|
||||
uv run ~/.codex/skills/nano-banana-pro/scripts/generate_image.py --prompt "make the sky more dramatic with storm clouds" --filename "2025-11-23-14-25-30-dramatic-sky.png" --input-image "original-photo.jpg" --resolution 2K
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "nano-banana-pro",
|
||||
"version": "1.0.1",
|
||||
"publishedAt": 1767651987917
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "google-genai>=1.0.0",
|
||||
# "pillow>=10.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
|
||||
|
||||
Usage:
|
||||
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_api_key(provided_key: str | None) -> str | None:
|
||||
"""Get API key from argument first, then environment."""
|
||||
if provided_key:
|
||||
return provided_key
|
||||
return os.environ.get("GEMINI_API_KEY")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt", "-p",
|
||||
required=True,
|
||||
help="Image description/prompt"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--filename", "-f",
|
||||
required=True,
|
||||
help="Output filename (e.g., sunset-mountains.png)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-image", "-i",
|
||||
help="Optional input image path for editing/modification"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolution", "-r",
|
||||
choices=["1K", "2K", "4K"],
|
||||
default="1K",
|
||||
help="Output resolution: 1K (default), 2K, or 4K"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key", "-k",
|
||||
help="Gemini API key (overrides GEMINI_API_KEY env var)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get API key
|
||||
api_key = get_api_key(args.api_key)
|
||||
if not api_key:
|
||||
print("Error: No API key provided.", file=sys.stderr)
|
||||
print("Please either:", file=sys.stderr)
|
||||
print(" 1. Provide --api-key argument", file=sys.stderr)
|
||||
print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Import here after checking API key to avoid slow import on error
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image as PILImage
|
||||
|
||||
# Initialise client
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# Set up output path
|
||||
output_path = Path(args.filename)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load input image if provided
|
||||
input_image = None
|
||||
output_resolution = args.resolution
|
||||
if args.input_image:
|
||||
try:
|
||||
input_image = PILImage.open(args.input_image)
|
||||
print(f"Loaded input image: {args.input_image}")
|
||||
|
||||
# Auto-detect resolution if not explicitly set by user
|
||||
if args.resolution == "1K": # Default value
|
||||
# Map input image size to resolution
|
||||
width, height = input_image.size
|
||||
max_dim = max(width, height)
|
||||
if max_dim >= 3000:
|
||||
output_resolution = "4K"
|
||||
elif max_dim >= 1500:
|
||||
output_resolution = "2K"
|
||||
else:
|
||||
output_resolution = "1K"
|
||||
print(f"Auto-detected resolution: {output_resolution} (from input {width}x{height})")
|
||||
except Exception as e:
|
||||
print(f"Error loading input image: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Build contents (image first if editing, prompt only if generating)
|
||||
if input_image:
|
||||
contents = [input_image, args.prompt]
|
||||
print(f"Editing image with resolution {output_resolution}...")
|
||||
else:
|
||||
contents = args.prompt
|
||||
print(f"Generating image with resolution {output_resolution}...")
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model="gemini-3-pro-image-preview",
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["TEXT", "IMAGE"],
|
||||
image_config=types.ImageConfig(
|
||||
image_size=output_resolution
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Process response and convert to PNG
|
||||
image_saved = False
|
||||
for part in response.parts:
|
||||
if part.text is not None:
|
||||
print(f"Model response: {part.text}")
|
||||
elif part.inline_data is not None:
|
||||
# Convert inline data to PIL Image and save as PNG
|
||||
from io import BytesIO
|
||||
|
||||
# inline_data.data is already bytes, not base64
|
||||
image_data = part.inline_data.data
|
||||
if isinstance(image_data, str):
|
||||
# If it's a string, it might be base64
|
||||
import base64
|
||||
image_data = base64.b64decode(image_data)
|
||||
|
||||
image = PILImage.open(BytesIO(image_data))
|
||||
|
||||
# Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed)
|
||||
if image.mode == 'RGBA':
|
||||
rgb_image = PILImage.new('RGB', image.size, (255, 255, 255))
|
||||
rgb_image.paste(image, mask=image.split()[3])
|
||||
rgb_image.save(str(output_path), 'PNG')
|
||||
elif image.mode == 'RGB':
|
||||
image.save(str(output_path), 'PNG')
|
||||
else:
|
||||
image.convert('RGB').save(str(output_path), 'PNG')
|
||||
image_saved = True
|
||||
|
||||
if image_saved:
|
||||
full_path = output_path.resolve()
|
||||
print(f"\nImage saved: {full_path}")
|
||||
else:
|
||||
print("Error: No image was generated in the response.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating image: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: notion
|
||||
description: Notion API for creating and managing pages, databases, and blocks.
|
||||
homepage: https://developers.notion.com
|
||||
metadata: {"clawdbot":{"emoji":"📝"}}
|
||||
---
|
||||
|
||||
# notion
|
||||
|
||||
Use the Notion API to create/read/update pages, data sources (databases), and blocks.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an integration at https://notion.so/my-integrations
|
||||
2. Copy the API key (starts with `ntn_` or `secret_`)
|
||||
3. Store it:
|
||||
```bash
|
||||
mkdir -p ~/.config/notion
|
||||
echo "ntn_your_key_here" > ~/.config/notion/api_key
|
||||
```
|
||||
4. Share target pages/databases with your integration (click "..." → "Connect to" → your integration name)
|
||||
|
||||
## API Basics
|
||||
|
||||
All requests need:
|
||||
```bash
|
||||
NOTION_KEY=$(cat ~/.config/notion/api_key)
|
||||
curl -X GET "https://api.notion.com/v1/..." \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
> **Note:** The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API.
|
||||
|
||||
## Common Operations
|
||||
|
||||
**Search for pages and data sources:**
|
||||
```bash
|
||||
curl -X POST "https://api.notion.com/v1/search" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "page title"}'
|
||||
```
|
||||
|
||||
**Get page:**
|
||||
```bash
|
||||
curl "https://api.notion.com/v1/pages/{page_id}" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03"
|
||||
```
|
||||
|
||||
**Get page content (blocks):**
|
||||
```bash
|
||||
curl "https://api.notion.com/v1/blocks/{page_id}/children" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03"
|
||||
```
|
||||
|
||||
**Create page in a data source:**
|
||||
```bash
|
||||
curl -X POST "https://api.notion.com/v1/pages" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"parent": {"database_id": "xxx"},
|
||||
"properties": {
|
||||
"Name": {"title": [{"text": {"content": "New Item"}}]},
|
||||
"Status": {"select": {"name": "Todo"}}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Query a data source (database):**
|
||||
```bash
|
||||
curl -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filter": {"property": "Status", "select": {"equals": "Active"}},
|
||||
"sorts": [{"property": "Date", "direction": "descending"}]
|
||||
}'
|
||||
```
|
||||
|
||||
**Create a data source (database):**
|
||||
```bash
|
||||
curl -X POST "https://api.notion.com/v1/data_sources" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"parent": {"page_id": "xxx"},
|
||||
"title": [{"text": {"content": "My Database"}}],
|
||||
"properties": {
|
||||
"Name": {"title": {}},
|
||||
"Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
|
||||
"Date": {"date": {}}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Update page properties:**
|
||||
```bash
|
||||
curl -X PATCH "https://api.notion.com/v1/pages/{page_id}" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"properties": {"Status": {"select": {"name": "Done"}}}}'
|
||||
```
|
||||
|
||||
**Add blocks to page:**
|
||||
```bash
|
||||
curl -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \
|
||||
-H "Authorization: Bearer $NOTION_KEY" \
|
||||
-H "Notion-Version: 2025-09-03" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"children": [
|
||||
{"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"text": {"content": "Hello"}}]}}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Property Types
|
||||
|
||||
Common property formats for database items:
|
||||
- **Title:** `{"title": [{"text": {"content": "..."}}]}`
|
||||
- **Rich text:** `{"rich_text": [{"text": {"content": "..."}}]}`
|
||||
- **Select:** `{"select": {"name": "Option"}}`
|
||||
- **Multi-select:** `{"multi_select": [{"name": "A"}, {"name": "B"}]}`
|
||||
- **Date:** `{"date": {"start": "2024-01-15", "end": "2024-01-16"}}`
|
||||
- **Checkbox:** `{"checkbox": true}`
|
||||
- **Number:** `{"number": 42}`
|
||||
- **URL:** `{"url": "https://..."}`
|
||||
- **Email:** `{"email": "a@b.com"}`
|
||||
- **Relation:** `{"relation": [{"id": "page_id"}]}`
|
||||
|
||||
## Key Differences in 2025-09-03
|
||||
|
||||
- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval
|
||||
- **Two IDs:** Each database now has both a `database_id` and a `data_source_id`
|
||||
- Use `database_id` when creating pages (`parent: {"database_id": "..."}`)
|
||||
- Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`)
|
||||
- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id`
|
||||
- **Parent in responses:** Pages show `parent.data_source_id` alongside `parent.database_id`
|
||||
- **Finding the data_source_id:** Search for the database, or call `GET /v1/data_sources/{data_source_id}`
|
||||
|
||||
## Notes
|
||||
|
||||
- Page/database IDs are UUIDs (with or without dashes)
|
||||
- The API cannot set database view filters — that's UI-only
|
||||
- Rate limit: ~3 requests/second average
|
||||
- Use `is_inline: true` when creating data sources to embed them in pages
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "notion",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1767545360889
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: obsidian
|
||||
description: Work with Obsidian vaults (plain Markdown notes) and automate via obsidian-cli.
|
||||
homepage: https://help.obsidian.md
|
||||
metadata: {"clawdbot":{"emoji":"💎","requires":{"bins":["obsidian-cli"]},"install":[{"id":"brew","kind":"brew","formula":"yakitrak/yakitrak/obsidian-cli","bins":["obsidian-cli"],"label":"Install obsidian-cli (brew)"}]}}
|
||||
---
|
||||
|
||||
# Obsidian
|
||||
|
||||
Obsidian vault = a normal folder on disk.
|
||||
|
||||
Vault structure (typical)
|
||||
- Notes: `*.md` (plain text Markdown; edit with any editor)
|
||||
- Config: `.obsidian/` (workspace + plugin settings; usually don’t touch from scripts)
|
||||
- Canvases: `*.canvas` (JSON)
|
||||
- Attachments: whatever folder you chose in Obsidian settings (images/PDFs/etc.)
|
||||
|
||||
## Find the active vault(s)
|
||||
|
||||
Obsidian desktop tracks vaults here (source of truth):
|
||||
- `~/Library/Application Support/obsidian/obsidian.json`
|
||||
|
||||
`obsidian-cli` resolves vaults from that file; vault name is typically the **folder name** (path suffix).
|
||||
|
||||
Fast “what vault is active / where are the notes?”
|
||||
- If you’ve already set a default: `obsidian-cli print-default --path-only`
|
||||
- Otherwise, read `~/Library/Application Support/obsidian/obsidian.json` and use the vault entry with `"open": true`.
|
||||
|
||||
Notes
|
||||
- Multiple vaults common (iCloud vs `~/Documents`, work/personal, etc.). Don’t guess; read config.
|
||||
- Avoid writing hardcoded vault paths into scripts; prefer reading the config or using `print-default`.
|
||||
|
||||
## obsidian-cli quick start
|
||||
|
||||
Pick a default vault (once):
|
||||
- `obsidian-cli set-default "<vault-folder-name>"`
|
||||
- `obsidian-cli print-default` / `obsidian-cli print-default --path-only`
|
||||
|
||||
Search
|
||||
- `obsidian-cli search "query"` (note names)
|
||||
- `obsidian-cli search-content "query"` (inside notes; shows snippets + lines)
|
||||
|
||||
Create
|
||||
- `obsidian-cli create "Folder/New note" --content "..." --open`
|
||||
- Requires Obsidian URI handler (`obsidian://…`) working (Obsidian installed).
|
||||
- Avoid creating notes under “hidden” dot-folders (e.g. `.something/...`) via URI; Obsidian may refuse.
|
||||
|
||||
Move/rename (safe refactor)
|
||||
- `obsidian-cli move "old/path/note" "new/path/note"`
|
||||
- Updates `[[wikilinks]]` and common Markdown links across the vault (this is the main win vs `mv`).
|
||||
|
||||
Delete
|
||||
- `obsidian-cli delete "path/note"`
|
||||
|
||||
Prefer direct edits when appropriate: open the `.md` file and change it; Obsidian will pick it up.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "obsidian",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1767545362143
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: ocr
|
||||
description: Optical Character Recognition (OCR) tool, supports Chinese and English text extraction from PDFs and images. Use cases: (1) extract text from scanned PDFs, (2) recognize text from images, (3) extract text content from invoices, contracts, and other documents
|
||||
---
|
||||
|
||||
# OCR Text Recognition
|
||||
|
||||
This skill uses PaddleOCR for text recognition, supporting both Chinese and English.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Perform OCR recognition directly on image or PDF files:
|
||||
|
||||
```python
|
||||
from paddleocr import PaddleOCR
|
||||
|
||||
ocr = PaddleOCR(lang='ch')
|
||||
result = ocr.predict("file_path.jpg")
|
||||
```
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
Install dependencies before first use:
|
||||
|
||||
```bash
|
||||
pip3 install paddlepaddle paddleocr
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Recognition results return JSON containing:
|
||||
- `rec_texts`: List of recognized text
|
||||
- `rec_scores`: Confidence score for each text
|
||||
|
||||
## Typical Use Cases
|
||||
|
||||
1. **PDF Scans**: Use PyMuPDF to extract images first, then OCR
|
||||
2. **Image Text Recognition**: Perform OCR directly on images
|
||||
3. **Multi-page PDFs**: Process page by page
|
||||
|
||||
## Scripts
|
||||
|
||||
Common scripts are located in the `scripts/` directory.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: ocr
|
||||
description: 光学字符识别 (OCR) 工具,支持从 PDF 和图片中提取中英文文本。适用场景:(1) 从扫描版 PDF 提取文字,(2) 识别图片中的文字,(3) 提取发票、合同等文档的文字内容
|
||||
---
|
||||
|
||||
# OCR 文字识别
|
||||
|
||||
本技能使用 PaddleOCR 进行文字识别,支持中文和英文。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基础用法
|
||||
|
||||
直接对图片或 PDF 文件进行 OCR 识别:
|
||||
|
||||
```python
|
||||
from paddleocr import PaddleOCR
|
||||
|
||||
ocr = PaddleOCR(lang='ch')
|
||||
result = ocr.predict("file_path.jpg")
|
||||
```
|
||||
|
||||
## 依赖安装
|
||||
|
||||
首次使用前请安装依赖:
|
||||
|
||||
```bash
|
||||
pip3 install paddlepaddle paddleocr
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
识别结果返回 JSON 格式,包含:
|
||||
- `rec_texts`: 识别出的文字列表
|
||||
- `rec_scores`: 每段文字的置信度分数
|
||||
|
||||
## 典型使用场景
|
||||
|
||||
1. **PDF 扫描件**:先用 PyMuPDF 提取图片,再进行 OCR
|
||||
2. **图片文字识别**:直接对图片进行 OCR
|
||||
3. **多页 PDF**:逐页处理
|
||||
|
||||
## 脚本
|
||||
|
||||
常用脚本位于 `scripts/` 目录下。
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7cpqbnatvf5664znnnamfg6s81jzv4",
|
||||
"slug": "ocr-python",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1771704541442
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OCR Text Recognition Script
|
||||
Usage: python3 ocr.py <file_path> [--output <output_file>]
|
||||
Supported: .pdf, .jpg, .jpeg, .png
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
|
||||
def extract_images_from_pdf(pdf_path):
|
||||
"""Extract images from PDF"""
|
||||
import fitz
|
||||
|
||||
images = []
|
||||
doc = fitz.open(pdf_path)
|
||||
|
||||
for page_num in range(len(doc)):
|
||||
page = doc[page_num]
|
||||
page_images = page.get_images()
|
||||
|
||||
for img_index, img in enumerate(page_images):
|
||||
xref = img[0]
|
||||
base_image = doc.extract_image(xref)
|
||||
image_bytes = base_image["image"]
|
||||
image_ext = base_image["ext"]
|
||||
|
||||
output_path = f"/tmp/pdf_page{page_num+1}_img{img_index}.{image_ext}"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
images.append(output_path)
|
||||
|
||||
doc.close()
|
||||
return images
|
||||
|
||||
|
||||
def ocr_file(file_path, output_path=None):
|
||||
"""Perform OCR recognition on file"""
|
||||
from paddleocr import PaddleOCR
|
||||
|
||||
# Initialize OCR
|
||||
ocr = PaddleOCR(lang='ch', use_angle_cls=True)
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if ext == '.pdf':
|
||||
# PDF: extract images first, then recognize
|
||||
images = extract_images_from_pdf(file_path)
|
||||
all_texts = []
|
||||
|
||||
for img_path in images:
|
||||
print(f"Recognizing: {img_path}")
|
||||
result = ocr.predict(img_path)
|
||||
if result:
|
||||
texts = result[0].get('rec_texts', [])
|
||||
all_texts.extend(texts)
|
||||
|
||||
# Clean up temporary images
|
||||
for img_path in images:
|
||||
try:
|
||||
os.remove(img_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
final_texts = all_texts
|
||||
else:
|
||||
# Image: recognize directly
|
||||
print(f"Recognizing: {file_path}")
|
||||
result = ocr.predict(file_path)
|
||||
|
||||
if result and len(result) > 0:
|
||||
final_texts = result[0].get('rec_texts', [])
|
||||
else:
|
||||
final_texts = []
|
||||
|
||||
# Output results
|
||||
if output_path:
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
for text in final_texts:
|
||||
f.write(text + '\n')
|
||||
print(f"Results saved to: {output_path}")
|
||||
else:
|
||||
print("\n=== OCR Recognition Results ===\n")
|
||||
for text in final_texts:
|
||||
print(text)
|
||||
|
||||
return final_texts
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='OCR Text Recognition Tool')
|
||||
parser.add_argument('file', help='File to recognize (PDF or image)')
|
||||
parser.add_argument('--output', '-o', help='Output file path (optional)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.file):
|
||||
print(f"Error: File not found: {args.file}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
ocr_file(args.file, args.output)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: openai-whisper
|
||||
description: Local speech-to-text with the Whisper CLI (no API key).
|
||||
homepage: https://openai.com/research/whisper
|
||||
metadata: {"clawdbot":{"emoji":"🎙️","requires":{"bins":["whisper"]},"install":[{"id":"brew","kind":"brew","formula":"openai-whisper","bins":["whisper"],"label":"Install OpenAI Whisper (brew)"}]}}
|
||||
---
|
||||
|
||||
# Whisper (CLI)
|
||||
|
||||
Use `whisper` to transcribe audio locally.
|
||||
|
||||
Quick start
|
||||
- `whisper /path/audio.mp3 --model medium --output_format txt --output_dir .`
|
||||
- `whisper /path/audio.m4a --task translate --output_format srt`
|
||||
|
||||
Notes
|
||||
- Models download to `~/.cache/whisper` on first run.
|
||||
- `--model` defaults to `turbo` on this install.
|
||||
- Use smaller models for speed, larger for accuracy.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "openai-whisper",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1767545365755
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: paddleocr-doc-parsing
|
||||
description: Parse documents using PaddleOCR's API. Supports both sync and async modes for images and PDFs.
|
||||
homepage: https://www.paddleocr.com
|
||||
metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "📄",
|
||||
"os": ["darwin", "linux"],
|
||||
"requires":
|
||||
{
|
||||
"bins": ["curl", "base64", "jq", "python3"],
|
||||
"env": ["PADDLEOCR_ACCESS_TOKEN", "PADDLEOCR_API_URL"],
|
||||
},
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
# PaddleOCR Document Parsing
|
||||
|
||||
Parse images and PDF files using PaddleOCR's API. Supports both synchronous and asynchronous parsing modes with structured output.
|
||||
|
||||
## Resource Links
|
||||
|
||||
| Resource | Link |
|
||||
| --------------------- | ------------------------------------------------------------------------------ |
|
||||
| **Official Website** | [https://www.paddleocr.com](https://www.paddleocr.com) |
|
||||
| **API Documentation** | [https://ai.baidu.com/ai-doc/AISTUDIO/Cmkz2m0ma](https://ai.baidu.com/ai-doc/AISTUDIO/Cmkz2m0ma) |
|
||||
| **GitHub** | [https://github.com/PaddlePaddle/PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR) |
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-format support**: PDF and image files (JPG, PNG, BMP, TIFF)
|
||||
- **Two parsing modes**:
|
||||
- **Sync mode**: Fast response for small files (<600s timeout)
|
||||
- **Async mode**: For large files with progress polling
|
||||
- **Layout analysis**: Automatic detection of text blocks, tables, formulas
|
||||
- **Multi-language**: Support for 110+ languages
|
||||
- **Structured output**: Markdown format with preserved document structure
|
||||
|
||||
## Setup
|
||||
|
||||
1. Visit [PaddleOCR](https://www.paddleocr.com) to obtain your API credentials
|
||||
2. Set environment variables:
|
||||
|
||||
```bash
|
||||
export PADDLEOCR_ACCESS_TOKEN="your_token_here"
|
||||
export PADDLEOCR_API_URL="https://your-endpoint.aistudio-app.com/layout-parsing"
|
||||
|
||||
# Optional: For async mode
|
||||
export PADDLEOCR_JOB_URL="https://your-job-endpoint.aistudio-app.com/api/v2/ocr/jobs"
|
||||
export PADDLEOCR_MODEL="PaddleOCR-VL-1.5"
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Sync Mode (Default)
|
||||
|
||||
For small files and quick processing:
|
||||
|
||||
```bash
|
||||
# Parse local image
|
||||
{baseDir}/paddleocr_parse.sh document.jpg
|
||||
|
||||
# Parse PDF
|
||||
{baseDir}/paddleocr_parse.sh -t pdf document.pdf
|
||||
|
||||
# Parse from URL
|
||||
{baseDir}/paddleocr_parse.sh https://example.com/document.jpg
|
||||
|
||||
# Save output to file
|
||||
{baseDir}/paddleocr_parse.sh -o result.json document.jpg
|
||||
|
||||
# Verbose output
|
||||
{baseDir}/paddleocr_parse.sh -v document.jpg
|
||||
```
|
||||
|
||||
### Async Mode
|
||||
|
||||
For large files with progress tracking:
|
||||
|
||||
```bash
|
||||
# Parse large PDF with async mode
|
||||
{baseDir}/paddleocr_parse.sh --async large-document.pdf
|
||||
|
||||
# Parse from URL with async mode
|
||||
{baseDir}/paddleocr_parse.sh --async -t pdf https://example.com/doc.pdf
|
||||
|
||||
# Save async result to file
|
||||
{baseDir}/paddleocr_parse.sh --async -o result.json document.pdf
|
||||
```
|
||||
|
||||
### Using Python Script Directly
|
||||
|
||||
```bash
|
||||
# Sync mode
|
||||
python3 {baseDir}/paddleocr_parse.py document.jpg
|
||||
|
||||
# Async mode
|
||||
python3 {baseDir}/paddleocr_parse.py --async-mode document.pdf
|
||||
|
||||
# With output file
|
||||
python3 {baseDir}/paddleocr_parse.py -o result.json --async-mode document.pdf
|
||||
```
|
||||
|
||||
## Response Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"logId": "unique_request_id",
|
||||
"errorCode": 0,
|
||||
"errorMsg": "Success",
|
||||
"result": {
|
||||
"layoutParsingResults": [
|
||||
{
|
||||
"prunedResult": [...],
|
||||
"markdown": {
|
||||
"text": "# Document Title\n\nParagraph content...",
|
||||
"images": {}
|
||||
},
|
||||
"outputImages": [...],
|
||||
"inputImage": "http://input-image"
|
||||
}
|
||||
],
|
||||
"dataInfo": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important Fields:**
|
||||
|
||||
- **`prunedResult`** - Contains detailed layout element information including positions, categories, etc.
|
||||
- **`markdown`** - Stores the document content converted to Markdown format with preserved structure and formatting.
|
||||
|
||||
## Mode Selection Guide
|
||||
|
||||
| Use Case | Recommended Mode |
|
||||
|----------|-----------------|
|
||||
| Small images (< 10MB) | Sync |
|
||||
| Single page PDFs | Sync |
|
||||
| Large PDFs (> 10MB) | Async |
|
||||
| Multi-page documents | Async |
|
||||
| Batch processing | Async |
|
||||
| Quick text extraction | Sync |
|
||||
|
||||
## Error Handling
|
||||
|
||||
The script will exit with code 1 and print error message for:
|
||||
- Missing required environment variables
|
||||
- File not found
|
||||
- API authentication failures
|
||||
- Invalid JSON responses
|
||||
- API error codes (non-zero)
|
||||
|
||||
## Quota Information
|
||||
|
||||
See official documentation: https://ai.baidu.com/ai-doc/AISTUDIO/Xmjclapam
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn733khw3j210jv8ntd4sm19kx8125hm",
|
||||
"slug": "paddleocr-doc-parsing-v2",
|
||||
"version": "1.0.4",
|
||||
"publishedAt": 1770944761798
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PaddleOCR Async Document Parser
|
||||
|
||||
Supports both sync and async parsing modes.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def get_env_or_exit(name: str) -> str:
|
||||
"""Get environment variable or exit with error."""
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
print(f"Error: {name} environment variable is required", file=sys.stderr)
|
||||
print(f"Set it with: export {name}=\"your_value_here\"", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return value
|
||||
|
||||
|
||||
def sync_parse(file_path: str, file_type: int, api_url: str, token: str, verbose: bool = False) -> dict:
|
||||
"""Synchronous document parsing."""
|
||||
|
||||
if file_path.startswith("http"):
|
||||
# URL mode
|
||||
payload = {
|
||||
"file": file_path,
|
||||
"fileType": file_type,
|
||||
"useDocOrientationClassify": False,
|
||||
"useDocUnwarping": False,
|
||||
}
|
||||
else:
|
||||
# Local file mode
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
print(f"Error: File not found: {file_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
file_bytes = path.read_bytes()
|
||||
file_data = base64.b64encode(file_bytes).decode("ascii")
|
||||
|
||||
payload = {
|
||||
"file": file_data,
|
||||
"fileType": file_type,
|
||||
"useDocOrientationClassify": False,
|
||||
"useDocUnwarping": False,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
if verbose:
|
||||
print(f"Making sync request to: {api_url}", file=sys.stderr)
|
||||
|
||||
response = requests.post(api_url, json=payload, headers=headers, timeout=600)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error: HTTP {response.status_code}", file=sys.stderr)
|
||||
print(response.text, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
def async_parse(file_path: str, model: str, job_url: str, token: str, verbose: bool = False) -> dict:
|
||||
"""Asynchronous document parsing."""
|
||||
|
||||
headers = {
|
||||
"Authorization": f"bearer {token}",
|
||||
}
|
||||
|
||||
optional_payload = {
|
||||
"useDocOrientationClassify": False,
|
||||
"useDocUnwarping": False,
|
||||
"useChartRecognition": False,
|
||||
}
|
||||
|
||||
if verbose:
|
||||
print(f"Processing file: {file_path}", file=sys.stderr)
|
||||
|
||||
if file_path.startswith("http"):
|
||||
# URL Mode
|
||||
headers["Content-Type"] = "application/json"
|
||||
payload = {
|
||||
"fileUrl": file_path,
|
||||
"model": model,
|
||||
"optionalPayload": optional_payload
|
||||
}
|
||||
job_response = requests.post(job_url, json=payload, headers=headers)
|
||||
else:
|
||||
# Local File Mode
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
print(f"Error: File not found: {file_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
data = {
|
||||
"model": model,
|
||||
"optionalPayload": json.dumps(optional_payload)
|
||||
}
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": f}
|
||||
job_response = requests.post(job_url, headers=headers, data=data, files=files)
|
||||
|
||||
if verbose:
|
||||
print(f"Response status: {job_response.status_code}", file=sys.stderr)
|
||||
|
||||
if job_response.status_code != 200:
|
||||
print(f"Error: HTTP {job_response.status_code}", file=sys.stderr)
|
||||
print(job_response.text, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
job_id = job_response.json()["data"]["jobId"]
|
||||
print(f"Job submitted. ID: {job_id}", file=sys.stderr)
|
||||
|
||||
# Poll for results
|
||||
jsonl_url = ""
|
||||
while True:
|
||||
job_result = requests.get(f"{job_url}/{job_id}", headers=headers)
|
||||
job_result.raise_for_status()
|
||||
|
||||
data = job_result.json()["data"]
|
||||
state = data["state"]
|
||||
|
||||
if state == 'pending':
|
||||
if verbose:
|
||||
print("Status: pending", file=sys.stderr)
|
||||
elif state == 'running':
|
||||
try:
|
||||
progress = data['extractProgress']
|
||||
total = progress['totalPages']
|
||||
extracted = progress['extractedPages']
|
||||
print(f"Status: running ({extracted}/{total} pages)", file=sys.stderr)
|
||||
except KeyError:
|
||||
if verbose:
|
||||
print("Status: running...", file=sys.stderr)
|
||||
elif state == 'done':
|
||||
extracted = data['extractProgress']['extractedPages']
|
||||
print(f"Status: done ({extracted} pages extracted)", file=sys.stderr)
|
||||
jsonl_url = data['resultUrl']['jsonUrl']
|
||||
break
|
||||
elif state == "failed":
|
||||
error_msg = data.get('errorMsg', 'Unknown error')
|
||||
print(f"Error: Job failed - {error_msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
# Fetch JSONL results
|
||||
if jsonl_url:
|
||||
jsonl_response = requests.get(jsonl_url)
|
||||
jsonl_response.raise_for_status()
|
||||
|
||||
lines = jsonl_response.text.strip().split('\n')
|
||||
results = []
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
result = json.loads(line)["result"]
|
||||
results.extend(result.get("layoutParsingResults", []))
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
if verbose:
|
||||
print(f"Warning: Failed to parse line: {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
return {"result": {"layoutParsingResults": results}}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Parse documents using PaddleOCR API")
|
||||
parser.add_argument("input", help="Input file path or URL")
|
||||
parser.add_argument("-t", "--type", choices=["image", "pdf"], default="image",
|
||||
help="File type (default: image)")
|
||||
parser.add_argument("-o", "--output", help="Output file (default: stdout)")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||
parser.add_argument("--async-mode", action="store_true",
|
||||
help="Use async mode (for large files)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get configuration from environment
|
||||
token = get_env_or_exit("PADDLEOCR_ACCESS_TOKEN")
|
||||
|
||||
if args.async_mode:
|
||||
job_url = get_env_or_exit("PADDLEOCR_JOB_URL")
|
||||
model = os.environ.get("PADDLEOCR_MODEL", "PaddleOCR-VL-1.5")
|
||||
result = async_parse(args.input, model, job_url, token, args.verbose)
|
||||
else:
|
||||
api_url = get_env_or_exit("PADDLEOCR_API_URL")
|
||||
file_type_code = 0 if args.type == "pdf" else 1
|
||||
result = sync_parse(args.input, file_type_code, api_url, token, args.verbose)
|
||||
|
||||
# Output result
|
||||
output = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
print(f"Output saved to: {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PaddleOCR Document Parser Script
|
||||
# Supports both sync and async modes
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
file_type="image"
|
||||
output_file=""
|
||||
verbose="false"
|
||||
async_mode="false"
|
||||
|
||||
# Function to display usage
|
||||
usage() {
|
||||
cat << EOF
|
||||
Usage: $0 [OPTIONS] INPUT_FILE_PATH_OR_URL
|
||||
|
||||
Parse documents using PaddleOCR API
|
||||
|
||||
OPTIONS:
|
||||
-t, --type TYPE File type (image, pdf) [default: image]
|
||||
-o, --output FILE Output file [default: stdout]
|
||||
-v, --verbose Verbose output
|
||||
--async Use async mode (for large files/PDFs)
|
||||
-h, --help Show this help message
|
||||
|
||||
ENVIRONMENT:
|
||||
PADDLEOCR_ACCESS_TOKEN Required: API access token
|
||||
PADDLEOCR_API_URL Required: Sync mode endpoint URL
|
||||
PADDLEOCR_JOB_URL Required for async: Async endpoint URL
|
||||
PADDLEOCR_MODEL Optional: Model name [default: PaddleOCR-VL-1.5]
|
||||
|
||||
SETUP:
|
||||
1. Visit https://www.paddleocr.com to get API credentials
|
||||
2. Set environment variables:
|
||||
export PADDLEOCR_ACCESS_TOKEN="your_token"
|
||||
export PADDLEOCR_API_URL="https://your-endpoint/layout-parsing"
|
||||
|
||||
EXAMPLES:
|
||||
# Sync mode (default)
|
||||
$0 document.jpg
|
||||
$0 -t pdf document.pdf
|
||||
$0 -o result.json document.jpg
|
||||
|
||||
# Async mode (for large files)
|
||||
$0 --async large-document.pdf
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--type)
|
||||
file_type="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
output_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-v|--verbose)
|
||||
verbose="true"
|
||||
shift
|
||||
;;
|
||||
--async)
|
||||
async_mode="true"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
input_file="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate input
|
||||
if [[ -z "$input_file" ]]; then
|
||||
echo "Error: Input file path or URL is required"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check required environment variables
|
||||
if [[ -z "$PADDLEOCR_ACCESS_TOKEN" ]]; then
|
||||
echo "Error: PADDLEOCR_ACCESS_TOKEN environment variable is required"
|
||||
echo "Get it from: https://www.paddleocr.com"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$PADDLEOCR_API_URL" ]]; then
|
||||
echo "Error: PADDLEOCR_API_URL environment variable is required"
|
||||
echo "Set it to your PaddleOCR API endpoint"
|
||||
echo "Example: export PADDLEOCR_API_URL=\"https://your-endpoint.aistudio-app.com/layout-parsing\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set optional defaults
|
||||
PADDLEOCR_MODEL="${PADDLEOCR_MODEL:-PaddleOCR-VL-1.5}"
|
||||
|
||||
# Check if input is a URL or local file
|
||||
if [[ "$input_file" =~ ^https?:// ]]; then
|
||||
is_url="true"
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Input is a URL: $input_file" >&2
|
||||
fi
|
||||
else
|
||||
is_url="false"
|
||||
if [[ ! -f "$input_file" ]]; then
|
||||
echo "Error: Input file not found: $input_file"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Input is a local file: $input_file" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Use Python script for async mode
|
||||
if [[ "$async_mode" == "true" ]]; then
|
||||
if [[ -z "$PADDLEOCR_JOB_URL" ]]; then
|
||||
echo "Error: PADDLEOCR_JOB_URL environment variable is required for async mode"
|
||||
echo "Example: export PADDLEOCR_JOB_URL=\"https://your-endpoint.aistudio-app.com/api/v2/ocr/jobs\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Using async mode with model: $PADDLEOCR_MODEL" >&2
|
||||
fi
|
||||
|
||||
# Get script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Run Python async parser
|
||||
if [[ -n "$output_file" ]]; then
|
||||
python3 "$SCRIPT_DIR/paddleocr_parse.py" --async-mode -o "$output_file" "$input_file"
|
||||
else
|
||||
python3 "$SCRIPT_DIR/paddleocr_parse.py" --async-mode "$input_file"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Sync mode (bash implementation)
|
||||
|
||||
# Validate file type
|
||||
case "$file_type" in
|
||||
image|img) file_type_code=1 ;;
|
||||
pdf) file_type_code=0 ;;
|
||||
*)
|
||||
echo "Error: Invalid file type '$file_type'. Supported: image, pdf"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Build payload - directly use URL or encode file
|
||||
if [[ "$is_url" == "true" ]]; then
|
||||
# Use URL directly in payload
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"file": "$input_file",
|
||||
"fileType": $file_type_code,
|
||||
"useDocOrientationClassify": false,
|
||||
"useDocUnwarping": false
|
||||
}
|
||||
EOF
|
||||
)
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Using URL directly in API request" >&2
|
||||
fi
|
||||
else
|
||||
# Encode local file to base64
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Encoding $input_file to base64..." >&2
|
||||
fi
|
||||
|
||||
file_base64=$(cat "$input_file" | base64 | tr -d '\n')
|
||||
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"file": "$file_base64",
|
||||
"fileType": $file_type_code,
|
||||
"useDocOrientationClassify": false,
|
||||
"useDocUnwarping": false
|
||||
}
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Making API request to: $PADDLEOCR_API_URL" >&2
|
||||
echo "Payload size: ${#payload} bytes" >&2
|
||||
fi
|
||||
|
||||
# Make API request
|
||||
# Use temporary file to avoid "Argument list too long" error for large payloads
|
||||
payload_file=$(mktemp)
|
||||
echo "$payload" > "$payload_file"
|
||||
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Request payload saved to temporary file: $payload_file" >&2
|
||||
fi
|
||||
|
||||
# Use trap to ensure temporary file cleanup on script exit
|
||||
cleanup() {
|
||||
if [[ -f "$payload_file" ]]; then
|
||||
rm -f "$payload_file"
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Cleaned up temporary file: $payload_file" >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
response=$(curl -s -X POST "$PADDLEOCR_API_URL" \
|
||||
-m 600 \
|
||||
--fail-with-body \
|
||||
-H "Authorization: token $PADDLEOCR_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @"$payload_file")
|
||||
|
||||
# Check for curl errors
|
||||
curl_exit_code=$?
|
||||
if [[ $curl_exit_code -ne 0 ]]; then
|
||||
echo "Error: Curl request failed with code $curl_exit_code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check response for errors using jq
|
||||
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
|
||||
echo "Error: Invalid JSON response from API"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
error_code=$(echo "$response" | jq -r '.errorCode // empty')
|
||||
error_msg=$(echo "$response" | jq -r '.errorMsg // empty')
|
||||
|
||||
if [[ -n "$error_code" && "$error_code" != "0" ]]; then
|
||||
echo "API Error ($error_code): $error_msg"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract and process result
|
||||
if [[ -n "$output_file" ]]; then
|
||||
echo "$response" > "$output_file"
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Output saved to: $output_file" >&2
|
||||
fi
|
||||
else
|
||||
echo "$response"
|
||||
fi
|
||||
|
||||
if [[ "$verbose" == "true" ]]; then
|
||||
echo "Processing completed successfully" >&2
|
||||
fi
|
||||
@@ -0,0 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## [1.2.0] - 2026-02-07
|
||||
|
||||
### 🔄 Major Changes
|
||||
|
||||
- **Project Renamed** — `web-scraper` → `playwright-scraper-skill`
|
||||
- Updated all documentation and links
|
||||
- Updated GitHub repo name
|
||||
- **Bilingual Documentation** — All docs now in English (with Chinese README available)
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2026-02-07
|
||||
|
||||
### ✅ Added
|
||||
|
||||
- **LICENSE** — MIT License
|
||||
- **CONTRIBUTING.md** — Contribution guidelines
|
||||
- **examples/README.md** — Detailed usage examples
|
||||
- **test.sh** — Automated test script
|
||||
- **README.md** — Redesigned with badges
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- Clearer file structure
|
||||
- More detailed documentation
|
||||
- More practical examples
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-02-07
|
||||
|
||||
### ✅ Initial Release
|
||||
|
||||
**Tools Created:**
|
||||
- ✅ `playwright-simple.js` — Fast simple scraper
|
||||
- ✅ `playwright-stealth.js` — Anti-bot protected version (primary) ⭐
|
||||
|
||||
**Test Results:**
|
||||
- ✅ Discuss.com.hk success (200 OK, 19.6s)
|
||||
- ✅ Example.com success (3.4s)
|
||||
- ✅ Auto fallback to deep-scraper's Playwright
|
||||
|
||||
**Documentation:**
|
||||
- ✅ SKILL.md (full documentation)
|
||||
- ✅ README.md (quick reference)
|
||||
- ✅ Example scripts (discuss-hk.sh)
|
||||
- ✅ package.json
|
||||
|
||||
**Key Findings:**
|
||||
1. **Playwright Stealth is the best solution** (100% success on Discuss.com.hk)
|
||||
2. **Don't use Crawlee** (easily detected)
|
||||
3. **Chaser (Rust) doesn't work currently** (blocked by Cloudflare)
|
||||
4. **Hiding `navigator.webdriver` is key**
|
||||
|
||||
---
|
||||
|
||||
## Future Plans
|
||||
|
||||
- [ ] Add proxy IP rotation
|
||||
- [ ] CAPTCHA handling integration
|
||||
- [ ] Cookie management (maintain login state)
|
||||
- [ ] Batch scraping (parallel processing)
|
||||
- [ ] Integration with OpenClaw browser tool
|
||||
@@ -0,0 +1,132 @@
|
||||
# Contributing Guide
|
||||
|
||||
Thank you for considering contributing to playwright-scraper-skill!
|
||||
|
||||
## 🐛 Reporting Issues
|
||||
|
||||
If you find a bug or have a feature suggestion:
|
||||
|
||||
1. Check [Issues](https://github.com/waisimon/playwright-scraper-skill/issues) to see if it already exists
|
||||
2. If not, create a new Issue
|
||||
3. Provide the following information:
|
||||
- Problem description
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Environment (Node.js version, OS)
|
||||
- Error messages (if any)
|
||||
|
||||
## 💡 Feature Requests
|
||||
|
||||
1. Create an Issue with `[Feature Request]` in the title
|
||||
2. Explain:
|
||||
- The desired feature
|
||||
- Use cases
|
||||
- Why this feature would be useful
|
||||
|
||||
## 🔧 Submitting Code
|
||||
|
||||
### Setting Up Development Environment
|
||||
|
||||
```bash
|
||||
# Fork the repo and clone
|
||||
git clone https://github.com/YOUR_USERNAME/playwright-scraper-skill.git
|
||||
cd playwright-scraper-skill
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
|
||||
# Test
|
||||
node scripts/playwright-simple.js https://example.com
|
||||
```
|
||||
|
||||
### Contribution Workflow
|
||||
|
||||
1. Create a new branch:
|
||||
```bash
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
|
||||
2. Make your changes
|
||||
|
||||
3. Test your changes:
|
||||
```bash
|
||||
npm test
|
||||
node scripts/playwright-stealth.js <test-URL>
|
||||
```
|
||||
|
||||
4. Commit:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add: brief description of changes"
|
||||
```
|
||||
|
||||
5. Push and create a Pull Request:
|
||||
```bash
|
||||
git push origin feature/my-new-feature
|
||||
```
|
||||
|
||||
### Commit Message Guidelines
|
||||
|
||||
Use clear commit messages:
|
||||
|
||||
- `Add: new feature`
|
||||
- `Fix: issue description`
|
||||
- `Update: existing feature`
|
||||
- `Refactor: code refactoring`
|
||||
- `Docs: documentation update`
|
||||
- `Test: add or modify tests`
|
||||
|
||||
Example:
|
||||
```
|
||||
Fix: playwright-stealth.js screenshot timeout issue
|
||||
|
||||
- Increase timeout parameter to 10 seconds
|
||||
- Add try-catch error handling
|
||||
- Update documentation
|
||||
```
|
||||
|
||||
## 📝 Documentation
|
||||
|
||||
If your changes affect usage:
|
||||
|
||||
- Update `SKILL.md` (full documentation)
|
||||
- Update `README.md` (quick reference)
|
||||
- Update `examples/README.md` (if adding new examples)
|
||||
- Update `CHANGELOG.md` (record changes)
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
Before submitting a PR, confirm:
|
||||
|
||||
- [ ] Code runs properly
|
||||
- [ ] Doesn't break existing functionality
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Clear commit messages
|
||||
- [ ] No sensitive information (API keys, personal paths, etc.)
|
||||
|
||||
## 🎯 Priority Areas
|
||||
|
||||
Currently welcoming contributions in:
|
||||
|
||||
1. **New anti-bot techniques** — Improve success rates
|
||||
2. **Support more websites** — Test and share success cases
|
||||
3. **Performance optimization** — Speed up scraping
|
||||
4. **Error handling** — Better error messages and recovery
|
||||
5. **Documentation improvements** — Clearer explanations and examples
|
||||
|
||||
## 🚫 Unaccepted Contributions
|
||||
|
||||
- Adding complex dependencies (keep it lightweight)
|
||||
- Features violating privacy or laws
|
||||
- Breaking existing API changes (unless well justified)
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
Have questions? Feel free to:
|
||||
- Create an Issue for discussion
|
||||
- Ask in Pull Request comments
|
||||
|
||||
---
|
||||
|
||||
Thank you for your contribution! 🙏
|
||||
@@ -0,0 +1,121 @@
|
||||
# Installation Guide
|
||||
|
||||
## 📦 Quick Installation
|
||||
|
||||
### 1. Clone or Download the Skill
|
||||
|
||||
```bash
|
||||
# Method 1: Using git clone (if public repo)
|
||||
git clone https://github.com/waisimon/playwright-scraper-skill.git
|
||||
cd playwright-scraper-skill
|
||||
|
||||
# Method 2: Download ZIP and extract
|
||||
# After downloading, enter the directory
|
||||
cd playwright-scraper-skill
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Install Playwright (recommended)
|
||||
npm install
|
||||
|
||||
# Install browser (Chromium)
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### 3. Test
|
||||
|
||||
```bash
|
||||
# Quick test
|
||||
node scripts/playwright-simple.js https://example.com
|
||||
|
||||
# Test Stealth version
|
||||
node scripts/playwright-stealth.js https://example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Advanced Installation
|
||||
|
||||
### Using with OpenClaw
|
||||
|
||||
If you're using OpenClaw, you can place this skill in the skills directory:
|
||||
|
||||
```bash
|
||||
# Assuming your OpenClaw workspace is at ~/.openclaw/workspace
|
||||
cp -r playwright-scraper-skill ~/.openclaw/workspace/skills/
|
||||
|
||||
# Then you can invoke it in OpenClaw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Verify Installation
|
||||
|
||||
Run the example script:
|
||||
|
||||
```bash
|
||||
# Discuss.com.hk example (verified working)
|
||||
bash examples/discuss-hk.sh
|
||||
```
|
||||
|
||||
If you see output similar to this, installation is successful:
|
||||
|
||||
```
|
||||
🕷️ Starting Playwright Stealth scraper...
|
||||
📱 Navigating to: https://m.discuss.com.hk/#hot
|
||||
📡 HTTP Status: 200
|
||||
✅ Scraping complete!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
### Issue: Playwright not found
|
||||
|
||||
**Error message:** `Error: Cannot find module 'playwright'`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### Issue: Browser launch failed
|
||||
|
||||
**Error message:** `browserType.launch: Executable doesn't exist`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### Issue: Permission errors
|
||||
|
||||
**Error message:** `Permission denied`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
chmod +x scripts/*.js
|
||||
chmod +x examples/*.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 System Requirements
|
||||
|
||||
- **Node.js:** v18+ recommended
|
||||
- **OS:** macOS / Linux / Windows
|
||||
- **Disk Space:** ~500MB (including Chromium)
|
||||
- **RAM:** 2GB+ recommended
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
After installation, check out:
|
||||
- [README.md](README.md) — Quick reference
|
||||
- [SKILL.md](SKILL.md) — Full documentation
|
||||
- [examples/](examples/) — Example scripts
|
||||
@@ -0,0 +1,187 @@
|
||||
# Playwright Scraper Skill 🕷️
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://nodejs.org/)
|
||||
[](https://playwright.dev/)
|
||||
|
||||
**[中文文檔](README_ZH.md)** | English
|
||||
|
||||
A Playwright-based web scraping OpenClaw Skill with anti-bot protection. Successfully tested on complex websites like Discuss.com.hk.
|
||||
|
||||
> 📦 **Installation:** See [INSTALL.md](INSTALL.md)
|
||||
> 📚 **Full Documentation:** See [SKILL.md](SKILL.md)
|
||||
> 💡 **Examples:** See [examples/README.md](examples/README.md)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- ✅ **Pure Playwright** — Modern, powerful, easy to use
|
||||
- ✅ **Anti-Bot Protection** — Hides automation, realistic UA
|
||||
- ✅ **Verified** — 100% success on Discuss.com.hk
|
||||
- ✅ **Simple to Use** — One-line commands
|
||||
- ✅ **Customizable** — Environment variable support
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Quick scraping
|
||||
node scripts/playwright-simple.js https://example.com
|
||||
|
||||
# Stealth mode (recommended)
|
||||
node scripts/playwright-stealth.js "https://m.discuss.com.hk/#hot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Two Modes
|
||||
|
||||
| Mode | Use Case | Speed | Anti-Bot |
|
||||
|------|----------|-------|----------|
|
||||
| **Simple** | Regular dynamic sites | Fast (3-5s) | None |
|
||||
| **Stealth** ⭐ | Sites with anti-bot | Medium (5-20s) | Medium-High |
|
||||
|
||||
### Simple Mode
|
||||
|
||||
For sites without anti-bot protection:
|
||||
|
||||
```bash
|
||||
node scripts/playwright-simple.js <URL>
|
||||
```
|
||||
|
||||
### Stealth Mode (Recommended)
|
||||
|
||||
For sites with Cloudflare or anti-bot protection:
|
||||
|
||||
```bash
|
||||
node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
**Anti-Bot Techniques:**
|
||||
- Hide `navigator.webdriver`
|
||||
- Realistic User-Agent (iPhone)
|
||||
- Human-like behavior simulation
|
||||
- Screenshot and HTML saving support
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Customization
|
||||
|
||||
All scripts support environment variables:
|
||||
|
||||
```bash
|
||||
# Show browser
|
||||
HEADLESS=false node scripts/playwright-stealth.js <URL>
|
||||
|
||||
# Custom wait time (milliseconds)
|
||||
WAIT_TIME=10000 node scripts/playwright-stealth.js <URL>
|
||||
|
||||
# Save screenshot
|
||||
SCREENSHOT_PATH=/tmp/page.png node scripts/playwright-stealth.js <URL>
|
||||
|
||||
# Save HTML
|
||||
SAVE_HTML=true node scripts/playwright-stealth.js <URL>
|
||||
|
||||
# Custom User-Agent
|
||||
USER_AGENT="Mozilla/5.0 ..." node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Test Results
|
||||
|
||||
| Website | Result | Time |
|
||||
|---------|--------|------|
|
||||
| **Discuss.com.hk** | ✅ 200 OK | 5-20s |
|
||||
| **Example.com** | ✅ 200 OK | 3-5s |
|
||||
| **Cloudflare Protected** | ✅ Mostly successful | 10-30s |
|
||||
|
||||
---
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
```
|
||||
playwright-scraper-skill/
|
||||
├── scripts/
|
||||
│ ├── playwright-simple.js # Simple mode
|
||||
│ └── playwright-stealth.js # Stealth mode ⭐
|
||||
├── examples/
|
||||
│ ├── discuss-hk.sh # Discuss.com.hk example
|
||||
│ └── README.md # More examples
|
||||
├── SKILL.md # Full documentation
|
||||
├── INSTALL.md # Installation guide
|
||||
├── README.md # This file
|
||||
├── README_ZH.md # Chinese documentation
|
||||
├── CONTRIBUTING.md # Contribution guide
|
||||
├── CHANGELOG.md # Version history
|
||||
└── package.json # npm config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Best Practices
|
||||
|
||||
1. **Try web_fetch first** — OpenClaw's built-in tool is fastest
|
||||
2. **Use Simple for dynamic sites** — When no anti-bot protection
|
||||
3. **Use Stealth for protected sites** ⭐ — Main workhorse
|
||||
4. **Use specialized skills** — For YouTube, Reddit, etc.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Getting 403 blocked?
|
||||
|
||||
Use Stealth mode:
|
||||
```bash
|
||||
node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### Cloudflare challenge?
|
||||
|
||||
Increase wait time + headful mode:
|
||||
```bash
|
||||
HEADLESS=false WAIT_TIME=30000 node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### Playwright not found?
|
||||
|
||||
Reinstall:
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
More issues? See [INSTALL.md](INSTALL.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - See [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Playwright Official Docs](https://playwright.dev/)
|
||||
- [Full Documentation (SKILL.md)](SKILL.md)
|
||||
- [Installation Guide (INSTALL.md)](INSTALL.md)
|
||||
- [Examples (examples/)](examples/)
|
||||
@@ -0,0 +1,184 @@
|
||||
# Playwright Scraper Skill 🕷️
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://nodejs.org/)
|
||||
[](https://playwright.dev/)
|
||||
|
||||
基於 Playwright 的網頁爬蟲 OpenClaw Skill。支援反爬保護,已驗證成功爬取 Discuss.com.hk 等複雜網站。
|
||||
|
||||
> 📦 **安裝方法:** 查看 [INSTALL.md](INSTALL.md)
|
||||
> 📚 **完整文件:** 查看 [SKILL.md](SKILL.md)
|
||||
> 💡 **使用範例:** 查看 [examples/README.md](examples/README.md)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特色
|
||||
|
||||
- ✅ **純 Playwright** — 現代、強大、易用
|
||||
- ✅ **反爬保護** — 隱藏自動化特徵、真實 UA
|
||||
- ✅ **已驗證** — Discuss.com.hk 100% 成功
|
||||
- ✅ **簡單易用** — 一行命令搞定
|
||||
- ✅ **可自訂** — 支援環境變數配置
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速開始
|
||||
|
||||
### 安裝
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### 使用
|
||||
|
||||
```bash
|
||||
# 快速爬取
|
||||
node scripts/playwright-simple.js https://example.com
|
||||
|
||||
# 反爬保護版(推薦)
|
||||
node scripts/playwright-stealth.js "https://m.discuss.com.hk/#hot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 兩種模式
|
||||
|
||||
| 模式 | 適用場景 | 速度 | 反爬能力 |
|
||||
|------|---------|------|----------|
|
||||
| **Simple** | 一般動態網站 | 快(3-5秒) | 無 |
|
||||
| **Stealth** ⭐ | 有反爬保護的網站 | 中(5-20秒) | 中高 |
|
||||
|
||||
### Simple 模式
|
||||
|
||||
適合沒有反爬保護的網站:
|
||||
|
||||
```bash
|
||||
node scripts/playwright-simple.js <URL>
|
||||
```
|
||||
|
||||
### Stealth 模式(推薦)
|
||||
|
||||
適合有 Cloudflare 或反爬保護的網站:
|
||||
|
||||
```bash
|
||||
node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
**反爬技巧:**
|
||||
- 隱藏 `navigator.webdriver`
|
||||
- 真實 User-Agent(iPhone)
|
||||
- 模擬真人行為
|
||||
- 支援截圖和 HTML 儲存
|
||||
|
||||
---
|
||||
|
||||
## 🎯 自訂參數
|
||||
|
||||
所有腳本都支援環境變數:
|
||||
|
||||
```bash
|
||||
# 顯示瀏覽器
|
||||
HEADLESS=false node scripts/playwright-stealth.js <URL>
|
||||
|
||||
# 自訂等待時間(毫秒)
|
||||
WAIT_TIME=10000 node scripts/playwright-stealth.js <URL>
|
||||
|
||||
# 儲存截圖
|
||||
SCREENSHOT_PATH=/tmp/page.png node scripts/playwright-stealth.js <URL>
|
||||
|
||||
# 儲存 HTML
|
||||
SAVE_HTML=true node scripts/playwright-stealth.js <URL>
|
||||
|
||||
# 自訂 User-Agent
|
||||
USER_AGENT="Mozilla/5.0 ..." node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 測試結果
|
||||
|
||||
| 網站 | 結果 | 時間 |
|
||||
|------|------|------|
|
||||
| **Discuss.com.hk** | ✅ 200 OK | 5-20 秒 |
|
||||
| **Example.com** | ✅ 200 OK | 3-5 秒 |
|
||||
| **Cloudflare 保護網站** | ✅ 多數成功 | 10-30 秒 |
|
||||
|
||||
---
|
||||
|
||||
## 📁 檔案結構
|
||||
|
||||
```
|
||||
playwright-scraper-skill/
|
||||
├── scripts/
|
||||
│ ├── playwright-simple.js # 簡單版
|
||||
│ └── playwright-stealth.js # Stealth 版 ⭐
|
||||
├── examples/
|
||||
│ ├── discuss-hk.sh # Discuss.com.hk 範例
|
||||
│ └── README.md # 更多範例
|
||||
├── SKILL.md # 完整文件
|
||||
├── INSTALL.md # 安裝指南
|
||||
├── README.md # 本檔案
|
||||
├── CONTRIBUTING.md # 貢獻指南
|
||||
├── CHANGELOG.md # 版本記錄
|
||||
└── package.json # npm 配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用建議
|
||||
|
||||
1. **先試 web_fetch** — OpenClaw 內建工具最快
|
||||
2. **動態網站用 Simple** — 沒有反爬保護時
|
||||
3. **反爬網站用 Stealth** ⭐ — 主力工具
|
||||
4. **特殊網站用專用 skill** — YouTube、Reddit 等
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排除
|
||||
|
||||
### 被 403 擋住?
|
||||
|
||||
使用 Stealth 模式:
|
||||
```bash
|
||||
node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### Cloudflare 挑戰?
|
||||
|
||||
增加等待時間 + 有頭模式:
|
||||
```bash
|
||||
HEADLESS=false WAIT_TIME=30000 node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### 找不到 Playwright?
|
||||
|
||||
重新安裝:
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
更多問題查看 [INSTALL.md](INSTALL.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 貢獻
|
||||
|
||||
歡迎貢獻!查看 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## 📄 授權
|
||||
|
||||
MIT License - 查看 [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相關連結
|
||||
|
||||
- [Playwright 官方文檔](https://playwright.dev/)
|
||||
- [完整文件 (SKILL.md)](SKILL.md)
|
||||
- [安裝指南 (INSTALL.md)](INSTALL.md)
|
||||
- [使用範例 (examples/)](examples/)
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
name: playwright-scraper-skill
|
||||
description: Playwright-based web scraping OpenClaw Skill with anti-bot protection. Successfully tested on complex sites like Discuss.com.hk.
|
||||
version: 1.2.0
|
||||
author: Simon Chan
|
||||
---
|
||||
|
||||
# Playwright Scraper Skill
|
||||
|
||||
A Playwright-based web scraping OpenClaw Skill with anti-bot protection. Choose the best approach based on the target website's anti-bot level.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Use Case Matrix
|
||||
|
||||
| Target Website | Anti-Bot Level | Recommended Method | Script |
|
||||
|---------------|----------------|-------------------|--------|
|
||||
| **Regular Sites** | Low | web_fetch tool | N/A (built-in) |
|
||||
| **Dynamic Sites** | Medium | Playwright Simple | `scripts/playwright-simple.js` |
|
||||
| **Cloudflare Protected** | High | **Playwright Stealth** ⭐ | `scripts/playwright-stealth.js` |
|
||||
| **YouTube** | Special | deep-scraper | Install separately |
|
||||
| **Reddit** | Special | reddit-scraper | Install separately |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
```bash
|
||||
cd playwright-scraper-skill
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1️⃣ Simple Sites (No Anti-Bot)
|
||||
|
||||
Use OpenClaw's built-in `web_fetch` tool:
|
||||
|
||||
```bash
|
||||
# Invoke directly in OpenClaw
|
||||
Hey, fetch me the content from https://example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ Dynamic Sites (Requires JavaScript)
|
||||
|
||||
Use **Playwright Simple**:
|
||||
|
||||
```bash
|
||||
node scripts/playwright-simple.js "https://example.com"
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"title": "Example Domain",
|
||||
"content": "...",
|
||||
"elapsedSeconds": "3.45"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Anti-Bot Protected Sites (Cloudflare etc.)
|
||||
|
||||
Use **Playwright Stealth**:
|
||||
|
||||
```bash
|
||||
node scripts/playwright-stealth.js "https://m.discuss.com.hk/#hot"
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Hide automation markers (`navigator.webdriver = false`)
|
||||
- Realistic User-Agent (iPhone, Android)
|
||||
- Random delays to mimic human behavior
|
||||
- Screenshot and HTML saving support
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ YouTube Video Transcripts
|
||||
|
||||
Use **deep-scraper** (install separately):
|
||||
|
||||
```bash
|
||||
# Install deep-scraper skill
|
||||
npx clawhub install deep-scraper
|
||||
|
||||
# Use it
|
||||
cd skills/deep-scraper
|
||||
node assets/youtube_handler.js "https://www.youtube.com/watch?v=VIDEO_ID"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Script Descriptions
|
||||
|
||||
### `scripts/playwright-simple.js`
|
||||
- **Use Case:** Regular dynamic websites
|
||||
- **Speed:** Fast (3-5 seconds)
|
||||
- **Anti-Bot:** None
|
||||
- **Output:** JSON (title, content, URL)
|
||||
|
||||
### `scripts/playwright-stealth.js` ⭐
|
||||
- **Use Case:** Sites with Cloudflare or anti-bot protection
|
||||
- **Speed:** Medium (5-20 seconds)
|
||||
- **Anti-Bot:** Medium-High (hides automation, realistic UA)
|
||||
- **Output:** JSON + Screenshot + HTML file
|
||||
- **Verified:** 100% success on Discuss.com.hk
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Best Practices
|
||||
|
||||
### 1. Try web_fetch First
|
||||
If the site doesn't have dynamic loading, use OpenClaw's `web_fetch` tool—it's fastest.
|
||||
|
||||
### 2. Need JavaScript? Use Playwright Simple
|
||||
If you need to wait for JavaScript rendering, use `playwright-simple.js`.
|
||||
|
||||
### 3. Getting Blocked? Use Stealth
|
||||
If you encounter 403 or Cloudflare challenges, use `playwright-stealth.js`.
|
||||
|
||||
### 4. Special Sites Need Specialized Skills
|
||||
- YouTube → deep-scraper
|
||||
- Reddit → reddit-scraper
|
||||
- Twitter → bird skill
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Customization
|
||||
|
||||
All scripts support environment variables:
|
||||
|
||||
```bash
|
||||
# Set screenshot path
|
||||
SCREENSHOT_PATH=/path/to/screenshot.png node scripts/playwright-stealth.js URL
|
||||
|
||||
# Set wait time (milliseconds)
|
||||
WAIT_TIME=10000 node scripts/playwright-simple.js URL
|
||||
|
||||
# Enable headful mode (show browser)
|
||||
HEADLESS=false node scripts/playwright-stealth.js URL
|
||||
|
||||
# Save HTML
|
||||
SAVE_HTML=true node scripts/playwright-stealth.js URL
|
||||
|
||||
# Custom User-Agent
|
||||
USER_AGENT="Mozilla/5.0 ..." node scripts/playwright-stealth.js URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance Comparison
|
||||
|
||||
| Method | Speed | Anti-Bot | Success Rate (Discuss.com.hk) |
|
||||
|--------|-------|----------|-------------------------------|
|
||||
| web_fetch | ⚡ Fastest | ❌ None | 0% |
|
||||
| Playwright Simple | 🚀 Fast | ⚠️ Low | 20% |
|
||||
| **Playwright Stealth** | ⏱️ Medium | ✅ Medium | **100%** ✅ |
|
||||
| Puppeteer Stealth | ⏱️ Medium | ✅ Medium-High | ~80% |
|
||||
| Crawlee (deep-scraper) | 🐢 Slow | ❌ Detected | 0% |
|
||||
| Chaser (Rust) | ⏱️ Medium | ❌ Detected | 0% |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Anti-Bot Techniques Summary
|
||||
|
||||
Lessons learned from our testing:
|
||||
|
||||
### ✅ Effective Anti-Bot Measures
|
||||
1. **Hide `navigator.webdriver`** — Essential
|
||||
2. **Realistic User-Agent** — Use real devices (iPhone, Android)
|
||||
3. **Mimic Human Behavior** — Random delays, scrolling
|
||||
4. **Avoid Framework Signatures** — Crawlee, Selenium are easily detected
|
||||
5. **Use `addInitScript` (Playwright)** — Inject before page load
|
||||
|
||||
### ❌ Ineffective Anti-Bot Measures
|
||||
1. **Only changing User-Agent** — Not enough
|
||||
2. **Using high-level frameworks (Crawlee)** — More easily detected
|
||||
3. **Docker isolation** — Doesn't help with Cloudflare
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Issue: 403 Forbidden
|
||||
**Solution:** Use `playwright-stealth.js`
|
||||
|
||||
### Issue: Cloudflare Challenge Page
|
||||
**Solution:**
|
||||
1. Increase wait time (10-15 seconds)
|
||||
2. Try `headless: false` (headful mode sometimes has higher success rate)
|
||||
3. Consider using proxy IPs
|
||||
|
||||
### Issue: Blank Page
|
||||
**Solution:**
|
||||
1. Increase `waitForTimeout`
|
||||
2. Use `waitUntil: 'networkidle'` or `'domcontentloaded'`
|
||||
3. Check if login is required
|
||||
|
||||
---
|
||||
|
||||
## 📝 Memory & Experience
|
||||
|
||||
### 2026-02-07 Discuss.com.hk Test Conclusions
|
||||
- ✅ **Pure Playwright + Stealth** succeeded (5s, 200 OK)
|
||||
- ❌ Crawlee (deep-scraper) failed (403)
|
||||
- ❌ Chaser (Rust) failed (Cloudflare)
|
||||
- ❌ Puppeteer standard failed (403)
|
||||
|
||||
**Best Solution:** Pure Playwright + anti-bot techniques (framework-independent)
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Future Improvements
|
||||
|
||||
- [ ] Add proxy IP rotation
|
||||
- [ ] Implement cookie management (maintain login state)
|
||||
- [ ] Add CAPTCHA handling (2captcha / Anti-Captcha)
|
||||
- [ ] Batch scraping (parallel URLs)
|
||||
- [ ] Integration with OpenClaw's `browser` tool
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
- [Playwright Official Docs](https://playwright.dev/)
|
||||
- [puppeteer-extra-plugin-stealth](https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth)
|
||||
- [deep-scraper skill](https://clawhub.com/opsun/deep-scraper)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7abhyrdgfj6r0wmtzdb1bxj181n3m5",
|
||||
"slug": "playwright-scraper-skill-1-2-0",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1771796187545
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
# Usage Examples
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### 1. Quick Scrape (Example.com)
|
||||
|
||||
```bash
|
||||
node scripts/playwright-simple.js https://example.com
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"title": "Example Domain",
|
||||
"url": "https://example.com/",
|
||||
"content": "Example Domain\n\nThis domain is for use...",
|
||||
"metaDescription": "",
|
||||
"elapsedSeconds": "3.42"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Anti-Bot Protected Site (Discuss.com.hk)
|
||||
|
||||
```bash
|
||||
node scripts/playwright-stealth.js "https://m.discuss.com.hk/#hot"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"title": "香港討論區 discuss.com.hk",
|
||||
"url": "https://m.discuss.com.hk/#hot",
|
||||
"htmlLength": 186345,
|
||||
"contentPreview": "...",
|
||||
"cloudflare": false,
|
||||
"screenshot": "./screenshot-1770467444364.png",
|
||||
"data": {
|
||||
"links": [
|
||||
{
|
||||
"text": "區議員周潔瑩疑消防通道違泊 道歉稱急於搬貨",
|
||||
"href": "https://m.discuss.com.hk/index.php?action=thread&tid=32148378..."
|
||||
}
|
||||
]
|
||||
},
|
||||
"elapsedSeconds": "19.59"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### 3. Custom Wait Time
|
||||
|
||||
```bash
|
||||
WAIT_TIME=15000 node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### 4. Show Browser (Debug Mode)
|
||||
|
||||
```bash
|
||||
HEADLESS=false node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### 5. Save Screenshot and HTML
|
||||
|
||||
```bash
|
||||
SCREENSHOT_PATH=/tmp/my-page.png \
|
||||
SAVE_HTML=true \
|
||||
node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### 6. Custom User-Agent
|
||||
|
||||
```bash
|
||||
USER_AGENT="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
|
||||
node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Using in Shell Scripts
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Run from playwright-scraper-skill directory
|
||||
|
||||
URL="https://example.com"
|
||||
OUTPUT_FILE="result.json"
|
||||
|
||||
echo "🕷️ Starting scrape: $URL"
|
||||
|
||||
node scripts/playwright-stealth.js "$URL" > "$OUTPUT_FILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Success! Results saved to: $OUTPUT_FILE"
|
||||
else
|
||||
echo "❌ Failed"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Batch Scraping Multiple URLs
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
URLS=(
|
||||
"https://example.com"
|
||||
"https://example.org"
|
||||
"https://example.net"
|
||||
)
|
||||
|
||||
for url in "${URLS[@]}"; do
|
||||
echo "Scraping: $url"
|
||||
node scripts/playwright-stealth.js "$url" > "output_$(date +%s).json"
|
||||
sleep 5 # Avoid IP blocking
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Calling from Node.js
|
||||
|
||||
```javascript
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
function scrape(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('node', [
|
||||
'scripts/playwright-stealth.js',
|
||||
url
|
||||
]);
|
||||
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
// Extract JSON (last line)
|
||||
const lines = output.trim().split('\n');
|
||||
const json = JSON.parse(lines[lines.length - 1]);
|
||||
resolve(json);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Exit code: ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Usage
|
||||
(async () => {
|
||||
const result = await scrape('https://example.com');
|
||||
console.log(result.title);
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scraping News Articles
|
||||
|
||||
```bash
|
||||
node scripts/playwright-stealth.js "https://news.example.com/article/123"
|
||||
```
|
||||
|
||||
### Scraping E-commerce Products
|
||||
|
||||
```bash
|
||||
WAIT_TIME=10000 \
|
||||
SAVE_HTML=true \
|
||||
node scripts/playwright-stealth.js "https://shop.example.com/product/456"
|
||||
```
|
||||
|
||||
### Scraping Forum Posts
|
||||
|
||||
```bash
|
||||
node scripts/playwright-stealth.js "https://forum.example.com/thread/789"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Page Not Fully Loaded
|
||||
|
||||
**Solution:** Increase wait time
|
||||
```bash
|
||||
WAIT_TIME=20000 node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### Issue: Still Blocked by Cloudflare
|
||||
|
||||
**Solution:** Use headful mode + manual wait
|
||||
```bash
|
||||
HEADLESS=false \
|
||||
WAIT_TIME=30000 \
|
||||
node scripts/playwright-stealth.js <URL>
|
||||
```
|
||||
|
||||
### Issue: Requires Login
|
||||
|
||||
**Solution:** Manually login first, export cookies, then load
|
||||
(Future feature, currently not supported)
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Parallel scraping:** Use `Promise.all()` or shell `&`
|
||||
2. **Delay requests:** `sleep 5` to avoid IP blocking
|
||||
3. **Use proxies:** Rotate IPs (future feature)
|
||||
4. **Cache results:** Avoid duplicate scraping
|
||||
|
||||
---
|
||||
|
||||
For more information, see [SKILL.md](../SKILL.md)
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# 範例:爬取 Discuss.com.hk 熱門話題
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "🕷️ Discuss.com.hk 爬蟲範例"
|
||||
echo ""
|
||||
echo "使用 Playwright Stealth(已驗證成功)"
|
||||
echo ""
|
||||
|
||||
cd "$SKILL_DIR" && \
|
||||
WAIT_TIME=10000 \
|
||||
SCREENSHOT_PATH=/tmp/discuss-hk.png \
|
||||
SAVE_HTML=true \
|
||||
node scripts/playwright-stealth.js "https://m.discuss.com.hk/#hot"
|
||||
+1
@@ -0,0 +1 @@
|
||||
../playwright/cli.js
|
||||
+1
@@ -0,0 +1 @@
|
||||
../playwright-core/cli.js
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "playwright-scraper-skill",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Playwright
|
||||
Copyright (c) Microsoft Corporation
|
||||
|
||||
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user