技能备份 - 2026-04-15 (40个技能)

This commit is contained in:
root
2026-04-15 18:53:15 +08:00
parent f62f14814f
commit c65fce24e4
791 changed files with 190773 additions and 0 deletions
+71
View File
@@ -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"
}
}
}
+63
View File
@@ -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
+328
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn72ce44tqw8bnnnewrn1s5x3s7yz7sq",
"slug": "agent-browser",
"version": "0.2.0",
"publishedAt": 1768882342488
}
+85
View File
@@ -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
+6
View File
@@ -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
}
+34
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn73pggwgch91znm1x7sjpfc5581jeen",
"slug": "competitor-analyzer",
"version": "1.0.0",
"publishedAt": 1771785543875
}
+88
View File
@@ -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*
+44
View File
@@ -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.
+67
View File
@@ -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).
+26
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn7apafdj4thknczrgxdzfd2v1808svf",
"slug": "feishu-card",
"version": "1.4.11",
"publishedAt": 1771169197365
}
+33
View File
@@ -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 };
+1
View File
@@ -0,0 +1 @@
module.exports = require('./send.js');
+38
View File
@@ -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"
}
}
}
}
+16
View File
@@ -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"
}
}
+324
View File
@@ -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);
}
})();
}
+93
View File
@@ -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();
+53
View File
@@ -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) {}
}
+38
View File
@@ -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!');
+343
View File
@@ -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 组合使用,把群聊变成工作台
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn794q0pnh9evxszw7bkkxgff580qpqf",
"slug": "feishu-im",
"version": "1.0.0",
"publishedAt": 1770938949904
}
+15
View File
@@ -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.
+32
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn7apafdj4thknczrgxdzfd2v1808svf",
"slug": "feishu-post",
"version": "1.1.7",
"publishedAt": 1771169184529
}
+22
View File
@@ -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();
+111
View File
@@ -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",
};
+48
View File
@@ -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();
}
+595
View File
@@ -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
}
}
}
}
}
+11
View File
@@ -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"
}
}
+195
View File
@@ -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 };
+13
View File
@@ -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 };
+132
View File
@@ -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
+143
View File
@@ -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
+300
View File
@@ -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.
只需要提供 OpenIDtenant_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 / 索引文件路径是否正确
+486
View File
@@ -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. **发布新版本(关键!)**
- 开通后,点击页面右上角的 **"发布"** 按钮
- 等待发布完成(显示"已发布"状态)
- ⚠️ **不发布的话,权限不会生效!**
---
## 💬 请回复配置信息
请按以下格式回复:
```
配置OpenIDou_你的OpenID
权限已开通并发布:是
```
例如:
```
配置OpenIDou_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"
]
@@ -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}
+148
View File
@@ -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"
]
}
}
+193
View File
@@ -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
}
+133
View File
@@ -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
```
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn77ajmmqw3cgnc3ay1x3e0ccd805hsw",
"slug": "find-skills",
"version": "0.1.0",
"publishedAt": 1769698710765
}
+79
View File
@@ -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.21 秒音频约需 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"
+47
View 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)"'
```
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
"slug": "github",
"version": "1.0.0",
"publishedAt": 1767545344344
}
+398
View File
@@ -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.
+6
View File
@@ -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]
```
+130
View File
@@ -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 youre 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
```
+6
View File
@@ -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()
+156
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
"slug": "notion",
"version": "1.0.0",
"publishedAt": 1767545360889
}
+55
View File
@@ -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 dont 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 youve 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.). Dont 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.
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
"slug": "obsidian",
"version": "1.0.0",
"publishedAt": 1767545362143
}
+45
View File
@@ -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.
+45
View File
@@ -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/` 目录下。
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn7cpqbnatvf5664znnnamfg6s81jzv4",
"slug": "ocr-python",
"version": "1.0.0",
"publishedAt": 1771704541442
}
+111
View File
@@ -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()
+19
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
"slug": "openai-whisper",
"version": "1.0.0",
"publishedAt": 1767545365755
}
+158
View File
@@ -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 🕷️
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
[![Playwright](https://img.shields.io/badge/Playwright-1.40+-blue.svg)](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 🕷️
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
[![Playwright](https://img.shields.io/badge/Playwright-1.40+-blue.svg)](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-AgentiPhone
- 模擬真人行為
- 支援截圖和 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
View File
@@ -0,0 +1 @@
../playwright/cli.js
+1
View File
@@ -0,0 +1 @@
../playwright-core/cli.js
+38
View File
@@ -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"
}
}
}
}
@@ -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.
@@ -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