Skills
9929 foundAgent Skills are multi-file prompts that give AI agents specialized capabilities. They include instructions, configurations, and supporting files that can be used with Claude, Cursor, Windsurf, and other AI coding assistants.
Reframe a player's current situation to reveal new meaning, goals, roles, or playstyles without changing the underlying mechanics. Use when diagnosing stagna...
--- name: game-design-player-perspective-reframe description: Reframe a player's current situation to reveal new meaning, goals, roles, or playstyles without changing the underlying mechanics. Use when diagnosing stagnation, boredom, or mid/late-game disengagement; when designing re-engagement prompts, adaptive guidance, or dynamic missions; or when a player is technically able to continue but no longer sees the current state as interesting, valuable, or purposeful. --- # Game Design Player Perspective Reframe Reframe a player's current situation so the same game state can be interpreted through a more motivating lens. Use this skill when the player is not blocked by a fundamentally broken system, but by a stale interpretation of what their situation means or what kind of play is currently available to them. ## Core principle Sometimes the problem is not lack of content, but lack of meaning. A player can have options available and still feel stuck because they are reading the current state through an exhausted frame: "I cannot grow," "I am behind," "nothing is happening," or "this part is just waiting." Reframing changes the interpretation of the state so a new kind of goal, role, or challenge becomes visible. ## What to produce Generate: 1. **Current state summary** - what the player is doing, wanting, and feeling 2. **Stagnation diagnosis** - why the current frame is no longer working 3. **Reframe options** - alternative ways to interpret the current state 4. **Chosen reframe** - the strongest new lens 5. **Action hook** - immediate next objective or prompt 6. **Expected effect** - why the reframe may restore interest, agency, or momentum 7. **Use-case judgment** - whether reframing is actually the right intervention, or whether the underlying system instead needs fixing ## Process ### 1. Define the stuck state Clarify: - what the player is trying to do - what they believe is the problem - what the system state actually looks like - what kind of disengagement is happening: boredom, frustration, aimlessness, repetition, self-comparison fatigue, etc. Write: - **Player state** - **Current goal** - **Why the current frame is failing** ### 2. Decide whether reframing is appropriate at all Before generating reframes, check whether the problem is truly interpretive rather than structural. Reframing is appropriate when: - the player has meaningful options, but does not currently value or notice them - the underlying systems are basically sound, but the player's current lens is exhausted - the game can support alternate self-directed goals without pretending the state is healthier than it is - the intervention is meant to extend or redirect engagement, not conceal a broken loop Reframing is not the right primary move when: - the system is actually opaque, unfair, or under-rewarding - the player lacks real agency or feasible next steps - the economy is over-constrained and the reframe would just romanticize waiting - frustration is caused by balance, UX, matchmaking, or monetization abuse If the issue is mostly structural, say so clearly and treat any reframe as secondary at best. ### 3. Diagnose the dominant stagnation pattern Common patterns: - **growth lock** - player only values expansion and cannot see value in consolidation - **efficiency fatigue** - player is optimizing mechanically but no longer feels purpose - **goal vacuum** - no compelling next objective is visible - **identity exhaustion** - player has overidentified with one role or playstyle - **failure fixation** - player reads current state only as a deficit or loss - **content blindness** - systems are present but the player does not recognize them as meaningful play ### 4. Choose a reframe strategy Use one or combine several: #### Role reframe Shift who the player is right now. Examples: - builder -> optimizer - collector -> curator - attacker -> steward - grinder -> planner #### Goal reframe Shift what success means. Examples: - expansion -> refinement - speed -> elegance - raw power -> consistency - completion -> experimentation #### Constraint reframe Turn a limitation into a challenge premise. Examples: - "What can you achieve with only your current tools?" - "Can you solve this with one district / one deck / one weapon class?" #### System reframe Reveal another layer of meaning already present in the same mechanics. Examples: - "This is not just waiting; this is production planning." - "This is not a content gap; it is a logistics puzzle." #### Narrative reframe Wrap the current state in story meaning. Examples: - recovery phase - rebuilding chapter - proving-ground moment - specialist mission #### Social reframe Redefine the current state through comparison, contribution, or recognition. Examples: - show off efficiency - mentor others - attempt a community challenge - compare style rather than speed ### 5. Generate multiple plausible reframes Produce at least three candidate reframes before choosing one. Each candidate should include: - new interpretation - why it fits the current state - what kind of player it is most likely to help - risk of backfiring ### 6. Select the best reframe Pick the reframe most likely to: - restore agency - make the current state feel meaningful - create an immediate next step - fit the player's likely values - avoid lying about a broken system Important: do not use reframing to excuse an actually broken or abusive loop. If the system is fundamentally busted, say so. ### 7. Attach an action hook The reframe must point to a concrete next move. Examples: - optimize output using only current buildings - redesign one district around beauty instead of income - complete a self-imposed low-resource challenge - treat the next three sessions as a scouting-and-planning phase - focus on one underused system and master it Without an action hook, the reframe stays abstract and weak. ### 8. State the expected effect The expected effect should be modest and believable. Good targets: - renewed curiosity - restored short-term agency - lower self-defeating frustration - better recognition of alternate goals already present in the system - a temporary bridge from stale play to fresher play Bad targets: - masking a broken progression wall - making players accept exploitative friction - pretending a starved content phase is secretly rich ### 9. State the use-case judgment Conclude with a blunt judgment: - **Strong fit for reframing** - **Partial fit; system fixes matter more** - **Weak fit; this is mostly a structural problem** Say why. Explain what the reframe is trying to change: - restore curiosity - reduce frustration by changing success criteria - open a new playstyle identity - create a short-term challenge layer - transform waiting into anticipation or planning ## Response structure ### Current State Summary - ... ### Stagnation Diagnosis - ... ### Reframe Options 1. ... 2. ... 3. ... ### Chosen Reframe - ... ### Action Hook - ... ### Expected Effect - ... ### Use-Case Judgment - ... ## Fast mode Use this quick pass when speed matters: - what is the player currently trying to do? - is the problem interpretive or structural? - why does the current frame feel dead? - what other role, goal, or lens could fit the same state? - what should the player do immediately under that new frame? ## Working principle A good reframe does not pretend the player's situation is different. It makes a different and more useful truth visible inside the same situation.
Generate QR codes from URLs or text. Export as PNG with customizable size. No API key required.
--- slug: qrcode-tool name: QR Code Generator description: "Generate QR codes from URLs or text. Export as PNG with customizable size. No API key required." keywords: qrcode, qr, barcode, generator, url, text version: "1.0.0" author: Qiance language: en --- # QR Code Generator Generate QR codes from any text or URL. Supports customization and exports as PNG format. ## Features - Generate QR codes from any text/URL - Custom size (default 300px) - Custom margin - Export as PNG format - No API key required ## Usage ```bash # Generate QR code for URL python3 scripts/qrcode_generator.py "https://example.com" # Generate QR code for text python3 scripts/qrcode_generator.py "Hello World" # Custom size python3 scripts/qrcode_generator.py "https://example.com" --size 500 ``` ## Examples ``` Generate QR code for: https://github.com Generate QR code for: Contact me at [email protected] Generate QR code for: WIFI:T:WPA;S:MyNetwork;P:password;; ``` ## Technical Details - Uses qrserver.com public API - SSL certificate verification enabled (certifi) - No sensitive data transmission ## Dependencies - Python 3.7+ - certifi (SSL certificates) ## Privacy Note Input text is sent to api.qrserver.com (third-party service). Not recommended for sensitive information. --- ## 中文说明 输入URL或文本,生成PNG二维码。 - 自定义尺寸(默认300px) - 无需API Key - 使用qrserver.com公开API FILE:README.md # QR Code Generator Generate QR codes from any text or URL with customizable options. ## Installation No installation required. Uses Python standard library + certifi for SSL. ```bash pip install certifi # Optional but recommended for SSL verification ``` ## Usage ### Basic Usage ```bash # Generate QR code for URL python3 scripts/qrcode_generator.py "https://example.com" # Generate QR code for text python3 scripts/qrcode_generator.py "Hello World" ``` ### Advanced Options ```bash # Custom size python3 scripts/qrcode_generator.py "https://example.com" --size 500 # Custom margin python3 scripts/qrcode_generator.py "Hello" --margin 2 # Different format python3 scripts/qrcode_generator.py "Test" --format gif # JSON output python3 scripts/qrcode_generator.py "https://example.com" --json ``` ## Examples | Input | Use Case | |-------|----------| | `https://github.com` | Website URL | | `mailto:[email protected]` | Email link | | `tel:+1234567890` | Phone number | | `WIFI:T:WPA;S:MyNetwork;P:password;;` | WiFi credentials | | `Hello World` | Plain text | ## API Uses the free [qrserver.com API](https://api.qrserver.com). No API key required. ## Privacy ⚠️ Input text is sent to a third-party API (api.qrserver.com). Do not use for sensitive information like passwords or private keys. ## License MIT License FILE:scripts/qrcode_generator.py #!/usr/bin/env python3 """QR Code Generator - Generate QR codes from text/URL""" import sys import os import base64 import ssl import urllib.request import urllib.parse import argparse try: import certifi CERTIFI_AVAILABLE = True except ImportError: CERTIFI_AVAILABLE = False def generate_qrcode(text, size=300, margin=4, format='png'): """Generate QR code using qrserver.com API Args: text: Text or URL to encode size: QR code size in pixels (default 300) margin: Margin around QR code (default 4) format: Output format (default png) Returns: dict with success status and data/base64 or error message """ encoded_text = urllib.parse.quote(text) url = f"https://api.qrserver.com/v1/create-qr-code/?size={size}x{size}&margin={margin}&format={format}&data={encoded_text}" headers = { 'User-Agent': 'QRCode-Tool/1.0 (https://github.com/qiance)' } try: # SSL with certifi if available, fallback to default if CERTIFI_AVAILABLE: ctx = ssl.create_default_context() ctx.load_verify_locations(certifi.where()) else: ctx = ssl.create_default_context() req = urllib.request.Request(url, headers=headers) response = urllib.request.urlopen(req, timeout=15, context=ctx) data = response.read() return { "success": True, "data": f"data:image/{format};base64,{base64.b64encode(data).decode()}", "url": url, "size": size, "text_length": len(text) } except Exception as e: return {"success": False, "error": str(e)} def main(): parser = argparse.ArgumentParser( description='Generate QR codes from text or URLs', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python3 qrcode_generator.py "https://example.com" python3 qrcode_generator.py "Hello World" --size 500 python3 qrcode_generator.py "WIFI:T:WPA;S:MyNetwork;P:password;;" --margin 2 """ ) parser.add_argument('text', help='Text or URL to encode') parser.add_argument('--size', type=int, default=300, help='QR code size in pixels (default: 300)') parser.add_argument('--margin', type=int, default=4, help='Margin around QR code (default: 4)') parser.add_argument('--format', choices=['png', 'gif', 'jpeg', 'jpg'], default='png', help='Output format (default: png)') parser.add_argument('--json', action='store_true', help='Output as JSON') args = parser.parse_args() result = generate_qrcode(args.text, args.size, args.margin, args.format) if args.json: import json print(json.dumps(result, indent=2)) else: if result["success"]: print(f"✅ QR Code generated successfully!") print(f" Size: {result['size']}x{result['size']} pixels") print(f" Text length: {result['text_length']} characters") print(f" Base64 length: {len(result['data'])} characters") else: print(f"❌ Failed to generate QR code: {result['error']}") if __name__ == '__main__': main()
Generates complete promotional emails with optimized subject lines, preview text, body copy, CTAs, and legal compliance for various campaign types.
# Promotional Email Writer ## Purpose This skill generates complete promotional email copy for marketing campaigns — subject lines, preview text, body copy, and CTAs — across multiple campaign types: product launches, flash sales, abandoned cart recovery, newsletters, seasonal campaigns, and email drip sequences. Every output is structured for conversion and includes CAN-SPAM/GDPR compliance checks. Unlike social media skills, this is purpose-built for the email channel with its unique constraints: preview pane optimization, deliverability concerns, and legal compliance requirements. ## Triggers - "写营销邮件" - "promotional email" - "email subject line" - "abandoned cart email" - "newsletter copy" - "邮件营销" - "email drip sequence" - "邮件A/B测试" - "促销邮件" - "email campaign" ## Workflow 1. Receive campaign context from user: campaign type (launch/sale/abandoned cart/newsletter/seasonal), product details, target audience, and email goal. 2. Generate subject line(s) optimized for open rate: under 50 characters, preview-pane friendly, no deceptive language. 3. Write preview text that complements (not repeats) the subject line. 4. Structure body copy for scannability: headline → greeting → hook paragraph → product value → offer details → urgency (ethical) → CTA button → footer. 5. Craft primary CTA button copy with clear action language. 6. Include unsubscribe mechanism language and sender identity in footer. 7. Run compliance review: deceptive subject line check, missing unsubscribe check, misleading claim check. 8. Deliver complete email ready for ESP (Email Service Provider) upload. ## Prompt Templates ### 1. Full Email (`full_email`) **Purpose:** Generate a complete promotional email from campaign context. **Input:** - `campaign_type` — launch / flash_sale / seasonal / newsletter / re-engagement - `product_name` — Product or offer - `promotion_details` — Discount, bundle, or offer specifics - `target_audience` — Subscriber segment - `brand_voice` — Tone: formal / casual / playful / luxury **Output:** Complete email: Subject Line | Preview Text | Body Copy (with sections) | CTA Button | Footer (with unsubscribe). ### 2. Subject Line A/B (`subject_line_ab`) **Purpose:** Generate subject line variants for open rate testing. **Input:** - `campaign_context` — Brief campaign description - `audience_segment` — Who is receiving - `count` — How many variants (default 5) **Output:** 5 subject lines labeled by approach (curiosity, benefit, urgency, personalization, question) with character counts and predicted open-rate rationale. ### 3. Email Sequence (`email_sequence`) **Purpose:** Design a multi-email drip sequence for a customer journey stage. **Input:** - `journey_stage` — Welcome / Nurture / Abandoned cart / Post-purchase / Win-back - `product_name` — Product or brand - `sequence_length` — Number of emails (typically 3–5) **Output:** Email sequence table: Email # | Timing | Subject | Body Summary | CTA | Goal. ### 4. Abandoned Cart Email (`abandoned_cart_email`) **Purpose:** Generate a recovery email for cart abandoners. **Input:** - `product_name` — Item(s) left in cart - `cart_value` — Total cart value - `abandonment_window` — Hours since abandonment - `incentive` — Optional discount or free shipping offer **Output:** Recovery email with: gentle reminder subject, product image description placeholders, benefit recap, urgency (if incentive), CTA back to cart. ### 5. Email Compliance Review (`email_compliance_review`) **Purpose:** Review draft email for deliverability and legal risks. **Input:** - `email_draft` — Complete email: subject + body + footer - `target_region` — GDPR (EU), CAN-SPAM (US), CASL (Canada), or PIPL (China) **Output:** Compliance report: Check | Status (Pass/Flag) | Issue | Suggested Fix. ## Output Format Every full email follows this deliverable structure: ``` SUBJECT LINE: [under 50 chars] PREVIEW TEXT: [complements subject, under 100 chars] [BODY] Header/Logo space Headline Greeting Hook paragraph Product/Offer section Social proof (if applicable) CTA Button → [Button text] Urgency/Scarcity note (ethical) Closing [FOOTER] Unsubscribe link language Company info Privacy policy link ``` ## Safety Rules - **NEVER** write deceptive subject lines (e.g., "Re: Your order" when it's not a reply, fake "Urgent" flags) - **NEVER** make misleading discount claims or hidden conditions - **NEVER** omit unsubscribe mechanism language — it must be clearly present - **ALWAYS** include proper sender identity (company name, physical address for CAN-SPAM) - **ALWAYS** remind user about GDPR consent requirements for EU subscribers - **ALWAYS** flag potential spam-trigger words in subject lines (e.g., "FREE!!!", "ACT NOW!!!") ## Examples ### Example 1: Full Email for Flash Sale **Input:** Campaign="618大促", Product="XX护肤品套装", Discount="满300减50", Audience="女性25-40岁", Voice="亲切温暖" **Output:** Subject "你的618专属护肤清单来了 ✨", preview "满300减50,这套搭配我们准备了很久", body with hero image placeholder, product trio showcase, discount breakdown, countdown urgency, CTA "立即抢购", full footer. ### Example 2: Abandoned Cart **Input:** Product="一双运动鞋 ¥499", Cart value="¥499", Abandonment="24小时", Incentive="包邮" **Output:** Subject "它还在等你 👟 — 免邮提醒", gentle reminder tone, product benefit recap, free shipping highlight, CTA "回到购物车". ## Related Skills - [ad-copy-ab-tester](../ad-copy-ab-tester/) — For ad copy variants (paid channel vs. owned email) - [social-caption-kit](../social-caption-kit/) — For social media promotion of the same campaign - [landing-page-copy-pro](../landing-page-copy-pro/) — For the landing page that email CTAs link to FILE:ACCEPTANCE.md # Acceptance Criteria — Promotional Email Writer - [ ] SKILL.md is self-contained (agent can operate from it alone) - [ ] All 5 prompt templates are complete with `placeholder` inputs - [ ] Safety rules address CAN-SPAM, GDPR, deceptive subjects, and unsubscribe compliance - [ ] README.md has clear install instructions + 3 usage examples - [ ] skill.json is valid JSON with all required fields - [ ] Content is unique — email channel structure differs from social/ad skills - [ ] Email sequence, abandoned cart, and compliance review are distinct features - [ ] Slugs follow naming convention (user-facing, no prefix codes) FILE:README.md # Promotional Email Writer Complete marketing email copy — subject lines, preview text, body, and CTAs for every campaign type. ## Features - Full email generation for launches, flash sales, newsletters, seasonal campaigns - Subject line A/B variants with predicted performance rationale - Email drip sequence design for customer journey stages - Abandoned cart recovery email templates - CAN-SPAM/GDPR compliance review built-in - Conversion-optimized CTA and body structure ## Install ``` openclaw skills install harrylabsj/promo-email-writer ``` ## Usage ``` 写一封618大促的营销邮件,产品是XX品牌的护肤品套装,满300减50 为这封邮件生成5个不同的标题做A/B测试 写一封弃购挽回邮件,用户加了购物车24小时没付款 设计一个3封邮件的欢迎序列,产品是SaaS订阅服务 ``` ## Platforms Email (Platform-Agnostic — works with any ESP) ## Safety CAN-SPAM compliant. No deceptive subject lines. Clear unsubscribe language. GDPR-aware. Honest offers with no hidden conditions. ## License MIT FILE:skill.json { "name": "Promotional Email Writer", "description": "Complete promotional email copy — subject lines, preview text, body copy, and CTAs for launches, flash sales, abandoned cart, newsletters, and seasonal campaigns. Conversion-focused with CAN-SPAM compliance.", "version": "1.0.0", "type": "prompt-flow", "category": "Marketing / Email Marketing", "keywords": [ "email copy", "promo email", "marketing email", "subject line", "abandoned cart", "newsletter", "email campaign", "CAN-SPAM", "email sequence", "营销邮件" ], "platforms": ["Email (Platform-Agnostic)"], "requires": {}, "requires_api": false, "author": "harrylabsj", "license": "MIT", "safety": { "no_code_execution": true, "no_network": true, "no_credentials": true, "compliance_notes": "CAN-SPAM/GDPR compliant subject lines — no deceptive openers. Clear unsubscribe mechanism language. No misleading discount claims. Proper sender identity. GDPR-aware data handling reminders." } }
Generates tailored, platform-optimized social media captions from one core brand message for WeChat, Weibo, Instagram, Facebook, Twitter/X, and LinkedIn.
# Multi-Platform Social Caption Kit ## Purpose This skill takes one core brand message or product brief and generates platform-optimized captions for six major social platforms simultaneously: WeChat Moments (朋友圈), Weibo, Instagram, Facebook, Twitter/X, and LinkedIn. Each caption is adapted to the platform's unique tone norms, length constraints, hashtag conventions, emoji culture, and audience expectations — while preserving a consistent brand voice. "Kit" signals a bundled, all-in-one caption package rather than single-platform generation. ## Triggers - "生成朋友圈文案" - "social caption pack" - "多平台配文" - "caption for all platforms" - "brand caption" - "社交媒体配文" - "cross-platform post" - "品牌配文" - "hashtag strategy" - "平台适配文案" ## Workflow 1. Receive the core message/product brief from user: what to communicate, brand voice description, campaign type, and which platforms to cover. 2. If brand voice is not specified, ask clarifying questions about tone, formality, and personality before generation. 3. Generate platform-adapted captions: - **WeChat Moments (朋友圈)**: Conversational, personal tone, 1–2 emoji, no hashtags, optional @mentions - **Weibo**: More public-facing, hashtag-heavy (#话题#), can be longer, trending topic integration - **Instagram**: Visual-first context, heavy emoji usage, hashtag block (up to 30), Story-friendly format - **Facebook**: Community-oriented, engagement-driving questions, link-friendly, longer form OK - **Twitter/X**: Concise within 280 chars, trending hashtag, thread-compatible - **LinkedIn**: Professional tone, thought-leadership framing, minimal hashtags (3–5) 4. Apply platform-specific best practices: link handling (URL placement differs), emoji density, @mention conventions, and hashtag norms. 5. Include a hashtag strategy section per platform: which hashtags, how many, and why. 6. Add engagement hooks appropriate to each platform's interaction patterns (questions, polls, CTAs). 7. Output as a unified caption pack with clear platform labels. ## Prompt Templates ### 1. Caption Pack (`caption_pack`) **Purpose:** Generate cross-platform captions from one core message. **Input:** - `core_message` — The key message or announcement - `brand_voice` — Tone descriptors (e.g., "warm humorous professional") - `media_type` — Text-only / image post / video post / carousel - `platforms` — Which platforms to generate for (default: all 6) **Output:** Platform-labeled caption pack with: Platform | Caption | Character Count | Hashtags | Engagement Hook. ### 2. Brand Voice Presets (`brand_voice_presets`) **Purpose:** Guide the user through defining a consistent brand voice, then generate sample captions. **Input:** - `brand_description` — Free-text brand personality (e.g., "a DTC skincare brand that feels like a knowledgeable older sister") - `sample_message` — One test message to generate sample captions for **Output:** Brand voice definition (3 adjectives + example sentences) + 3 platform-adapted sample captions in the defined voice. ### 3. Campaign Caption Suite (`campaign_caption_suite`) **Purpose:** Generate a multi-platform caption rollout for a campaign. **Input:** - `campaign_name` — Campaign name or theme - `campaign_duration` — Timeline (launch day, mid-campaign, closing) - `assets_available` — Types of media available (images, video, UGC) **Output:** Campaign caption calendar: Date/Phase | Platform | Caption | Media Note. ### 4. Platform Hashtag Strategy (`platform_hashtag_strategy`) **Purpose:** Generate a hashtag strategy tailored per platform for a given topic. **Input:** - `topic` — Content topic or product category - `target_platforms` — Which platforms need hashtag strategies **Output:** Per-platform hashtag sets: Platform | Niche Hashtags (3–5) | Broad Hashtags (2–3) | Trending (1–2) | Count Guidance. ### 5. Engagement Booster (`engagement_booster`) **Purpose:** Enhance an existing caption for higher engagement. **Input:** - `existing_caption` — Current caption text - `platform` — Platform it's intended for - `engagement_goal` — Comments/Shares/Saves/Clicks **Output:** Enhanced caption with: improved hook, engagement question, CTA, optimized hashtags, emoji placement. ## Output Format **Caption Pack format:** | Platform | Caption | Chars | Hashtags | Engagement | |----------|---------|-------|----------|------------| | WeChat Moments | 文案... | 120 | N/A | 互动问题 | Each caption is self-contained and ready to copy-paste into the respective platform. ## Safety Rules - **NEVER** suggest engagement bait tactics that violate platform TOS (e.g., "tag 3 friends to win") - **NEVER** create content that impersonates individuals or brands - **NEVER** use a fake persona or fabricated identity in brand voice - **ALWAYS** maintain authentic, human tone — the caption should sound like a real person wrote it - **ALWAYS** include disclosure reminders for sponsored/paid content - **ALWAYS** respect per-platform content policies, age restrictions, and sensitive topic rules ## Examples ### Example 1: Caption Pack for Product Launch **Input:** Core message="新品咖啡豆上市,单一产地哥伦比亚,中深烘", Brand voice="casual coffee nerd", Platforms="all 6" **Output:** Six captions: WeChat Moments (day-in-life style), Weibo (hashtag-heavy announcement), Instagram (visual tasting notes), Facebook (community question), Twitter/X (sharp one-liner), LinkedIn (sourcing story with professional angle). ### Example 2: Brand Voice Presets **Input:** Brand="婴儿护肤品牌,走成分安全、妈妈放心路线", Test message="新品婴儿润肤乳上市" **Output:** Brand voice defined as "gentle, knowledgeable, reassuring" with sample captions demonstrating each tone. ## Related Skills - [viral-xiaohongshu-notes](../viral-xiaohongshu-notes/) — For Xiaohongshu-specific content (platform-native format) - [ad-copy-ab-tester](../ad-copy-ab-tester/) — For paid ad copy (different intent: ads vs. organic) - [promo-email-writer](../promo-email-writer/) — For email channel (different medium) FILE:ACCEPTANCE.md # Acceptance Criteria — Multi-Platform Social Caption Kit - [ ] SKILL.md is self-contained (agent can operate from it alone) - [ ] All 5 prompt templates are complete with `placeholder` inputs - [ ] Safety rules address platform TOS, authentic voice, and disclosure - [ ] README.md has clear install instructions + 3 usage examples - [ ] skill.json is valid JSON with all required fields - [ ] Content is unique — cross-platform caption pack structure differs from single-platform skills - [ ] Brand voice presets feature is a differentiator not present in other skills - [ ] Slugs follow naming convention (user-facing, no prefix codes) FILE:README.md # Multi-Platform Social Caption Kit One message → six platform-optimized captions. Post everywhere without sounding the same everywhere. ## Features - Generate captions for WeChat Moments, Weibo, Instagram, Facebook, Twitter/X, and LinkedIn simultaneously - Preserve consistent brand voice across platforms while adapting tone - Built-in hashtag strategy per platform (count, type, placement) - Brand voice definition and preset generation - Campaign caption suite with timeline planning - Engagement booster for underperforming captions ## Install ``` openclaw skills install harrylabsj/social-caption-kit ``` ## Usage ``` 帮我为这款新上市的咖啡豆生成6个平台的配文,品牌调性是"专业但轻松的咖啡爱好者" 用"温暖陪伴"的品牌调性,为母亲节活动生成各平台配文 给这段Instagram配文增强互动性,目标是增加评论 帮我定义一下我的品牌声音,然后生成几个平台的示例配文 ``` ## Platforms WeChat Moments (朋友圈), Weibo, Instagram, Facebook, Twitter/X, LinkedIn ## Safety No engagement bait. No fake personas. Authentic brand voice. All sponsored content disclosure reminders included. ## License MIT FILE:skill.json { "name": "Multi-Platform Social Caption Kit", "description": "One message → optimized captions for 6 social platforms (WeChat Moments, Weibo, Instagram, Facebook, Twitter/X, LinkedIn). Preserves brand voice while adapting tone, length, hashtags, and CTAs per platform.", "version": "1.0.0", "type": "prompt-flow", "category": "Social Media Content / Multi-Platform", "keywords": [ "social caption", "朋友圈文案", "Weibo caption", "Instagram caption", "cross-platform", "brand voice", "hashtag strategy", "social media pack", "配文", "multi-platform" ], "platforms": ["WeChat Moments (朋友圈)", "Weibo", "Instagram", "Facebook", "Twitter/X", "LinkedIn"], "requires": {}, "requires_api": false, "author": "harrylabsj", "license": "MIT", "safety": { "no_code_execution": true, "no_network": true, "no_credentials": true, "compliance_notes": "No platform TOS violations (engagement bait, misleading content). Authentic tone — no fake persona or fabricated identity. Proper disclosure for sponsored/paid content. Respect per-platform content policies and restricted topics. No impersonation of individuals or brands." } }
Generates five labeled ad copy variants with distinct appeal angles and platform-specific compliance checks for structured A/B testing of paid ads.
# Ad Copy Variants for A/B Testing ## Purpose This skill generates systematic, labeled ad copy variants designed for structured A/B testing across paid advertising platforms. It produces five distinct appeal-angle variants per product — Emotional, Rational, Scarcity, Social Proof, and Problem-Solution — each formatted for the target platform's constraints and policies. A built-in compliance checker flags potential ad policy violations before launch. Designed for performance marketers and media buyers who need testable, measurable creative variations, not random copy suggestions. ## Triggers - "generate ad variants" - "A/B test ad copy" - "广告文案变体" - "ad copy ab test" - "create ad copy" - "广告A/B测试" - "multiple ad versions" - "ad variant matrix" - "headline bank" - "CTA optimizer" ## Workflow 1. Receive product info + target ad platform(s) from user: product name, key benefits, target audience, budget tier, and campaign goal. 2. Generate the 5-angle variant matrix: - **Emotional**: Tap into desire, aspiration, or joy - **Rational**: Feature-driven, logical, value-focused - **Scarcity**: Limited-time, limited-quantity (ethically constrained) - **Social Proof**: User numbers, ratings, endorsements (only if verifiable) - **Problem-Solution**: Pain point → product as solution 3. Apply platform-specific constraints: character limits (e.g., WeChat Moments: 40 chars headline; Google: 30/90/90), image-text ratio rules, and forbidden content categories. 4. Run a compliance check against the target platform's ad policies, flagging: prohibited claims, missing disclosures, superlatives without substantiation, sensitive categories. 5. Generate CTA alternatives for each variant — platform-appropriate and conversion-optimized. 6. Output the full variant matrix, labeled and ready for ad platform upload. ## Prompt Templates ### 1. Variant Matrix (`variant_matrix`) **Purpose:** Generate the full 5-angle A/B variant matrix. **Input:** - `product_name` — Product - `key_benefits` — 2–3 main benefits - `target_audience` — Demographic and psychographic - `platform` — Ad platform name - `campaign_goal` — Awareness/Consideration/Conversion **Output:** A labeled 5-variant table: Variant Label | Headline | Body/Description | CTA | Character Counts. ### 2. Ad Compliance Check (`ad_compliance_check`) **Purpose:** Review ad copy for platform-specific policy violations. **Input:** - `ad_copy_full` — Complete ad text (headline + body + CTA) - `platform` — Target ad platform - `product_category` — Product category (for restricted category checks) **Output:** Compliance report: Flag | Severity | Issue Description | Suggested Fix. ### 3. CTA Optimizer (`cta_optimizer`) **Purpose:** Generate alternative CTAs for existing ad copy. **Input:** - `ad_copy` — Existing ad body text - `platform` — Platform context - `goal` — Click/Conversion/Engagement **Output:** 3 CTA alternatives with rationale for each and platform-fit score. ### 4. Headline Bank (`headline_bank`) **Purpose:** Generate 10 headline angles for a product. **Input:** - `product_name` — Product - `target_audience` — Audience - `platform` — Platform (determines character limits) **Output:** 10 headlines labeled by angle type (curiosity, benefit, question, statistic, comparison, emotional, how-to, direct, testimonial, news) with character count. ### 5. Ad Fatigue Refresher (`ad_fatigue_refresher`) **Purpose:** Refresh an existing top-performing ad with new variants. **Input:** - `current_top_ad` — Currently best-performing ad copy - `performance_metric` — What metric (CTR/conversion) it leads on - `fatigue_signal` — Why refresh (frequency up, CTR dropping) **Output:** 3 refreshed variants that preserve winning elements but change angle, format, or CTA. ## Output Format All variants are delivered in a structured A/B test matrix: | Variant # | Angle Type | Headline | Body (truncated) | CTA | Expected Audience Response | |-----------|-----------|----------|------------------|-----|---------------------------| | A | Emotional | ... | ... | ... | ... | | B | Rational | ... | ... | ... | ... | Plus compliance flags table when requested. ## Safety Rules - **NEVER** include forbidden claims per platform ad policy (health guarantees, financial returns, weight loss promises) - **NEVER** use discriminatory, exclusionary, or exploitative language - **NEVER** include misleading before/after representations without verifiable data - **NEVER** use unsubstantiated superlatives ("best", "#1", "top-rated") unless independently verifiable - **ALWAYS** include required disclosures: "Ad", "Sponsored", "Promotion" per platform - **ALWAYS** flag sensitive product categories (health, finance, supplements) for extra review ## Examples ### Example 1: Variant Matrix for WeChat Moments **Input:** Product="在线英语课程", Audience="25-35岁职场人", Platform="WeChat Moments", Goal="Conversion" **Output:** 5 variants: Emotional ("遇见更好的自己"), Rational ("每天15分钟,3个月流利对话"), Scarcity ("限时优惠,仅剩200名额"), Social Proof ("10万+学员的选择"), Problem-Solution ("开会不敢开口?试试这个方法"). ### Example 2: Compliance Check **Input:** Ad copy with "100% guaranteed results in 7 days", Platform="Google Ads", Category="Education" **Output:** HIGH severity flag: absolute guarantee claim without substantiation. Suggested: "Join 10,000+ learners" instead. ## Related Skills - [social-caption-kit](../social-caption-kit/) — For organic social captions (not paid ads) - [promo-email-writer](../promo-email-writer/) — For email marketing variants (different channel) - [landing-page-copy-pro](../landing-page-copy-pro/) — For landing page copy that the ad links to FILE:ACCEPTANCE.md # Acceptance Criteria — Ad Copy Variants for A/B Testing - [ ] SKILL.md is self-contained (agent can operate from it alone) - [ ] All 5 prompt templates are complete with `placeholder` inputs - [ ] Safety rules address platform ad policies, forbidden claims, and required disclosures - [ ] README.md has clear install instructions + 3 usage examples - [ ] skill.json is valid JSON with all required fields - [ ] Content is unique — A/B test matrix format with labeled angles differs from all other skills - [ ] Compliance checker is a distinct, platform-aware feature not present in other skills - [ ] Slugs follow naming convention (user-facing, no prefix codes) FILE:README.md # Ad Copy Variants for A/B Testing Systematic ad copy generation — 5 labeled variants per product for structured A/B testing across major ad platforms. ## Features - 5-angle variant matrix: Emotional, Rational, Scarcity, Social Proof, Problem-Solution - Platform-specific formatting for WeChat, Douyin, Google, Facebook, Kuaishou - Built-in compliance checker with platform ad policy flagging - CTA optimizer with platform-fit scoring - Headline bank: 10 angle-labeled headlines per product - Ad fatigue refresher for creative rotation ## Install ``` openclaw skills install harrylabsj/ad-copy-ab-tester ``` ## Usage ``` 为这款产品生成5组微信朋友圈广告文案变体,分别用情感、理性、稀缺、社会证明、问题-解决角度 检查这段广告文案在抖音信息流是否合规 给我10个产品标题的广告角度,投Google Ads用 现有的广告效果下降了,帮我refresh 3个新版本 ``` ## Platforms WeChat Moments Ads, Douyin Feed Ads, Google Ads, Facebook/Instagram Ads, Kuaishou Ads ## Safety All variants respect platform ad policies. No forbidden claims, no discriminatory language, no unsubstantiated superlatives. Includes compliance flagging and disclosure reminders. ## License MIT FILE:skill.json { "name": "Ad Copy Variants for A/B Testing", "description": "Systematic A/B ad copy generation with labeled variants (emotional, rational, scarcity, social proof, problem-solution) across WeChat, Douyin, Google, Facebook, and Kuaishou ad platforms. Includes compliance checks.", "version": "1.0.0", "type": "prompt-flow", "category": "Advertising / Creative Copy", "keywords": [ "ad copy", "A/B test", "广告文案", "ad variant", "creative testing", "WeChat ad", "Douyin ad", "Google ad copy", "Facebook ad", "headline bank", "CTA optimization" ], "platforms": ["WeChat Moments Ads", "Douyin Feed Ads", "Google Ads", "Facebook/Instagram Ads", "Kuaishou Ads"], "requires": {}, "requires_api": false, "author": "harrylabsj", "license": "MIT", "safety": { "no_code_execution": true, "no_network": true, "no_credentials": true, "compliance_notes": "No forbidden claims per platform ad policy. No discriminatory or exclusionary language. No misleading before/after representations. No unsubstantiated superlatives without verification. Include required disclosures per platform." } }
Validate and extract info from Chinese ID card numbers (身份证). 身份证号码验证、归属地查询、出生日期提取、性别判断、年龄计算、15位转18位。China mainland ID card validator and parser.
---
name: China ID Validator
description: "Validate and extract info from Chinese ID card numbers (身份证). 身份证号码验证、归属地查询、出生日期提取、性别判断、年龄计算、15位转18位。China mainland ID card validator and parser."
tags: china, id, card, validator, identity, 身份证, chinese, utility, tool
---
# China ID Validator 🪪
中国居民身份证号码验证与信息提取工具。
## Features | 功能
- **号码验证**:15位/18位身份证合法性校验
- **信息提取**:省份、出生日期、性别、年龄
- **格式转换**:15位↔18位互转
- **校验码验证**:18位末位校验位验证
## Usage | 使用
```bash
# 验证身份证号
python3 scripts/id_validator.py 110101199003079
# 提取信息
python3 scripts/id_validator.py validate 110101199003079
# 生成测试号码(仅供测试)
python3 scripts/id_validator.py generate 11 1990 3 7 男
```
---
*免责声明:本工具仅供学习参考,不构成任何投资或商业建议。*
FILE:scripts/id_validator.py
#!/usr/bin/env python3
"""Chinese ID Card (身份证) Validator & Info Extractor"""
import sys
import json
import re
from datetime import datetime
PROVINCE_CODES = {
"11":"北京","12":"天津","13":"河北","14":"山西","15":"内蒙古",
"21":"辽宁","22":"吉林","23":"黑龙江","31":"上海","32":"江苏",
"33":"浙江","34":"安徽","35":"福建","36":"江西","37":"山东",
"41":"河南","42":"湖北","43":"湖南","44":"广东","45":"广西",
"46":"海南","50":"重庆","51":"四川","52":"贵州","53":"云南",
"54":"西藏","61":"陕西","62":"甘肃","63":"青海","64":"宁夏",
"65":"新疆","71":"台湾","81":"香港","82":"澳门","91":"国外"
}
WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
CHECK_CHARS = "10X98765432"
def validate(id_number):
"""Validate a Chinese ID card number (15 or 18 digits)"""
id_number = id_number.strip().upper()
if re.match(r'^\d{15}$', id_number):
return validate_15(id_number)
elif re.match(r'^\d{17}[\dX]$', id_number):
return validate_18(id_number)
else:
return {"valid": False, "error": "格式错误:应为15位纯数字或18位数字+X"}
def validate_15(id_number):
province = id_number[:2]
if province not in PROVINCE_CODES:
return {"valid": False, "error": f"无效省份代码: {province}"}
try:
datetime.strptime("19" + id_number[6:12], "%Y%m%d")
except ValueError:
return {"valid": False, "error": "无效出生日期"}
return {
"valid": True,
"type": "15位",
"province": PROVINCE_CODES[province],
"province_code": province,
"birthday": "19" + id_number[6:12],
"gender": "女" if int(id_number[14]) % 2 == 0 else "男",
"converted_18": convert_15_to_18(id_number)
}
def validate_18(id_number):
province = id_number[:2]
if province not in PROVINCE_CODES:
return {"valid": False, "error": f"无效省份代码: {province}"}
try:
birth = datetime.strptime(id_number[6:14], "%Y%m%d")
except ValueError:
return {"valid": False, "error": "无效出生日期"}
# Check checksum
total = sum(int(id_number[i]) * WEIGHTS[i] for i in range(17))
check = CHECK_CHARS[total % 11]
if check != id_number[17]:
return {"valid": False, "error": f"校验码错误:末位应为{check},实际为{id_number[17]}"}
age = (datetime.now() - birth).days // 365
return {
"valid": True,
"type": "18位",
"province": PROVINCE_CODES[province],
"province_code": province,
"birthday": id_number[6:14],
"age": age,
"gender": "女" if int(id_number[16]) % 2 == 0 else "男",
"checksum": id_number[17]
}
def convert_15_to_18(id15):
"""Convert 15-digit ID to 18-digit"""
id17 = "19" + id15[:6] + id15[6:]
total = sum(int(id17[i]) * WEIGHTS[i] for i in range(17))
return id17 + CHECK_CHARS[total % 11]
def generate(province_code, year, month, day, gender):
"""Generate a random valid ID number (for testing only)"""
import random
if province_code not in PROVINCE_CODES:
return {"error": f"无效省份代码: {province_code}"}
date_str = f"{year}{month.zfill(2)}{day.zfill(2)}"
try:
datetime.strptime(date_str, "%Y%m%d")
except ValueError:
return {"error": "无效日期"}
city_county = f"{random.randint(1,99):02d}{random.randint(1,99):02d}"
region = province_code + city_county[:4] # 6-digit region code
seq = random.randint(10, 99)
gender_digit = random.choice([d for d in range(10) if d % 2 == (0 if gender == "女" else 1)])
id17 = region + date_str + f"{seq}{gender_digit}"
total = sum(int(id17[i]) * WEIGHTS[i] for i in range(17))
return {"id_number": id17 + CHECK_CHARS[total % 11], "note": "仅供测试使用"}
def main():
if len(sys.argv) < 2:
print(json.dumps({"error": "用法: id_validator.py <身份证号|validate|generate>", "examples": [
"id_validator.py 110101199003077534",
"id_validator.py validate 110101199003077534",
"id_validator.py generate 11 1990 3 7 男"
]}, ensure_ascii=False, indent=2))
return
action = sys.argv[1]
if action == "generate" and len(sys.argv) >= 7:
result = generate(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6])
elif action in ("validate", "校验"):
if len(sys.argv) < 3:
result = {"error": "请提供身份证号"}
else:
result = validate(sys.argv[2])
else:
result = validate(action)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
Generate URL-friendly slugs from text. 将任意文本转换为SEO友好的URL别名,支持中英文混合、自动去除特殊字符。适合博客、电商、CMS系统。URL slug maker, permalink generator, URL safe string.
---
name: Slug Generator
description: "Generate URL-friendly slugs from text. 将任意文本转换为SEO友好的URL别名,支持中英文混合、自动去除特殊字符。适合博客、电商、CMS系统。URL slug maker, permalink generator, URL safe string."
tags: slug, url, generator, seo, permalink, link, utility, tool
---
# Slug Generator 🔗
URL友好别名生成工具。
## Features | 功能
- **中英文支持**:中文转拼音Slug
- **特殊字符过滤**:自动移除不合规字符
- **多格式输出**:小写/大写/标题式
## Usage | 使用
```bash
# 基础转换
python3 scripts/slug.py "Hello World"
# 中文转拼音Slug
python3 scripts/slug.py "这是一个测试"
# 自定义分隔符
python3 scripts/slug.py "Hello World" --separator _
# 大写格式
python3 scripts/slug.py "Hello World" --uppercase
```
---
*免责声明:本工具仅供学习参考,不构成任何投资或商业建议。*
FILE:scripts/slug.py
#!/usr/bin/env python3
"""URL Slug Generator - 将文本转换为SEO友好的URL别名"""
import sys
import json
import re
import unicodedata
PINYIN_MAP = {
'的':'de','一':'yi','是':'shi','了':'le','在':'zai','不':'bu','有':'you',
'人':'ren','这':'zhe','中':'zhong','大':'da','为':'wei','上':'shang',
'个':'ge','国':'guo','我':'wo','以':'yi','要':'yao','他':'ta','时':'shi',
'来':'lai','用':'yong','们':'men','生':'sheng','到':'dao','作':'zuo',
'地':'di','于':'yu','出':'chu','就':'jiu','分':'fen','对':'dui','成':'cheng',
'会':'hui','可':'ke','主':'zhu','发':'fa','年':'nian','动':'dong','同':'tong',
'工':'gong','也':'ye','能':'neng','下':'xia','过':'guo','子':'zi','说':'shuo',
'产':'chan','种':'zhong','面':'mian','而':'er','方':'fang','后':'hou','多':'duo',
'定':'ding','行':'xing','学':'xue','所':'suo','民':'min','得':'de','经':'jing',
'十':'shi','三':'san','之':'zhi','进':'jin','着':'zhe','等':'deng','部':'bu',
'度':'du','家':'jia','里':'li','新':'xin','力':'li','请':'qing','联':'lian',
'合':'he','机':'ji','无':'wu','心':'xin','量':'liang','么':'me','事':'shi',
'知':'zhi','间':'jian','去':'qu','什':'shen','么':'me','还':'hai','天':'tian',
'日':'ri','本':'ben','月':'yue','年':'nian','好':'hao','小':'xiao','伙':'huo',
'伴':'ban','你':'ni','好':'hao','世':'shi','界':'jie','北':'bei','京':'jing',
'上':'shang','海':'hai','深':'shen','圳':'zhen','广':'guang','州':'zhou',
'杭':'hang','州':'zhou','成':'cheng','都':'dou','重':'chong','庆':'qing',
'天':'tian','津':'jin','南':'nan','京':'jing','西':'xi','安':'an','武':'wu',
'汉':'han','长':'chang','沙':'sha','数':'shu','据':'ju','科':'ke','技':'ji',
'开':'kai','发':'fa','产':'chan','品':'pin','中':'zhong','文':'wen','英':'ying',
'文':'wen','学':'xue','习':'xi','生':'sheng','活':'huo','工':'gong','作':'zuo',
}
def to_pinyin(text):
result = []
for char in text:
if char in PINYIN_MAP:
result.append(PINYIN_MAP[char])
elif char.isascii():
result.append(char.lower())
elif char == ' ' or char == '-':
result.append('-')
return ''.join(result)
def make_slug(text, separator='-', uppercase=False):
"""将文本转换为URL友好的slug"""
# Unicode正规化
text = unicodedata.normalize('NFKD', text)
# 中文转拼音
if re.search(r'[\u4e00-\u9fff]', text):
text = to_pinyin(text)
# 移除非ASCII字母/数字/连字符/下划线
slug = re.sub(r'[^a-zA-Z0-9\-_ ]', '', text)
# 替换空格和下划线为指定分隔符
slug = re.sub(r'[\s_]+', separator, slug)
# 合并连续分隔符
slug = re.sub(rf'{re.escape(separator)}+', separator, slug)
# 去除首尾分隔符
slug = slug.strip(separator)
# 转为小写
slug = slug.lower()
if uppercase == 'upper':
slug = slug.upper()
elif uppercase == 'title':
slug = separator.join(word.capitalize() for word in slug.split(separator))
return slug
def main():
args = sys.argv[1:]
separator = '-'
uppercase = False
if '--separator' in args:
idx = args.index('--separator')
separator = args[idx + 1] if idx + 1 < len(args) else '_'
args = args[:idx] + args[idx+2:]
if '--upper' in args:
uppercase = 'upper'
args = [a for a in args if a != '--upper']
elif '--title' in args:
uppercase = 'title'
args = [a for a in args if a != '--title']
text = ' '.join(args)
if not text:
print(json.dumps({
"usage": "slug.py <文本> [--separator <字符>] [--upper] [--title]",
"examples": [
"slug.py 'Hello World'",
"slug.py '这是一个测试'",
"slug.py 'Hello World' --separator _",
"slug.py 'hello-world' --title"
]
}, ensure_ascii=False, indent=2))
return
slug = make_slug(text, separator, uppercase)
print(json.dumps({
"input": text,
"slug": slug,
"separator": separator
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
Generate UUIDs in versions v1, v4, and v5 with options for count, namespace, name, and output format.
# uuid-generator
Generate UUIDs in various formats. Supports UUID v1, v4, v5, and custom patterns.
## Features
- **UUID v4** (default): Cryptographically random UUIDs
- **UUID v1**: Time-based UUIDs with timestamp and MAC address
- **UUID v5**: Namespace-based deterministic UUIDs (SHA-1)
- **Bulk generate**: Generate multiple UUIDs at once
- **Format options**: Standard, uppercase, no-dashes, URL-safe
## Usage
```
uuid
uuid v4
uuid v4 --count 10
uuid v1
uuid v5 ns:url "https://example.com"
uuid --format no-dashes
uuid --format uppercase
```
## Parameters
- `version`: UUID version to generate (v1/v4/v5, default: v4)
- `count`: Number of UUIDs to generate (default: 1)
- `namespace`: (v5 only) Namespace: url, dns, oid, x500, or custom
- `name`: (v5 only) Name within the namespace
- `format`: Output format: standard/uppercase/nodashes/urlsafe
## ⚠️ Disclaimer
This tool is provided "as is" for informational purposes only. Data accuracy is not guaranteed. Not financial, legal, or professional advice. Always verify critical information from official sources.
本工具仅供信息参考,不保证数据完全准确,不构成任何金融/法律/专业建议。请以官方来源为准。
FILE:scripts/uuid_gen.py
#!/usr/bin/env python3
"""UUID Generator - Generate UUID v1, v4, v5 in various formats"""
import uuid, sys
def generate_v4(count=1, fmt='standard'):
uuids = [uuid.uuid4() for _ in range(count)]
return format_uuids(uuids, fmt)
def generate_v1(count=1, fmt='standard'):
uuids = [uuid.uuid1() for _ in range(count)]
return format_uuids(uuids, fmt)
def generate_v5(namespace, name, count=1, fmt='standard'):
ns_map = {'url': uuid.UUID('6ba7b811-9dad-11d1-80b4-00c04fd430c8'),
'dns': uuid.UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8'),
'oid': uuid.UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8'),
'x500': uuid.UUID('6ba7b814-9dad-11d1-80b4-00c04fd430c8')}
ns = ns_map.get(namespace.lower(), uuid.UUID(namespace))
uuids = [uuid.uuid5(ns, name) for _ in range(count)]
return format_uuids(uuids, fmt)
def format_uuids(uuids, fmt):
results = []
for u in uuids:
s = str(u)
if fmt == 'uppercase':
s = s.upper()
elif fmt == 'nodashes':
s = s.replace('-', '')
elif fmt == 'urlsafe':
s = u.urlsafe().decode() if hasattr(u, 'urlsafe') else s.replace('-', '')
results.append(s)
return '\n'.join(results)
def main():
args = sys.argv[1:]
version = 'v4'
count = 1
fmt = 'standard'
namespace = None
name = None
i = 0
while i < len(args):
if args[i] == 'v1' or args[i] == 'v4' or args[i] == 'v5':
version = args[i]
elif args[i] == '--count' and i + 1 < len(args):
count = int(args[i+1]); i += 1
elif args[i] == '--format' and i + 1 < len(args):
fmt = args[i+1]; i += 1
elif args[i] == 'ns:url' or args[i] == 'ns:dns' or args[i].startswith('ns:'):
namespace = args[i][3:]
elif i > 0 and args[i-1].startswith('ns:'):
name = args[i]
i += 1
# Find name in args
for arg in args:
if not arg.startswith('-') and not arg.startswith('v') and arg not in ['url', 'dns', 'oid', 'x500']:
if namespace and not name:
name = arg
try:
if version == 'v4':
print(generate_v4(count, fmt))
elif version == 'v1':
print(generate_v1(count, fmt))
elif version == 'v5':
if not namespace or not name:
print("UUID v5 requires namespace and name", file=sys.stderr)
sys.exit(1)
print(generate_v5(namespace, name, count, fmt))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Generates timed, hook-driven Douyin short video scripts with visual cues, BGM suggestions, and CTA lines optimized for platform retention and engagement.
# Douyin Short Video Script Studio ## Purpose This skill generates structured oral presentation scripts for Douyin (抖音 / TikTok CN) short videos. It specializes in hook-driven openings (0–3 second grab), timed content beats, visual cue suggestions, BGM mood guidance, and transition scripting. "Studio" means a complete toolkit — from brief to shoot-ready script with timing, not just flat text. Best used when you need a Douyin video that converts attention into retention, with every second engineered for the platform's algorithm and viewer behavior. ## Triggers - "写抖音脚本" - "抖音口播" - "短视频脚本" - "抖音 hook" - "抖音 storyboard" - "douyin script" - "抖音文案" - "Douyin video script" - "口播稿" - "抖音分镜脚本" ## Workflow 1. Receive product/topic brief from user: product name, category, key message, target audience, desired video style/tone, and video length (15s, 30s, or 60s). 2. Determine the optimal script structure based on length: - 15s: Single-hook, single-point, hard CTA - 30s: Hook → Problem/Context → Solution/Reveal → CTA - 60s: Hook → Story/Proof → Deep Dive → Social Proof → CTA 3. Generate 3–5 opening hook variants optimized for 0–3 second retention (visual + verbal). 4. Structure body beats with estimated timing per segment (e.g., 0–3s hook, 3–8s setup, 8–20s core message). 5. Add visual cues for each beat: shot type (close-up, product detail, face-to-camera), motion direction, text overlay suggestions, and transition type (cut, zoom, swipe). 6. Suggest BGM mood and tempo (upbeat, emotional, trending, lo-fi) matched to content energy. 7. Write the closing CTA optimized for Douyin algorithm engagement: like, follow, comment prompt, or purchase link. 8. Include safety disclaimer and compliance review for commercial content. ## Prompt Templates ### 1. Script from Brief (`script_from_brief`) **Purpose:** Generate a complete timed Douyin oral script from a product/topic brief. **Input:** - `product_name` — Product or topic name - `category` — Niche (beauty, tech, food, lifestyle, education, fitness) - `key_message` — The single most important point to communicate - `target_audience` — Who the video is for (age, interest, pain point) - `video_length` — 15s, 30s, or 60s - `tone` — Style (energetic, calm, humorous, authoritative, relatable) - `cta_goal` — Desired action (follow, like, comment, buy, download) **Output:** Full timed script with: - Timestamped beats (0–3s, 3–8s, etc.) - Spoken lines (口播文案) - Visual cues per beat (shot, motion, text overlay) - BGM mood suggestion - Final CTA line ### 2. Hook Library (`hook_library`) **Purpose:** Generate 5 opening hook variants for a product or topic. **Input:** - `product_name` — Product or topic - `hook_type` — Optional preference (curiosity, pain point, surprise, story, number/list) - `target_audience` — Audience descriptor **Output:** 5 hook options, each with: - Verbal hook (first 1–2 sentences) - Visual direction (what to show in 0–3s) - Why it works (psychology rationale) - Best fit scenario ### 3. Storyboard Outline (`storyboard_outline`) **Purpose:** Convert a script brief into a 3-scene visual storyboard outline. **Input:** - `product_name` — Product/topic - `scene_count` — 3 or 5 scenes - `style` — Visual style (clean, lifestyle, demo, testimonial, Vlog) **Output:** Scene-by-scene breakdown: - Scene number + timestamp range - Shot description (angle, distance, subject) - On-screen text overlay suggestions - Audio notes (voiceover vs. music vs. silence) - Transition to next scene ### 4. Trending Angle Adapter (`trending_angle_adapter`) **Purpose:** Adapt a product/topic to a current Douyin trending format or challenge style. **Input:** - `product_name` — Product/topic - `trend_format` — Trending format (e.g., "before vs after", "day in the life", "myth busting", "POV", "trending sound rewrite") - `original_script` — (Optional) Existing script to adapt **Output:** Adapted script/outline that fits the trending format while preserving the core product message, with notes on how to make it feel native to the trend rather than forced. ### 5. Script Optimizer (`script_optimizer`) **Purpose:** Improve an existing Douyin script for retention, clarity, and conversion. **Input:** - `draft_script` — User's existing script or outline - `optimization_goal` — Primary goal (retention, clarity, conversion, humor, pacing) - `video_length` — Target length **Output:** Optimized script with: - Redlined changes (what changed and why) - Timing adjustments - Stronger hook alternatives - Visual enhancement suggestions - Pacing notes (where to speed up, where to pause) ## Output Format All script outputs follow a structured studio format: ``` ## Douyin Script: [Product/Topic] **Length:** [15s / 30s / 60s] | **Tone:** [Tone] | **CTA Goal:** [Goal] ### Beat 1 — Hook (0–3s) - **Script:** [Spoken line] - **Visual:** [Shot type + motion + text overlay] - **Audio:** [BGM mood / sound effect] ### Beat 2 — [Segment Name] (3–8s) ... ### Closing — CTA (final 3s) - **Script:** [CTA line] - **Visual:** [End card / product shot / follow prompt] - **Audio:** [Music swell / silence for impact] ``` Additional outputs provided as needed: - **Hook variants:** Bulleted list with rationale - **Storyboard:** Table format (Scene | Time | Shot | Text | Audio | Transition) - **Optimization notes:** Before/After comparison with reasoning ## Safety Rules - **NEVER** generate false product efficacy claims or misleading before/after transformations - **NEVER** suggest dangerous challenges, risky behaviors, or harmful stunts for views - **NEVER** create scripts that impersonate real individuals without disclosure - **ALWAYS** include explicit disclosure language for sponsored or commercial content (e.g., "本条内容为合作推广" or "#ad") - **ALWAYS** respect Douyin content review policies — no prohibited products, medical claims, or deceptive practices - **ALWAYS** remind the user to review and fact-check AI-generated scripts before filming and publishing - **ALWAYS** ensure visual cues and suggested actions comply with platform safety guidelines ## Examples ### Example 1: Script from Brief (Skincare, 30s) **Input:** Product="XX 维C精华", Category="beauty", Key Message="7天提亮肤色", Audience="20-30岁熬夜女性", Length="30s", Tone="energetic", CTA Goal="buy" **Output:** - Beat 1 (0–3s): Hook — "熬夜脸有救了!" + close-up of tired face → brightened face transition - Beat 2 (3–10s): Problem — "凌晨2点睡,早上暗沉到不敢照镜子" + lifestyle shot - Beat 3 (10–22s): Solution — "这瓶维C精华,7天提亮不是玄学" + product demo + ingredient text overlay - Beat 4 (22–30s): CTA — "链接在左下角,现在下单立减30" + end card with price ### Example 2: Hook Library (Same Product) **Input:** Product="XX 维C精华", Hook Type="curiosity", Audience="20-30岁女性" **Output:** 5 hooks: 1. "我用了7天,同事问我是不是去做医美了" (surprise + social proof) 2. "这瓶精华的维C浓度,我算了3遍才敢相信" (curiosity + number) 3. "熬夜到凌晨2点,我的脸居然比以前还亮" (contrast + relatability) 4. "皮肤科朋友偷偷告诉我,提亮根本不需要贵" (insider + value) 5. "别再花冤枉钱!提亮肤色,这一瓶够了" (direct + authority) ### Example 3: Storyboard Outline (Tech Gadget, 5 scenes) **Input:** Product="便携投影仪", Style="lifestyle" **Output:** 5-scene storyboard from unboxing → bedroom setup → movie night → portability demo → CTA end card. ## Related Skills - [live-selling-script-kit](../live-selling-script-kit/) — For live-streaming sales scripts when your Douyin video drives to a live room - [ad-copy-ab-tester](../ad-copy-ab-tester/) — For testing Douyin ad creative copy derived from these scripts - [landing-page-copy-pro](../landing-page-copy-pro/) — For landing page copy when your Douyin CTA drives traffic to a conversion page FILE:ACCEPTANCE.md # Acceptance Criteria — Douyin Short Video Script Studio - [ ] SKILL.md is self-contained (agent can operate from it alone) - [ ] All 5 prompt templates are complete with `placeholder` inputs - [ ] Safety rules are explicit and actionable (NEVER/ALWAYS format) - [ ] README.md has clear install instructions + 3 usage examples - [ ] skill.json is valid JSON with all required fields, `requires_api: false` - [ ] Content is unique — no duplication with other skills in this pack (focus on timed video beats, visual cues, and BGM mood) - [ ] Slugs follow naming convention (user-facing, no prefix codes) - [ ] Hook library and storyboard outline features are differentiated from viral-xiaohongshu-notes (video scripts vs. text notes) FILE:README.md # Douyin Short Video Script Studio Structured oral presentation scripts for Douyin (抖音) short videos — engineered for 0–3s hooks, timed beats, and shoot-ready production. ## Features - Generate complete timed scripts from briefs (15s / 30s / 60s) - Hook library: 5 opening variants with psychology rationale - Storyboard outlines with shot types, text overlays, and transitions - Trending angle adapter: fit your product into current Douyin formats - Script optimizer: improve existing drafts for retention and conversion - Visual cues and BGM mood guidance per beat ## Install ``` openclaw skills install harrylabsj/douyin-script-studio ``` ## Usage ``` 帮我写一个30秒的抖音口播脚本,产品是XX维C精华,主打7天提亮,面向20-30岁熬夜女性,语气活泼,目标是下单 给我5个抖音视频开头hook,产品是便携投影仪,面向租房年轻人 把这个脚本改成"Day in the life"的抖音热门形式 帮我优化这个抖音脚本的节奏和转化 ``` ## Platforms 抖音 (Douyin / TikTok CN) ## Safety No false efficacy claims. No misleading before/after. No dangerous challenges. All commercial content includes sponsorship disclosure. Always review scripts before filming. ## License MIT FILE:skill.json { "name": "Douyin Short Video Script Studio", "description": "Structured Douyin oral script generation with hook-driven openings (0-3s grab), timed content beats, visual cues, BGM mood guidance, and transition scripting for short video creators.", "version": "1.0.0", "type": "prompt-flow", "category": "Social Media Content / Platform-Specific", "keywords": [ "douyin", "抖音", "抖音脚本", "口播脚本", "short video script", "opening hook", "storyboard", "trending", "抖音文案", "video script", "creator toolkit" ], "platforms": ["抖音 (Douyin / TikTok CN)"], "requires": {}, "requires_api": false, "author": "harrylabsj", "license": "MIT", "safety": { "no_code_execution": true, "no_network": true, "no_credentials": true, "compliance_notes": "No false product efficacy claims. No misleading before/after transformations. Explicit disclosure of commercial/sponsored content. No dangerous challenge or behavior suggestions. Respect Douyin content review policies." } }
Generates Xiaohongshu native notes with authentic product recommendations, aesthetic formatting, niche hashtags, cover texts, and a commercial disclosure rem...
# Viral Xiaohongshu Note Writer ## Purpose This skill generates Xiaohongshu (小红书 / RED) platform-native notes optimized for virality. It creates "种草" (grass-planting / product recommendation) content with cover text design strategy, niche hashtag stacking, authentic personal-experience tone, product placement angles, and platform-unique aesthetic formatting. Best used when you have a product or service to promote and need a note that feels organic, engaging, and platform-appropriate — but still delivers commercial value. ## Triggers - "写小红书笔记" - "生成种草文案" - "小红书 cover" - "小红书 hashtag" - "种草角度" - "小红书改写" - "viral xiaohongshu note" - "xhs note writer" - "RED note generator" - "小红书内容创作" ## Workflow 1. Receive product information from user (product name, category, key features, price, target audience, and optional existing draft). 2. Identify niche: beauty, fashion, travel, food, home, parenting, or general lifestyle. 3. Structure the note using the Xiaohongshu native format: hook → personal experience → product reveal → usage tips → purchase guidance. 4. Insert emoji rhythm, line breaks, and section headers following Xiaohongshu aesthetic conventions. 5. Generate 3–5 niche-specific hashtags plus 2–3 trending tags for discoverability. 6. Provide 3–5 cover text options that match the note angle. 7. Include safety disclaimer reminding user to disclose commercial relationships. ## Prompt Templates ### 1. Note from Brief (`note_from_brief`) **Purpose:** Generate a complete Xiaohongshu note from product information. **Input:** - `product_name` — Name of the product - `category` — Niche (beauty/fashion/travel/food/home/parenting) - `key_features` — 2–4 main selling points - `target_audience` — Who this product is for - `price_range` — Optional price context - `angle` — Optional content angle (e.g., "成分党", "学生党", "干货分享") **Output:** Full note with hook paragraph, personal experience narrative, product reveal, usage tips, purchase guidance, hashtags, and 3 cover text options. ### 2. Cover Title Generator (`cover_title_generator`) **Purpose:** Generate cover image text options that drive clicks. **Input:** - `product_name` — Product name - `angle` — Content angle - `target_audience` — Audience descriptor **Output:** 5 cover title options, each with a rationale for why it works for the given product and audience. ### 3. Hashtag Strategy (`hashtag_strategy`) **Purpose:** Create a balanced hashtag set for maximum discoverability. **Input:** - `product_category` — Category (e.g., 面膜, 穿搭, 旅行) - `niche_keywords` — 2–3 niche-specific keywords - `trending_context` — Optional current trending topics or seasons **Output:** 3–5 niche hashtags (targeting specific interest groups) + 2–3 trending hashtags (for broader reach) + hashtag volume tier labeling. ### 4. Angle Switcher (`angle_switcher`) **Purpose:** Generate 3 different content angles for the same product. **Input:** - `product_name` — Product name - `key_features` — Key features - `audience_segments` — 2–3 possible audience types **Output:** 3 distinct note outlines, each from a different angle (e.g., 成分分析, 使用前后对比, 开箱体验), with hook and hashtag recommendations per angle. ### 5. Note Polish/Rewrite (`note_rewrite`) **Purpose:** Optimize an existing draft for Xiaohongshu engagement. **Input:** - `draft_content` — User's existing note draft - `optimization_goal` — What to improve (engagement/readability/SEO) **Output:** Polished version with improved hook, emoji rhythm, formatting, hashtags, and cover text suggestions. ## Output Format All outputs follow Xiaohongshu's native platform styling: - Short paragraphs (1–3 sentences each) - Emoji used deliberately for emphasis and section breaks - Hashtags appended at the bottom - Cover text options provided separately as a numbered list - Character count within platform limits (~1000 characters) ## Safety Rules - **NEVER** generate fake reviews, fabricated user experiences, or misleading testimonials - **NEVER** make unverified product efficacy claims (especially skincare, health, or wellness) - **NEVER** include medical/health claims without qualification (e.g., "FDA-registered" or "dermatologist-tested" only if verifiable) - **ALWAYS** prompt the user to disclose sponsored or commercial relationships per Xiaohongshu guidelines - **ALWAYS** respect Xiaohongshu community guidelines — no prohibited products or content - **ALWAYS** remind the user to review and fact-check AI-generated content before publishing ## Examples ### Example 1: Note from Brief (Skincare) **Input:** Product = "XX 玻尿酸保湿面霜", Price = "299元", Features = "三重玻尿酸、敏感肌可用、24小时保湿", Audience = "25-35岁女性", Angle = "成分党" **Output:** A full note with hook about winter skincare struggles, personal experience with dry skin, product reveal with ingredient breakdown (triple hyaluronic acid), usage tips (apply on damp skin), and hashtags like #玻尿酸面霜 #保湿面霜推荐 #干皮救星 #成分党 skincare. ### Example 2: Angle Switcher (Same Product) **Input:** Same product as above, audience segments = {成分党, 学生党, 宝妈} **Output:** Three outlines: (1) 成分分析 deep-dive, (2) 平价好物 budget-friendly angle, (3) 新手护肤 routine integration angle. ## Related Skills - [social-caption-kit](../social-caption-kit/) — For multi-platform repurposing of the same content - [product-title-booster](../product-title-booster/) — For optimizing the product's listing title to match the note - [review-reply-coach](../review-reply-coach/) — For responding to comments and reviews on the note FILE:ACCEPTANCE.md # Acceptance Criteria — Viral Xiaohongshu Note Writer - [ ] SKILL.md is self-contained (agent can operate from it alone) - [ ] All 5 prompt templates are complete with `placeholder` inputs - [ ] Safety rules are explicit and actionable (NEVER/ALWAYS format) - [ ] README.md has clear install instructions + 3 usage examples - [ ] skill.json is valid JSON with all required fields - [ ] Content is unique — no duplication with other skills in this pack (focus on Xiaohongshu-native 种草 format) - [ ] Slugs follow naming convention (user-facing, no prefix codes) - [ ] Cover text generator, hashtag strategy, and angle switcher features differentiated from social-caption-kit FILE:README.md # Viral Xiaohongshu Note Writer Create authentic, engaging Xiaohongshu (RED) notes optimized for virality and platform-native aesthetics. ## Features - Generate complete notes from product briefs with native 种草 tone - Craft click-optimized cover text options for your images - Build balanced hashtag strategies (niche + trending) - Explore multiple content angles for the same product - Polish and rewrite existing drafts for better engagement - Emoji rhythm, line breaks, and Xiaohongshu-native formatting ## Install ``` openclaw skills install harrylabsj/viral-xiaohongshu-notes ``` ## Usage ``` 写一篇小红书笔记,产品是XX面霜,299元,主打保湿和修护,面向25-35岁女性,成分党角度 帮我生成5个小红书封面标题,产品是便携咖啡机,面向职场白领 给我3个不同的种草角度写同一款洁面产品 帮我优化这篇小红书笔记的标题和hashtag ``` ## Platforms 小红书 (Xiaohongshu / RED) ## Safety This skill does not generate fake reviews or fabricated user experiences. All outputs include reminders to disclose commercial relationships per platform guidelines. Always review AI-generated content before publishing. ## License MIT FILE:skill.json { "name": "Viral Xiaohongshu Note Writer", "description": "Generate viral-style Xiaohongshu (RED) notes with cover text, niche hashtag strategy, authentic 种草 tone, and platform-optimized formatting for beauty, fashion, travel, food, home, and parenting niches.", "version": "1.0.0", "type": "prompt-flow", "category": "Social Media Content / Platform-Specific", "keywords": [ "xiaohongshu", "小红书", "种草文案", "小红书笔记", "RED note", "cover text", "hashtag strategy", "viral content", "种草", "product recommendation", "beauty note", "fashion note" ], "platforms": ["小红书 (Xiaohongshu / RED)"], "requires": {}, "requires_api": false, "author": "harrylabsj", "license": "MIT", "safety": { "no_code_execution": true, "no_network": true, "no_credentials": true, "compliance_notes": "No fake reviews or fabricated user experiences. No unverified product efficacy claims. No medical/health claims without qualification. Must prompt user to disclose commercial relationships. Respect Xiaohongshu community guidelines." } }
Depop is a Z-gen focused C2C fashion resale platform blending social media and shopping, acquired by Etsy in 2021 for $1.625B.
--- name: depop summary: 从伦敦二手时尚社区到被 Etsy 收购 — Depop 如何抓住 Z 世代的转售经济 read_when: - 研究二手时尚和转售经济时 - 分析 Z 世代消费行为时 - 了解社交电商模式时 - 对比 Poshmark、Vinted、Mercari 时 --- # Depop ## 概述 从伦敦二手时尚社区到被 Etsy 收购 — Depop 如何抓住 Z 世代的转售经济。 ## 历史时间线 - 2011: Simon Beckerman 在伦敦创立 Depop(最初是创意社区应用) - 2012: 转型为 C2C 时尚交易平台 - 2017-2018: 在 Z 世代中爆发式增长,成为'Instagram 风格的二手市场' - 2019: 获得 General Atlantic 投资 - 2021年6月: 被 Etsy 以 16.25 亿美元收购 - 2022: 用户超过 3000 万,主要在 26 岁以下 - 2023-2024: 持续全球扩张,尤其在美国和澳大利亚 ## 商业模式 C2C 二手时尚平台。收入来自:每笔交易收取 10% 手续费。特色:社交化购物体验(关注卖家、点赞、分享),类似 Instagram 的界面让购物变成社交活动。 ## 护城河分析 Z 世代用户基础(超过 90% 的活跃用户 26 岁以下);社区驱动的卖家文化(很多卖家是时尚达人/博主);Etsy 资源支持;独特的'社交+购物'混合模式。 ## 关键数据 - **收购价**: 16.25 亿美元(Etsy, 2021) - **用户数**: 3000万+,90% 在 26 岁以下 - **市场**: 美国、英国、澳大利亚、意大利等 - **活跃卖家**: 数百万 ## 有趣事实 - Depop 被称为'Z 世代的 eBay'——但它更像是一个社交媒体应用,购物是副产品 - 许多 Depop 卖家通过转售二手服装月入数千美元,甚至全职经营
通过纯 Python 标准库实现字符串和文件的 Base64 编解码,支持目录批量编码,无需外部依赖。
# cn-base64-tool - Base64 编解码工具
纯 Python 标准库实现的 Base64 编解码工具。
## 功能
- **编码**:将字符串或文件内容编码为 Base64
- **解码**:将 Base64 字符串还原为原始内容
- **文件支持**:支持对文件进行 Base64 编码/解码
## 使用方式
```bash
# 编码字符串
python3 cn_base64_tool.py encode "Hello World"
# 解码 Base64 字符串
python3 cn_base64_tool.py decode "SGVsbG8gV29ybGQ="
# 编码文件
python3 cn_base64_tool.py encode_file input.png
# 解码文件
python3 cn_base64_tool.py decode_file output.b64 output.png
# 批量编码(目录)
python3 cn_base64_tool.py encode_dir ./my_folder
```
## 技术说明
- 纯 Python 标准库(`base64`、`argparse`)
- 无外部依赖
- 支持 UTF-8 字符串
FILE:scripts/base64_tool.py
#!/usr/bin/env python3
"""
Base64 编解码工具
纯 Python 标准库实现
"""
import base64
import argparse
import sys
import os
def encode_string(text: str) -> str:
"""将字符串编码为 Base64"""
return base64.b64encode(text.encode('utf-8')).decode('ascii')
def decode_string(b64_text: str) -> str:
"""将 Base64 字符串解码为原始字符串"""
try:
return base64.b64decode(b64_text.encode('ascii')).decode('utf-8')
except Exception as e:
raise ValueError(f"解码失败: {e}")
def encode_file(input_path: str) -> str:
"""将文件内容编码为 Base64"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"文件不存在: {input_path}")
with open(input_path, 'rb') as f:
data = f.read()
return base64.b64encode(data).decode('ascii')
def decode_file(b64_text: str, output_path: str) -> None:
"""将 Base64 内容解码写入文件"""
data = base64.b64decode(b64_text.encode('ascii'))
with open(output_path, 'wb') as f:
f.write(data)
def main():
parser = argparse.ArgumentParser(
description='Base64 编解码工具',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
示例:
%(prog)s encode "Hello World" 编码字符串
%(prog)s decode "SGVsbG8gV29ybGQ=" 解码字符串
%(prog)s encode_file image.png 编码文件
%(prog)s decode_file output.b64 out.png 解码到文件
'''
)
subparsers = parser.add_subparsers(dest='command', help='子命令')
# encode
p_encode = subparsers.add_parser('encode', help='编码字符串为 Base64')
p_encode.add_argument('text', help='要编码的字符串')
# decode
p_decode = subparsers.add_parser('decode', help='解码 Base64 字符串')
p_decode.add_argument('text', help='要解码的 Base64 字符串')
# encode_file
p_encode_file = subparsers.add_parser('encode_file', help='将文件编码为 Base64')
p_encode_file.add_argument('input', help='输入文件路径')
p_encode_file.add_argument('-o', '--output', default='-', help='输出文件路径(默认 stdout)')
# decode_file
p_decode_file = subparsers.add_parser('decode_file', help='将 Base64 解码为文件')
p_decode_file.add_argument('b64', help='Base64 字符串或文件')
p_decode_file.add_argument('output', help='输出文件路径')
args = parser.parse_args()
if args.command == 'encode':
print(encode_string(args.text))
elif args.command == 'decode':
print(decode_string(args.text))
elif args.command == 'encode_file':
result = encode_file(args.input)
if args.output == '-':
print(result)
else:
with open(args.output, 'w') as f:
f.write(result)
print(f"已保存到: {args.output}")
elif args.command == 'decode_file':
decode_file(args.b64, args.output)
print(f"已保存到: {args.output}")
else:
parser.print_help()
sys.exit(1)
if __name__ == '__main__':
main()
Bilibili is a Chinese video platform evolving from an ACG community to a mainstream site with unique bullet chat culture, diverse content, and strong creator...
--- name: bilibili-app summary: 从 ACG 二次元社区到中国 YouTube — B 站如何从边缘文化走向主流 read_when: - 研究中国视频平台竞争时 - 分析 ACG 文化和二次元经济时 - 了解 B 站商业模式和 UP 主生态时 - 对比 Bilibili、YouTube、爱奇艺、腾讯视频时 --- # 哔哩哔哩 (Bilibili) ## 概述 从 ACG 二次元社区到中国 YouTube — B 站如何从边缘文化走向主流。 ## 历史时间线 - 2009: 徐逸在杭州创立 Mikufans(B 站前身) - 2010: 更名为 bilibili - 2014: 推出弹幕文化,成为核心体验 - 2018: 纳斯达克上市(股票代码 BILI) - 2019: 推出'后浪'广告,进入主流视野 - 2020-2021: 月活超过 2 亿,拓展知识区、生活区 - 2022: 推出 Story Mode(竖屏短视频),对抗抖音 - 2023-2024: 推进商业化(广告、直播、游戏、电商) ## 商业模式 视频社区:广告(信息流、品牌合作)、直播(虚拟礼物打赏)、游戏(自研+联运)、增值(大会员)、电商(会员购)。独特之处:承诺永不加视频贴片广告,保持用户体验。 ## 护城河分析 弹幕文化和社区氛围(高用户忠诚度);Z 世代用户占比极高(~80% 在 35 岁以下);UP 主生态(内容创作者粘性极高);从 ACG 到全品类的成功拓展。 ## 关键数据 - **上市**: 纳斯达克 BILI(2018) - **月活**: ~3.3 亿+ - **日均视频播放**: ~40 亿 - **UP 主**: ~400 万+ ## 有趣事实 - B 站的'弹幕'功能让用户的评论实时飞过视频画面——这是 B 站区别于所有其他视频平台的核心体验 - B 站承诺永远不会在视频前加贴片广告——这一决策赢得了用户信任,但也使商业化路径更加困难
Hik-Connect for Teams (HCT) Developer Skills. Integrates a series of skills for managing and controlling HCT devices, including resource management, access c...
---
name: hik-connect-team Skills
description: |
Hik-Connect for Teams (HCT) Developer Skills.
Integrates a series of skills for managing and controlling HCT devices, including resource management, access control, device capture, video streaming, and alarm push.
Use when: Need to perform batch management, remote control, real-time monitoring, media resource acquisition, or alarm push configuration for devices under Hik-Connect for Teams mode.
⚠️ Global Requirement: All sub-modules require configuration of environment variables:
- Hik-Connect Team OpenAPI AppKey
- Hik-Connect Team OpenAPI SecretKey
- Hik-Connect Team OpenAPI Domain (auto-obtained from token response)
---
# Hik-Connect Team Skills
## 1. Introduction
`Hik-Connect_Team_Skills` is a full-featured integration Skills designed specifically for **Hik-Connect for Teams (HCT)** developers. Based on the **HCTOpen OpenAPI** system, it encapsulates core capabilities from basic resource management to advanced alarm push through Python scripts.
This Skills adopts a modular design with built-in automated **Token maintenance mechanisms**, **dynamic path searching**, and **standardized error handling**, aiming to help developers quickly build HCT-based automated O&M, security monitoring, and business integration systems.
---
## 2. Core Modules Deep Dive
This Skills consists of five core sub-modules, each providing deep support for specific business scenarios:
| Module Name | Core Functions | Core Scripts | Applicable Scenarios |
|:---------------------------------------------------------------------------|:----------------------------------------------------------|:-----------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------|
| [**📦 Resource Management**](./modules/Hik-Connect_Team_Resource/SKILL.md) | Device discovery, detail acquisition, channel enumeration | `list_devices.py`<br>`device_detail.py`<br>`device_channels.py`<br>`list_doors.py` | Asset inventory, obtaining device serial numbers and channel IDs, access control resources, synchronizing organizational structure resources. |
| [**🚪 Access Control (ACS)**](./modules/Hik-Connect_Team_ACS/SKILL.md) | Remote open/close, normally open/normally closed control | `acs_control.py` | Remote office collaboration, unattended entrance management, access control linkage in emergencies. |
| [**📸 Device Capture**](./modules/Hik-Connect_Team_Capture/SKILL.md) | Real-time trigger capture, obtain image URL | `capture_pic.py` | Anomaly verification, real-time screen preview, manual secondary verification of AI recognition results. |
| [**🎥 Video Streaming**](./modules/Hik-Connect_Team_Video/SKILL.md) | Obtain real-time video stream | `get_video_url.py` | Real-time monitoring embedding, remote video inspection, third-party monitoring large screen integration. |
| [**🔔 Alarm Push (Alarm)**](./modules/Hik-Connect_Team_Alarm/SKILL.md) | Webhook subscription, fine-grained event management | `webhook_manager.py`<br>`event_manager.py` | Real-time alarm notification, third-party system integration (e.g., Feishu/DingTalk robots). |
---
## 3. Environment Preparation and Global Configuration
### 3.1 Credential Configuration
Before using any module, credentials must be configured. The system supports two methods:
#### Method A: Environment Variables (Recommended)
```bash
# Required: Obtain from Hik-Connect HCT Developer Platform
export HIK_CONNECT_TEAM_OPENAPI_APP_KEY="Your Hik-Connect Team OpenAPI AppKey"
export HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY="Your Hik-Connect Team OpenAPI SecretKey"
# Note: API domain is automatically obtained from token response (no longer required)
# Optional: Token cache configuration (enabled by default to reduce API call frequency)
export HIK_CONNECT_TEAM_TOKEN_CACHE="1" # 1=Enabled, 0=Disabled
```
#### Method B: OpenClaw Config Files (Fallback)
If environment variables are not set, the system will automatically search for credentials in OpenClaw config files:
```
Config search order (first found wins):
1. ~/.openclaw/config.json
2. ~/.openclaw/gateway/config.json
3. ~/.openclaw/channels.json
```
Config format:
```json
{
"channels": {
"hik_connect_team_openapi": {
"appKey": "Your Hik-Connect Team OpenAPI AppKey",
"secretKey": "Your Hik-Connect Team OpenAPI SecretKey",
"enabled": true
}
}
}
```
**Recommended: Save to `~/.openclaw/channels.json`** — This is the dedicated file for channel credentials.
**⚠️ Security Note**: Storing credentials in config files is convenient but introduces some risk. Environment variables are recommended for better security.
### 3.2 Dependency Installation
This Skills is developed based on Python 3.8+. It is recommended to install necessary dependencies using the following command:
```bash
pip3 install requests tabulate pycryptodome Pillow
```
---
## 🔒 Config File Reading Details
**Credential Priority** (Highest to Lowest):
```
┌─────────────────────────────────────────────────────────────┐
│ 1. Environment Variables (Highest Priority - Recommended) │
│ ├─ HIK_CONNECT_TEAM_OPENAPI_APP_KEY │
│ └─ HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY │
│ ✅ Advantage: No config file reading, fully isolated │
├─────────────────────────────────────────────────────────────┤
│ 2. OpenClaw Config Files (Only when env vars not set) │
│ ├─ ~/.openclaw/config.json │
│ ├─ ~/.openclaw/gateway/config.json │
│ └─ ~/.openclaw/channels.json │
│ ⚠️ Note: Only reads channels.hik_connect_team_openapi field │
├─────────────────────────────────────────────────────────────┤
```
## 4. Directory Structure Description
```text
Hik-Connect_Team_Skills/
├── SKILL.md # This guide file (Full-featured integration guide)
├── lib/ # Core library
│ └── token_manager.py # Encapsulates HCTOpenClient base class, handles Token refresh, request retries, and path searching
└── modules/ # Functional sub-modules
├── Hik-Connect_Team_Resource/ # Resource Management: Devices, channels, details
├── Hik-Connect_Team_ACS/ # Access Control: Open/close, normally open/normally closed
├── Hik-Connect_Team_Capture/ # Device Capture: Real-time trigger, URL acquisition
├── Hik-Connect_Team_Video/ # Video Streaming: Real-time preview address acquisition
└── Hik-Connect_Team_Alarm/ # Alarm Push: Webhook management, event subscription
```
---
## 5. Security and Best Practices
1. **Token Security**: The Skills automatically caches Tokens locally. Please ensure the security of the running environment to prevent unauthorized reading of cache files in the `lib/` directory.
2. **HTTPS Mandatory Requirement**: All Webhook callbacks from the HCT platform must use HTTPS. It is recommended to use `ngrok` or `cpolar` with SSL certificates for secure access.
3. **Signature Verification**: In the Alarm module, be sure to configure `signSecret` and implement HMAC-SHA256 signature verification on your receiving end to prevent forged alarm pushes.
4. **Error Handling**: All scripts return standard JSON format. If `success` is `false`, please check the `message` field for detailed error reasons.
---
FILE:README.md
# Hik-Connect Team (HCT) Skills
Welcome to the **Hik-Connect Team (HCT) Skills**. This is a comprehensive developer skill set designed for **Hik-Connect for Teams (HCT)**, providing a modular and efficient way to manage and control HCT devices through the **HCTOpen OpenAPI** system.
## 🌟 Overview
The HCT Skills empowers developers to integrate professional security and management features into their own applications or automated workflows. It handles the complexities of authentication, token management, and standardized communication with Hikvision's cloud services.
### Key Features
- **Resource Management**: Discover devices, get details, and enumerate channels.
- **Access Control (ACS)**: Remotely open/close doors and manage access states.
- **Real-time Capture**: Trigger and retrieve live snapshots from cameras.
- **Video Streaming**: Generate secure, time-limited URLs for live video previews.
- **Alarm Management**: Subscribe to events and receive real-time notifications via Webhooks.
---
## 🛠 Modules & Capabilities
The Skills is divided into specialized modules, each with its own dedicated scripts and documentation:
| Module | Description | Key Scripts |
|:----------------------------------------------------------------|:----------------------------|:-------------------------------------------------------|
| [**📦 Resource**](./modules/Hik-Connect_Team_Resource/SKILL.md) | Manage your asset inventory | `list_devices.py`, `device_detail.py`, `list_doors.py` |
| [**🚪 ACS**](./modules/Hik-Connect_Team_ACS/SKILL.md) | Remote door control | `acs_control.py` |
| [**📸 Capture**](./modules/Hik-Connect_Team_Capture/SKILL.md) | Instant image snapshots | `capture_pic.py` |
| [**🎥 Video**](./modules/Hik-Connect_Team_Video/SKILL.md) | Live stream URL generation | `get_video_url.py` |
| [**🔔 Alarm**](./modules/Hik-Connect_Team_Alarm/SKILL.md) | Webhook & Event management | `webhook_manager.py`, `event_manager.py` |
---
## 🚀 Getting Started
### 1. Prerequisites
- **Python 3.8+**
- **Node.js** (Required only for the Alarm/Webhook service)
- **HCT Developer Credentials**: You must have `HIK_CONNECT_TEAM_OPENAPI_APP_KEY` and `HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY` from the Hik-Connect HCT Developer Platform. The API domain will be automatically obtained from the token response.
### 2. Installation
Install the required Python dependencies:
```bash
pip3 install requests tabulate pycryptodome Pillow
```
### 3. Configuration
**Credentials only need to be configured ONCE. The system will automatically find and use them.**
#### Method A: Environment Variables (Recommended)
Set in your shell profile or before running scripts:
```bash
export HIK_CONNECT_TEAM_OPENAPI_APP_KEY="Your Hik-Connect Team OpenAPI AppKey"
export HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY="Your Hik-Connect Team OpenAPI SecretKey"
```
#### Method B: OpenClaw Config Files (Fallback)
If environment variables are not set, the system will automatically search for credentials in OpenClaw config files:
```
Config search order (first found wins):
1. ~/.openclaw/config.json
2. ~/.openclaw/gateway/config.json
3. ~/.openclaw/channels.json ⭐ Recommended
```
Config format:
```json
{
"channels": {
"hik_connect_team_openapi": {
"appKey": "Your Hik-Connect Team OpenAPI AppKey",
"secretKey": "Your Hik-Connect Team OpenAPI SecretKey",
"enabled": true
}
}
}
```
**Note**: API domain is automatically obtained from token response.
---
## 🔒 Credential Priority
**The skill obtains credentials in the following order (highest to lowest priority):**
```
┌─────────────────────────────────────────────────────────────┐
│ 1. Environment Variables (Highest Priority - Recommended) │
│ ├─ HIK_CONNECT_TEAM_OPENAPI_APP_KEY │
│ └─ HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY │
│ ✅ Advantage: No config file reading, fully isolated │
├─────────────────────────────────────────────────────────────┤
│ 2. OpenClaw Config Files (Only when env vars not set) │
│ ├─ ~/.openclaw/config.json │
│ ├─ ~/.openclaw/gateway/config.json │
│ └─ ~/.openclaw/channels.json │
│ ⚠️ Note: Only reads channels.hik_connect_team_openapi field │
├─────────────────────────────────────────────────────────────┤
│ 3. Error Handling (When no valid credentials) │
│ Program exits with error message │
└─────────────────────────────────────────────────────────────┘
```
---
## 💡 Usage Examples
### Example 1: List All Devices
```bash
cd "Hik-Connect Team Skills/modules/Hik-Connect_Team_Resource/scripts"
python list_devices.py
```
### Example 2: Remote Door Opening
```bash
cd "Hik-Connect Team Skills/modules/Hik-Connect_Team_ACS/scripts"
python acs_control.py --action-type 1 --element-list "your_door_resource_id"
```
### Example 3: Capture Device Image
```bash
cd "Hik-Connect Team Skills/modules/Hik-Connect_Team_Capture/scripts"
python capture_pic.py DEVICE_SERIAL
```
### Example 4: Get a Live Video Stream
```bash
cd "Hik-Connect Team Skills/modules/Hik-Connect_Team_Video/scripts"
python get_video_url.py --device-serial "SERIAL123" --resource-id "RES_ID_456"
```
### Example 5: Setting Up Alarms
The Alarm module requires a **public HTTPS URL** to receive webhook pushes from HCT platform.
#### Option A — Same Server as OpenClaw (Simplest)
1. Configure reverse proxy to route `/hikvision/webhook` to `127.0.0.1:3090`
2. Start Webhook server: `node modules/Hik-Connect_Team_Alarm/scripts/server.js`
3. Register URL: `python modules/Hik-Connect_Team_Alarm/scripts/webhook_manager.py save --url "https://your-domain.com/hikvision/webhook" --secret "your_secret"`
4. Subscribe: `python modules/Hik-Connect_Team_Alarm/scripts/event_manager.py subscribe`
#### Option B — Use a Tunnel Tool (ngrok/cpolar)
1. Run `ngrok http 3090` on OpenClaw server
2. Copy the tunnel URL
3. Start Webhook server and register the tunnel URL
> **Note**: Tunnel URLs change on restart for free tiers — you must re-register the Webhook after each restart.
#### Option C — Different Server with Public URL
If you have a separate public server and OpenClaw's port 3090 is reachable from it:
1. On your server, configure a reverse proxy to forward `/hikvision/webhook` to `<OpenClaw_SERVER_IP>:3090`
2. Start the Webhook server on OpenClaw server: `node modules/Hik-Connect_Team_Alarm/scripts/server.js`
3. Register your public URL: `python modules/Hik-Connect_Team_Alarm/scripts/webhook_manager.py save --url "https://your-domain.com/hikvision/webhook" --secret "your_secret"`
4. Subscribe to events: `python modules/Hik-Connect_Team_Alarm/scripts/event_manager.py subscribe`
> **⚠️ Third-party webhook receiver services (Pipedream, AWS Lambda URL, etc.) are NOT recommended** — they only receive requests, they cannot forward to your internal OpenClaw server.
### About Alarm Message Format
When alarm messages are pushed to OpenClaw, the AI agent may inherently attempt to translate, summarize, or reformat the raw data. This behavior is difficult to completely avoid.
**If you need a specific alarm message format:**
- Explicitly instruct the AI agent: "Do not process/modify/summarize the alarm data, return it as-is"
- If the format is still not ideal, directly tell the AI your preferred format (e.g., "Show alarm messages in a table", "Use the raw JSON format", etc.)
The raw alarm data from HCT platform contains complete information — the AI's processing is optional and can be overridden by your instructions.
---
## 🔒 Security Recommendations
### 1. Use Minimal Permission Credentials
- Create dedicated `HIK_CONNECT_TEAM_OPENAPI_APP_KEY`/`HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY` with only necessary API permissions
- Do not use main account credentials
- Rotate credentials regularly (recommended every 90 days)
### 2. Environment Variable Security
```bash
# Recommended: Use .env file (do not commit to version control)
echo "HIK_CONNECT_TEAM_OPENAPI_APP_KEY=your_key" >> .env
echo "HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY=your_secret" >> .env
chmod 600 .env
# Load environment variables
source .env
```
### 3. Disable Token Caching (High Security)
```bash
export HIK_CONNECT_TEAM_TOKEN_CACHE=0
python3 scripts/xxx.py ...
```
### 4. Regular Cache Cleanup
```bash
# Clear all cached Tokens
rm -rf /tmp/hctopen_global_token_cache/
```
### 5. Config File Scanning
The skill reads Hikvision configuration from (only when env vars not set):
```
~/.openclaw/config.json
~/.openclaw/gateway/config.json
~/.openclaw/channels.json
```
**Config Format**:
```json
{
"channels": {
"hik_connect_team_openapi": {
"appKey": "Your Hik-Connect Team OpenAPI AppKey",
"secretKey": "Your Hik-Connect Team OpenAPI SecretKey",
"enabled": true
}
}
}
```
**Security Recommendations**:
- ✅ Use dedicated Hikvision credentials, do not share with other services
- ✅ Set environment variables to override config file scanning if needed
- ✅ Regularly review credential permissions in config files
- ❌ Do not store main account credentials in config files
---
## ✅ Security Audit Checklist
### Pre-Installation Checks
- [ ] **Review Code** — Read `lib/token_manager.py` and module scripts
- [ ] **Verify API Domain** — Confirm domain is Hikvision official endpoint
- [ ] **Prepare Test Credentials** — Create dedicated app with only necessary permissions
- [ ] **Check Config Files** — Review `~/.openclaw/*.json` for sensitive credentials
- [ ] **Confirm Cache Location** — Ensure `/tmp/hctopen_global_token_cache/` is acceptable
### Installation Configuration
- [ ] **Use Environment Variables** — Prefer `HIK_CONNECT_TEAM_OPENAPI_APP_KEY` etc.
- [ ] **Disable Caching** (Optional) — Set `HIK_CONNECT_TEAM_TOKEN_CACHE=0` for high security
- [ ] **Minimal Permission Credentials** — Do not use main account credentials
- [ ] **Isolated Environment** (Optional) — Run in container/VM
### Post-Installation Verification
- [ ] **Verify Cache Permissions** — Confirm cache file permissions are 600
- [ ] **Test Functionality** — Verify with test device
- [ ] **Monitor Logs** — Check API calls are normal
- [ ] **Secure Credential Storage** — Use key manager
### Ongoing Maintenance
- [ ] **Rotate Credentials** — Recommended every 90 days
- [ ] **Review Dependencies** — Check `requests` etc. for security updates
- [ ] **Clear Cache** — Clear cache in high security environments
- [ ] **Monitor for Anomalies** — Watch for unusual API calls or errors
---
## 🔒 Security & Best Practices
- **Least Privilege**: Use credentials with only the permissions necessary for your specific task.
- **Token Caching**: Skills automatically caches access tokens in system temp directory (600 permissions) to minimize API calls.
- **HTTPS**: All Webhook endpoints **must** use HTTPS.
- **Stream Encryption**: If devices have "Stream Encryption" enabled, you must manually decrypt in HCT platform or app.
---
## 📂 Project Structure
```text
Hik-Connect_Team_Skills/
├── README.md # This overview document
├── SKILL.md # Technical integration guide
├── lib/ # Shared libraries
│ ├── token_manager.py # Token management & base client
│ └── README_TOKEN_MANAGER.md # Token manager documentation
└── modules/ # Functional sub-modules
├── Hik-Connect_Team_Resource/
├── Hik-Connect_Team_ACS/
├── Hik-Connect_Team_Capture/
├── Hik-Connect_Team_Video/
└── Hik-Connect_Team_Alarm/
```
For detailed information on each module, please refer to the `SKILL.md` file within each module's directory.
---
FILE:modules/Hik-Connect_Team_Video/SKILL.md
---
name: hctopen-video
description: |
HCTOpen device video stream skill. Supports getting real-time video stream address for specified device channel.
Use when: Need to get device real-time video stream URL.
Before calling this Skill's script, please check if user provided optional parameters. If user didn't provide video-duration, please clearly inform user in reply: 'Currently using default stream duration (duration 10 minutes), if you need to adjust, please let me know'. After getting confirmation or ignoring, continue execution.
⚠️ Security Requirement: Must set environment variables: Hik-Connect Team OpenAPI AppKey,Hik-Connect Team OpenAPI SecretKey. (API Domain is automatically obtained from token response)
parameters:
- name: device-serial
type: string
description: "Device serial number"
required: true
- name: resource-id
type: string
description: "Channel/monitoring point resource ID"
required: true
- name: video-duration
type: integer
description: "Video stream duration (seconds), default 600s, if user didn't specify duration, please inform user default value will be used, and ask if adjustment is needed."
default: 600
output_format:
- "⚠️ Important: After getting video stream, must return in Markdown link format: `[url]({url})`, do not return raw URL only!"
- "Example: [https://example.com/stream.m3u8]({https://example.com/stream.m3u8})"
troubleshooting:
scope: on-demand-only
trigger: "Only activate when user explicitly reports: 'video won't play', 'stream fails', 'cannot open', or similar playback errors."
mandatory_checks:
- "Step 1: Verify Stream Encryption is Disabled via device_detail.py"
- "Step 2: Verify video encoding format is H264 (ask user to check in HCT platform)"
metadata:
openclaw:
emoji: "🎥"
requires:
env: ["HIK_CONNECT_TEAM_OPENAPI_APP_KEY", "HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY"]
pip: ["requests"]
primaryEnv: "HIK_CONNECT_TEAM_OPENAPI_APP_KEY"
warnings:
- "May read ~/.openclaw/*.json for credentials (env vars have priority)"
config:
configFileRead:
paths:
- "~/.openclaw/config.json"
- "~/.openclaw/gateway/config.json"
- "~/.openclaw/channels.json"
priority: "lower than environment variables"
description: "Reads Hik-Connect Team credentials from OpenClaw config files as fallback"
---
# HCTOpen Video
HCT is short for Hik-Connect for Teams, meaning Hik-Connect Team mode.
HCTOpen is short for Hik-Connect for Teams OpenAPI.
This Skill provides device real-time video stream address acquisition functionality, can be accessed directly through link.
---
## ⚠️ Security Warning (Read Before Use)
| # | Check Item | Status | Description |
|---|---------------------------|-------------|----------------------------------------------------------------------------------------------------|
| 1 | **Credential Permission** | ⚠️ Required | Please use credentials with **video stream permission**, avoid using super admin credentials |
| 2 | **Traffic Consumption** | ⚠️ Note | Real-time video stream will consume large bandwidth, please close player in time when not in use |
| 3 | **Token Cache** | ✅ Encrypted | Token cached in system temp directory, only current user can read (600 permission) |
| 4 | **API Domain** | ✅ Auto | API domain is automatically obtained from token response (no longer requires manual configuration) |
---
## 🚀 Quick Start
### Run Video Stream Script
```bash
# Scenario 1: Get video stream for specified device and channel (default 600s)
python scripts/get_video_url.py --device-serial J10137390 --resource-id 6a447d3f9cfe4c8e8394c19f8fbcd3ba
# Scenario 2: Get video stream for specified duration (60s)
python scripts/get_video_url.py --device-serial D72821502 --resource-id 661543ed4b35465a9767081ae0a8bf45 --video-duration 600
```
> ⚠️ **Important**: The `--resource-id` must be the **camera resource ID** obtained from `device_channels.py`!
---
## 🛠 Workflow
```mermaid
graph TD
A[Start Script] --> B{Check Environment Variables}
B -- Missing --> C[Report Error and Exit]
B -- Pass --> D[Get AccessToken]
D --> E{Is Token Valid?}
E -- Cache Valid --> F[Use Cache Directly]
E -- Expired/No Cache --> G[Call API to Get New Token]
G --> H[Save to Local Cache]
F --> I[Send Video Stream Request]
H --> I
I --> J{Parse Return Result}
J -- Success --> K[Print Video Stream URL and Expiration Time]
J -- Failed --> L[Print Error Message]
K --> M[Output JSON Result]
L --> M
M --> N[End]
```
---
## 📋 API Parameter Details
### 1. Device Video Stream Request Parameters
**Endpoint**: `POST /api/hccgw/video/v1/live/address/get`
| Parameter Name | Type | Description | Required | Default | Notes |
|----------------|---------|--------------------------------------|----------|---------|----------------------------|
| `deviceSerial` | String | Device serial number | **Yes** | - | Device unique identifier |
| `resourceId` | String | Channel/monitoring point resource ID | **Yes** | - | Channel unique identifier |
| `expireTime` | Integer | Preview duration (seconds) | No | 600 | Default 600 seconds |
| `protocol` | Integer | Stream protocol | No | 2 | Fixed: 2 (HLS format only) |
### 2. API Return Data Description
| Field Name | Type | Description | Notes |
|--------------|---------|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| `url` | String | Video stream address | Directly accessible video stream URL |
| `expireTime` | String | Expiration time in `yyyy-mm-dd hh:mm:ss` format | Local timezone. **IMPORTANT: This value is the authoritative source. Do NOT parse expiration time from URL query parameters (e.g., Expires, expire).** |
| `playable` | Boolean | Whether the Video Stream URL is playable | If `false`, check field for reason. |
---
## 📝 Output Example
### Video Stream Success Example:
```text
[2026-04-23 18:12:02] Requesting video stream: Device=J10137390, Resource=6a447d3f9cfe4c8e8394c19f8fbcd3ba
[SUCCESS] Video stream successful: https://isgpopen.ezvizlife.com/v3/openlive/J10137390_1_1.m3u8?expire=1776939724&id=967488042038833152&c=c3a53f2806&t=4a33a0fa618fec303534c5bb856693aef55b488353c0f56a6edbc6dba8e54079&ev=100
[INFO] Stream URL expiration time: 2026-04-23 18:22:04
[JSON Output]
{
"success": true,
"url": "https://isgpopen.ezvizlife.com/v3/openlive/J10137390_1_1.m3u8?expire=1776939724&id=967488042038833152&c=c3a53f2806&t=4a33a0fa618fec303534c5bb856693aef55b488353c0f56a6edbc6dba8e54079&ev=100",
"expireTime": "2026-04-23 18:22:04",
"playable": true,
"error": null
}
======================================================================
Done
======================================================================
```
### Video Stream Failed Example( video encoding format is H265,Not Supported):
```text
[2026-04-24 13:51:42] Requesting video stream: Device=D72821502, Resource=661543ed4b35465a9767081ae0a8bf45
[SUCCESS] Got stream URL: https://vtmucyn.ezvizlife.com:8883/v3/openlive/D72821502_1_1.m3u8?expire=1777010504&id=967784913892188160&c=caf588fab7&t=837d2555567061dfa6095842439eafaf8536cf660f0f5aa5ee87c3c327916972&ev=100&u=d00f8fbf53ce42c1aaa8731f4ccacd68
[INFO] Stream URL expiration time: 2026-04-24 14:01:44
[ERROR] Stream URL is not playable, the error type is : H265_NOT_SUPPORTED
[JSON Output]
{
"success": false,
"url": "https://vtmucyn.ezvizlife.com:8883/v3/openlive/D72821502_1_1.m3u8?expire=1777010504&id=967784913892188160&c=caf588fab7&t=837d2555567061dfa6095842439eafaf8536cf660f0f5aa5ee87c3c327916972&ev=100&u=d00f8fbf53ce42c1aaa8731f4ccacd68",
"expireTime": "2026-04-24 14:01:44",
"playable": false
}
======================================================================
Done
======================================================================
```
---
## 📂 File Structure
```text
├── scripts/
│ └── get_video_url.py # Device video stream core execution script
└── SKILL.md # Skill usage documentation
```
---
## ❓ FAQ
- **Q: Why is video stream loading slowly?**
- A: Video stream quality is affected by network bandwidth, please ensure stable network environment.
- **Q: What if "Resource ID error" is shown?**
- A: Please first get correct channel `resourceId` through resource management module.
- **Q: What is the validity period of video stream address?**
- A: **Equals your configured stream duration**, which is the value of the `video-duration` parameter. For example, setting `--video-duration 1080` (18 minutes) means the address validity is exactly 18 minutes.
- **Q: What if video stream address is expired?**
- A: Video stream address has time limit, please re-run script to get after expiration.
- **Q: Can video stream address be opened and played directly?**
- A: Yes.
- **Q: Video stream address fails to load?**
- A: **Must check in this order:**
1. **Stream encryption**: Run `device_detail.py <serial>` — `Stream Encryption` must be `Disabled`
2. **Video encoding format**: Check in HCT platform — must be **H264** (H265 may fail in browser)
---
---
**Error Codes**:
| Return Code | Return Message | Description |
|-------------|-----------------------|---------------------------------------------------------------------------------------------|
| EVZ60019 | Encryption is enabled | Stream encryption not disabled, you MUST disable it in HCT platform before stream will work |
---
FILE:modules/Hik-Connect_Team_Video/scripts/get_video_url.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Device Video Stream
"""
import sys
import os
import json
import argparse
from datetime import datetime, timezone
try:
import requests
except ImportError:
requests = None
# Robust lib directory import logic: search upward until lib directory is found
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Search upward 3 levels for root directory containing lib
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory, please ensure script is located in Hik-Connect_Team Skills directory structure")
sys.exit(1)
from token_manager import HCTOpenClient
def verify_stream(url):
"""
Verify stream is playable by fetching m3u8 and checking for error patterns.
Returns: (is_valid, error_type)
- H265 error pattern: m3u8 contains "ErrCode/9053"
"""
if not requests:
print("[WARN] requests library not installed, skipping stream verification")
return True, None
try:
resp = requests.get(url, timeout=5, headers={"User-Agent": "HCTOpen/1.0"})
if resp.status_code != 200:
return True, None # Don't block on HTTP errors, let player handle
content = resp.text
# Check for H265 error indicator: ErrCode/9053 in playlist
if "ErrCode/9053" in content or "9053_0.ts" in content:
return False, "H265_NOT_SUPPORTED"
# Check if playlist immediately ends (no valid segments)
lines = content.split("\n")
segment_count = sum(1 for line in lines if line.endswith(".ts"))
if segment_count == 0 and "#EXT-X-ENDLIST" in content:
return False, "NO_VALID_SEGMENTS"
return True, None
except Exception as e:
print(f"[WARN] Stream verification failed: {e}")
return True, None # Don't block on network errors
def format_expire_time(exp_time_ms):
"""Convert millisecond timestamp to yyyy-mm-dd hh:mm:ss in local timezone"""
if not exp_time_ms:
return None
dt = datetime.fromtimestamp(exp_time_ms / 1000, tz=timezone.utc).astimezone()
return dt.strftime("%Y-%m-%d %H:%M:%S")
class VideoClient(HCTOpenClient):
"""Device video stream client"""
def get_url(self, device_serial: str, resource_id: str, video_duration: int = 600):
"""Get video stream address"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Requesting video stream: Device={device_serial}, Resource={resource_id}")
endpoint = "/api/hccgw/video/v1/live/address/get"
payload = {
"resourceId": resource_id,
"deviceSerial": device_serial,
"protocol": 2, #HLS format: Stream retrieval supports only this format; no other formats are supported.
"expireTime": video_duration
}
# Video module API uses "Token" as Header Key
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
data = result.get("data", {})
stream_url = data.get("url")
exp_time_ms = data.get("expireTime")
if stream_url:
print(f"[SUCCESS] Got stream URL: {stream_url}")
# Verify stream is playable (check for H265 errors)
is_valid, error_type = verify_stream(stream_url)
# Format expire time as yyyy-mm-dd hh:mm:ss
expire_time_str = format_expire_time(exp_time_ms)
print(f"[INFO] Stream URL expiration time: {expire_time_str}")
if not is_valid:
print(f"[ERROR] Stream URL is not playable, the error type is : {error_type}")
self.exit_with_json({
"success": False,
"url": stream_url,
"expireTime": expire_time_str,
"playable": False
})
self.exit_with_json({
"success": True,
"url": stream_url,
"expireTime": expire_time_str,
"playable": True
})
else:
self.exit_with_json({
"success": False,
"url": None,
"expireTime": None,
"playable": False
})
else:
# Use unified message field
print(f"[ERROR] Video stream failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({
"success": False,
"url": None,
"expireTime": None,
"playable": False
})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Device Video Stream")
parser.add_argument("--device-serial", required=True, help="Device serial number")
parser.add_argument("--resource-id", required=True, help="Resource ID (Channel ID)")
parser.add_argument("--video-duration", type=int, default=600, help="Valid duration (seconds)")
args = parser.parse_args()
client = VideoClient()
client.get_url(args.device_serial, args.resource_id, args.video_duration)
if __name__ == "__main__":
main()
FILE:modules/Hik-Connect_Team_Resource/SKILL.md
---
name: hctopen-resource-manager
description: |
HCTOpen resource management skill. Supports viewing device list and specific device details, all channel details under specific device.
Use when: Need to view available devices, get specific device detailed information, get all channel information under specific device, etc.
Before calling this Skill's script, please check if user provided device serial number. If user didn't provide device serial number, please clearly inform user in reply: 'Currently using default parameters (such as viewing device list), if you need to view specific device information, please provide device serial number'. After getting confirmation or ignoring, continue execution.
⚠️ Security Requirement: Must set environment variables: Hik-Connect Team OpenAPI AppKey, Hik-Connect Team OpenAPI SecretKey. (API Domain is automatically obtained from token response)
parameters:
- name: device-serial
type: string
description: "Device serial number"
required: false
- name: page
type: integer
description: "Page number, default is 1"
default: 1
- name: page-size
type: integer
description: "Page size, default is 10"
default: 10
- name: device-category
type: string
description: "Device category filter. Options: encodingDevice, accessControllerDevice, alarmDevice, videoIntercomDevice, mobileDevice, businessDisplayDevice"
required: false
- name: match-key
type: string
description: "Fuzzy match key for device name or serial number. Only effective when device-category is specified."
required: false
responses:
- success: true
template: "Device information retrieved for you:"
media: "list_card"
metadata:
openclaw:
emoji: "📦"
requires:
env: ["HIK_CONNECT_TEAM_OPENAPI_APP_KEY", "HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY"]
pip: ["requests"]
primaryEnv: "HIK_CONNECT_TEAM_OPENAPI_APP_KEY"
warnings:
- "May read ~/.openclaw/*.json for credentials (env vars have priority)"
config:
configFileRead:
paths:
- "~/.openclaw/config.json"
- "~/.openclaw/gateway/config.json"
- "~/.openclaw/channels.json"
priority: "lower than environment variables"
description: "Reads Hik-Connect Team credentials from OpenClaw config files as fallback"
---
# HCTOpen Resource Manager
HCT is short for Hik-Connect for Teams, meaning Hik-Connect Team mode.
HCTOpen is short for Hik-Connect for Teams OpenAPI.
This Skill supports three core functions: view device list, query device details, and channel details under device.
---
## ⚠️ Security Warning (Read Before Use)
| # | Check Item | Status | Description |
|---|---------------------------|-------------|----------------------------------------------------------------------------------------------------|
| 1 | **Credential Permission** | ⚠️ Required | Please use credentials with **resource query permission**, avoid using super admin credentials |
| 2 | **Token Cache** | ✅ Encrypted | Token cached in system temp directory, only current user can read (600 permission) |
| 3 | **API Domain** | ✅ Auto | API domain is automatically obtained from token response (no longer requires manual configuration) |
---
## 🚀 Quick Start
### Run Resource Management Scripts
```bash
# Scenario 1: View device list (default pagination)
python scripts/list_devices.py
# Scenario 1a: Filter by device category (encodingDevice)
python scripts/list_devices.py --device-category encodingDevice
# Scenario 1b: Filter by device category with fuzzy match on name/serial
python scripts/list_devices.py --device-category encodingDevice --match-key D728215
# Scenario 2: Query single device details (by serial number)
python scripts/device_detail.py L33721705
# Scenario 3: View specific device channel list
python scripts/device_channels.py J10137390
# Scenario 4: View door access resource list (specified serial number)
python scripts/list_doors.py L33721705
```
---
## 🛠 Workflow
```mermaid
graph TD
A[Start Script] --> B{Check Environment Variables}
B -- Missing --> C[Report Error and Exit]
B -- Pass --> D[Get AccessToken]
D --> E{Is Token Valid?}
E -- Cache Valid --> F[Use Cache Directly]
E -- Expired/No Cache --> G[Call API to Get New Token]
G --> H[Save to Local Cache]
F --> I[Send Resource Query Request]
H --> I
I --> J{Parse Return Result}
J -- Success --> K[Print Resource List Table]
J -- Failed --> L[Print Error Message]
K --> M[Output JSON Result]
L --> M
M --> N[End]
```
---
## 📋 API Parameter Details
### 1. Device List Request Parameters
**Endpoint**: `POST /api/hccgw/resource/v1/devices/get`
| Parameter Name | Type | Description | Required | Default | Notes |
|-------------------|---------|---------------------------------------|----------|---------|---------------------------------------------------------------------------------------------------------------|
| `page` | Integer | Page number | No | 1 | Starts from 1 |
| `pageSize` | Integer | Page size | No | 10 | Max 100 |
| `deviceCategory` | String | Device category filter | No | - | encodingDevice, accessControllerDevice, alarmDevice, videoIntercomDevice, mobileDevice, businessDisplayDevice |
| `filter.matchKey` | String | Fuzzy match for device name or serial | No | - | Only effective when deviceCategory is specified |
#### deviceCategory Options
| deviceCategory Value | Description |
|--------------------------|----------------------------|
| `encodingDevice` | `Encoding Device / Camera` |
| `accessControllerDevice` | `Access Controller Device` |
| `alarmDevice` | `Alarm Device` |
| `videoIntercomDevice` | `Video Intercom Device` |
| `mobileDevice` | `Mobile Device` |
| `businessDisplayDevice` | `Business Display Device` |
### Device List Output Field Description
| Field Name | Type | Description |
|--------------------------|---------|------------------------------------------------------|
| `success` | Boolean | Whether request was successful |
| `total` | Integer | Total number of devices |
| `pageIndex` | Integer | Current page number |
| `pageSize` | Integer | Page size |
| `devices` | Array | Device list, each element is a device object |
| `devices[].id` | String | Device ID |
| `devices[].name` | String | Device name |
| `devices[].category` | String | Device type |
| `devices[].type` | String | Device model |
| `devices[].serialNo` | String | Device serial number |
| `devices[].version` | String | Firmware version |
| `devices[].onlineStatus` | Integer | Network status: 0 (offline), 1 (online), 2 (unknown) |
| `devices[].addTime` | String | Added time |
### Device List Success Example:
```text
[2026-04-09 15:44:01] Getting device list (page 1, 10 items per page)...
======================================================================
HCTOpen Device List (Total: 2, Current page count: 2)
======================================================================
No. Device ID Device Serial Number Device Name Model Version Device Type Added Time Status
---------------------------------------------------------------------------------------------------------------------------------------
1 2604f502e63247d393e83c07f58705b9 D72821502 Small Cup DS-2CV2026G0-IDW V5.5.110 build 200819 encodingDevice 2026-03-30 01:30:55 Online
2 39a2f72cf2d8404b9067d35cfe2d3501 J10137390 Test Room DS-2TD2637-10/P V5.5.64 build 230207 encodingDevice 2026-04-01 05:57:00 Online
======================================================================
[JSON Output]
{
"success": true,
"totalCount": 2,
"pageIndex": 1,
"pageSize": 10,
"devices": [
{
"id": "2604f502e63247d393e83c07f58705b9",
"serialNo": "D72821502",
"name": "Small Cup",
"type": "DS-2CV2026G0-IDW",
"version": "V5.5.110 build 200819",
"onlineStatus": 1,
"category": "encodingDevice",
"addTime": "2026-03-30 01:30:55"
},
{
"id": "39a2f72cf2d8404b9067d35cfe2d3501",
"serialNo": "J10137390",
"name": "Test Room",
"type": "DS-2TD2637-10/P",
"version": "V5.5.64 build 230207",
"onlineStatus": 1,
"category": "encodingDevice",
"addTime": "2026-04-01 05:57:00"
}
]
}
======================================================================
Done
======================================================================
```
### Device List Failed Example:
```text
[2026-04-22 19:05:43] Getting device list (page 1, 10 items per page)...
[WARNING] match-key is only effective when device-category is specified..
{'pageIndex': 1, 'pageSize': 10, 'filter': {'matchKey': 'D728215'}}
[ERROR] Failed to get device list: Device category is request{OPEN000010}
[JSON Output]
{
"success": false,
"error": "Device category is request{OPEN000010}",
"errorCode": "OPEN000010"
}
======================================================================
Done
======================================================================
```
### 2. Device Detail Request Parameters
**Endpoint**: `POST /api/hccgw/resource/v1/devicedetail/get`
| Parameter Name | Type | Description | Required | Default | Notes |
|------------------|--------|----------------------|----------|---------|--------------------------|
| `deviceSerialNo` | String | Device serial number | **Yes** | - | Device unique identifier |
### Device Detail Output Field Description
| Field Name | Type | Description |
|--------------------------------------------|---------|-------------------------------------------------|
| `success` | Boolean | Whether request was successful |
| `data` | Object | Device detail data object |
| `data.device` | Object | Device detailed information |
| `data.device.baseInfo` | Object | Device basic information |
| `data.device.baseInfo.id` | String | Device ID |
| `data.device.baseInfo.name` | String | Device name |
| `data.device.baseInfo.category` | String | Device type |
| `data.device.baseInfo.serialNo` | String | Device serial number |
| `data.device.baseInfo.version` | String | Firmware version |
| `data.device.baseInfo.type` | String | Device model |
| `data.device.baseInfo.streamEncryptEnable` | String | Stream encryption enable, 1-enabled, 0-disabled |
| `data.device.onlineStatus` | Integer | Device online status: 1-online, 0-offline |
### Device Detail Success Example:
```text
======================================================================
HCTOpen Device Detail
======================================================================
[Time] 2026-04-07 10:00:00
[INFO] Querying device details: F68147103
Device Name Device Serial Number Model Version Status
---------------- -------------- ---------------- -------------------- --------
F68147103 F68147103 DS-9664NI-I8 V4.40.220 build 210125 Online
======================================================================
[JSON Output]
{
"success": true,
"data": {
"device": {
"baseInfo": {
"id": "5c263e4293c84eae81720e9e481e33ad",
"name": "F68147103",
"category": "encodingDevice",
"serialNo": "F68147103",
"version": "V4.40.220 build 210125",
"type": "DS-9664NI-I8",
"streamEncryptEnable": "1",
}
"onlineStatus": 1,
}
}
}
======================================================================
Done
======================================================================
```
### 3. Device Channel List Request Parameters
**Endpoint**: `POST /api/hccgw/resource/v1/areas/cameras/get`
| Parameter Name | Type | Description | Required | Default | Notes |
|----------------|---------|----------------------|----------|---------|--------------------------|
| `deviceSerial` | String | Device serial number | **Yes** | - | Device unique identifier |
| `page` | Integer | Page number | No | 1 | Starts from 1 |
| `pageSize` | Integer | Page size | No | 10 | Max 100 |
### Device Channel List Output Field Description
| Field Name | Type | Description |
|---------------------------|---------|-------------------------------------------------------|
| `success` | Boolean | Whether request was successful |
| `data` | Object | Device channel list data object |
| `data.totalCount` | Integer | Total channel count |
| `data.pageIndex` | Integer | Current page number |
| `data.pageSize` | Integer | Page size |
| `data.camera` | Array | Camera channel list, each element is a channel object |
| `data.camera[].id` | String | Camera ID |
| `data.camera[].name` | String | Camera name |
| `data.camera[].online` | String | Online status: "1"-online, "0"-offline |
| `data.camera[].channelNo` | String | Channel number |
### Device Channel List Success Example:
```text
[2026-04-09 17:11:21] Querying device channels: J10137390
======================================================================
HCTOpen Device Channel List (Current page count: 2)
======================================================================
No. Resource ID Channel Name Status Area Channel No.
--------------------------------------------------------------
1 6a447d3f9cfe4c8e8394c19f8fbcd3ba Test Room_1 Offline OpenClaw 1
2 84b70e3ced36474fb2b8e6d02b9f8efc Test Room_2 Offline OpenClaw 2
======================================================================
[JSON Output]
{
"success": true,
"pageIndex": 1,
"pageSize": 50,
"total": 2,
"channels": [
{
"id": "6a447d3f9cfe4c8e8394c19f8fbcd3ba",
"name": "Test Room_1",
"online": "1",
"channelNo": "1"
},
{
"id": "84b70e3ced36474fb2b8e6d02b9f8efc",
"name": "Test Room_2",
"online": "1",
"channelNo": "2"
}
]
}
======================================================================
Done
======================================================================
```
### 4. Door Access Resource List Request Parameters
**Endpoint**: `POST /api/hccgw/resource/v1/areas/doors/get`
| Parameter Name | Type | Description | Required | Default | Notes |
|----------------|--------|----------------------|----------|---------|---------------------------------------------------|
| `deviceSerial` | String | Device serial number | Yes | - | Filter door access resources for specified device |
### Door Access Resource List Output Field Description
| Field Name | Type | Description |
|----------------------|---------|----------------------------------------|
| `success` | Boolean | Whether request was successful |
| `total` | Integer | Total door access resources |
| `doors` | Array | Door access list |
| `doors[].resourceId` | String | Door Resource ID |
| `doors[].name` | String | Door Access name |
| `doors[].online` | String | Online status: "1"-online, "0"-offline |
### Door Access Resource List Success Example:
```text
[2026-04-10 09:49:51] Getting door access resource list (Device serial number: L33721705)...
======================================================================
HCTOpen Door Access Resource List (Count: 1)
======================================================================
No. Door Resource ID Door Access Name Status
---------------------------------------------------
1 2aabf37ad9804f66acc4ad4fb7bd4698 L33721705 Online
======================================================================
[JSON Output]
{
"success": true,
"total": 1,
"doors": [
{
"resourceId": "2aabf37ad9804f66acc4ad4fb7bd4698",
"name": "L33721705",
"online": "1"
}
]
}
======================================================================
Done
======================================================================
```
---
## 📂 File Structure
```text
├── scripts/
│ ├── list_devices.py # Device list query script
│ ├── device_detail.py # Device detail query script
│ ├── device_channels.py # Device channel query script
│ └── list_doors.py # Device door access resource query script
└── SKILL.md # Skill usage documentation
```
---
## ❓ FAQ
- **Q: Why can't I find my device?**
- A: Please ensure Hik-Connect Team OpenAPI AppKey has permission to access the device, and check if serial number is entered correctly.
- **Q: What do status codes 1 and 0 mean?**
- A: 1 means online, 0 means offline.
- **Q: How to get all devices?**
- A: Script supports pagination, if there are many devices, please adjust `--page-size` parameter or loop request.
---
---
#### deviceCategory Options
**Error Codes**:
| Return Code | Return Message | Description |
|-------------|-----------------------------|---------------------------------------------------------------------------|
| OPEN000010 | Device category is request | `match-key` is only effective when `device-category` is specified. |
| OPEN000010 | Device category not support | Please ensure `device-category` is valid and within the supported options |
---
FILE:modules/Hik-Connect_Team_Resource/scripts/device_channels.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Device Channel List
"""
import sys
import os
import json
import argparse
from datetime import datetime
# Robust lib directory import logic: search upward until lib directory is found
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Search upward 3 levels for root directory containing lib
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory, please ensure script is located in Hik-Connect_Team Skills directory structure")
sys.exit(1)
from token_manager import HCTOpenClient
class DeviceChannelsClient(HCTOpenClient):
"""Device channel query client"""
def get_channels(self, device_serial: str, page: int = 1, page_size: int = 50):
"""Get and print device channel list"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Querying device channels: {device_serial}")
endpoint = "/api/hccgw/resource/v1/areas/cameras/get"
payload = {
"pageIndex": page,
"pageSize": page_size,
"filter": {"deviceSerialNo": device_serial}
}
# Resource module API uses "Token" as Header Key
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
data = result.get("data", {})
channels = data.get("camera", [])
total = len(channels)
headers = ["No.", "Resource ID", "Channel Name", "Status", "Area", "Channel No."]
rows = []
for i, ch in enumerate(channels, 1):
status = "Online" if ch.get("online") == "1" else "Offline"
area_name = ch.get("area", {}).get("name", "Unknown")
channel_no = ch.get("device", {}).get("channelInfo", {}).get("no", "-")
rows.append([
i,
ch.get("id"),
ch.get("name", "Unknown"),
status,
area_name,
channel_no
])
self.print_table(f"HCTOpen Device Channel List (Current page count: {total})", headers, rows)
# Maintain output format consistent with original script
self.exit_with_json({
"success": True,
"pageIndex": page,
"pageSize": page_size,
"total": total,
"channels": [
{
"id": c.get("id"),
"name": c.get("name"),
# Convert to "1" or "0"
"online": c.get("online"),
# Map to root-level channelNo
"channelNo": c.get("device", {}).get("channelInfo", {}).get("no")
}
for c in channels
]
})
else:
# Use unified message field
print(f"[ERROR] Failed to get channel list: {result.get('message', 'Unknown error')}")
self.exit_with_json({
"success": False,
"error": result.get("message", "Unknown error"),
"errorCode": result.get("errorCode")
})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Get Device Channel List")
parser.add_argument("device_serial", help="Device serial number")
parser.add_argument("--page", type=int, default=1, help="Page number")
parser.add_argument("--page-size", type=int, default=50, help="Page size")
args = parser.parse_args()
client = DeviceChannelsClient()
client.get_channels(args.device_serial, page=args.page, page_size=args.page_size)
if __name__ == "__main__":
main()
FILE:modules/Hik-Connect_Team_Resource/scripts/device_detail.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Device Detail
"""
import sys
import os
import json
import argparse
from datetime import datetime
# Robust lib directory import logic: search upward until lib directory is found
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Search upward 3 levels for root directory containing lib
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory, please ensure script is located in Hik-Connect_Team Skills directory structure")
sys.exit(1)
from token_manager import HCTOpenClient
class DeviceDetailClient(HCTOpenClient):
"""Device detail query client"""
def get_detail(self, device_serial: str):
"""Get and print device details"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Querying device details: {device_serial}")
endpoint = "/api/hccgw/resource/v1/devicedetail/get"
payload = {"deviceSerialNo": device_serial}
# Resource module API uses "Token" as Header Key
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
data = result.get("data", {}).get("device", {})
base_info = data.get("baseInfo", {})
# 1. Define list of fields to remove
exclude_keys = [
"availableCameraChannelNum",
"availableAlarmInputChannelNum",
"availableAlarmOutputChannelNum",
"areaId",
"area"
]
# 2. Create a simplified base_info for JSON output
# Use dict comprehension to filter out unwanted keys
filtered_base_info = {k: v for k, v in base_info.items() if k not in exclude_keys}
headers = ["Device ID", "Device Name", "Device Serial Number", "Device Type", "Model", "Status", "Version", "Stream Encryption"]
status = "Online" if data.get("onlineStatus") == 1 else "Offline"
rows = [[
base_info.get("id"),
base_info.get("name", "Unknown"),
base_info.get("serialNo", "Unknown"),
base_info.get("category", "Unknown"),
base_info.get("type", "Unknown"),
status,
base_info.get("version", "Unknown"),
"Enabled" if base_info.get("streamEncryptEnable", "0") == "1" else "Disabled",
]]
self.print_table("HCTOpen Device Detail", headers, rows)
# Maintain output format
self.exit_with_json({
"success": True,
"total": 1,
"devices": [{
"base_info": filtered_base_info,
"onlineStatus": data.get("onlineStatus")
}]
})
else:
# Use unified message field
print(f"[ERROR] Failed to get device details: {result.get('message', 'Unknown error')}")
self.exit_with_json({
"success": False,
"error": result.get("message", "Unknown error"),
"errorCode": result.get("errorCode")
})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Get Device Detail")
parser.add_argument("device_serial", help="Device serial number")
args = parser.parse_args()
client = DeviceDetailClient()
client.get_detail(args.device_serial)
if __name__ == "__main__":
main()
FILE:modules/Hik-Connect_Team_Resource/scripts/list_devices.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Device List
"""
import sys
import os
import argparse
import json
from datetime import datetime
# Robust lib directory import logic: search upward until lib directory is found
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Search upward 3 levels for root directory containing lib
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory, please ensure script is located in Hik-Connect_Team Skills directory structure")
sys.exit(1)
from token_manager import HCTOpenClient
class DeviceListClient(HCTOpenClient):
"""Device list query client"""
def fetch_devices(self, page: int = 1, page_size: int = 10, device_category: str = None, match_key: str = None):
"""Get device list and print"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Getting device list (page {page}, {page_size} items per page)...")
# Validate match_key requirement
if match_key and not device_category:
print("[WARNING] match-key is only effective when device-category is specified.")
endpoint = "/api/hccgw/resource/v1/devices/get"
payload = {"pageIndex": page, "pageSize": page_size}
# Add device category filter if specified
if device_category:
payload["deviceCategory"] = device_category
if match_key:
payload["filter"] = {"matchKey": match_key}
print(payload)
# Resource module API uses "Token" as Header Key
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
data = result.get("data", {})
devices = data.get("device", [])
total = len(devices)
headers = ["No.", "Device ID", "Device Serial Number", "Device Name", "Model", "Version", "Device Type", "Added Time", "Status"]
rows = []
for i, dev in enumerate(devices, 1):
status = "Online" if dev.get("onlineStatus") == 1 else "Offline"
rows.append([
i,
dev.get("id"),
dev.get("serialNo", "Unknown"),
dev.get("name", "Unknown"),
dev.get("type", "Unknown"),
dev.get("version", "-"),
dev.get("category", "Unknown"),
dev.get("addTime", "Unknown"),
status
])
self.print_table(f"HCTOpen Device List (Current page count: {total})", headers, rows)
self.exit_with_json({
"success": True,
"total": total,
"devices": [
{
"id": d.get("id"),
"deviceName": d.get("name"),
"serialNo": d.get("serialNo"),
"type": d.get("type"),
"onlineStatus": d.get("onlineStatus"),
"category": d.get("category"),
"addTime": d.get("addTime"),
}
for d in devices
]
})
else:
# Use unified message field
print(f"[ERROR] Failed to get device list: {result.get('message', 'Unknown error')}")
self.exit_with_json({
"success": False,
"error": result.get("message", "Unknown error"),
"errorCode": result.get("errorCode")
})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Get Device List")
parser.add_argument("--page", type=int, default=1, help="Page number (default: 1)")
parser.add_argument("--page-size", type=int, default=10, help="Page size (default: 10)")
parser.add_argument("--device-category", type=str, default=None,
help="Device category filter (encodingDevice, accessControllerDevice, alarmDevice, videoIntercomDevice, mobileDevice, businessDisplayDevice)")
parser.add_argument("--match-key", type=str, default=None,
help="Fuzzy match key for device name or serial number. Only effective when device-category is specified.")
args = parser.parse_args()
client = DeviceListClient()
client.fetch_devices(page=args.page, page_size=args.page_size, device_category=args.device_category, match_key=args.match_key)
if __name__ == "__main__":
main()
FILE:modules/Hik-Connect_Team_Resource/scripts/list_doors.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Door List
"""
import sys
import os
import argparse
import json
from datetime import datetime
# Robust lib directory import logic: search upward until lib directory is found
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Search upward 3 levels for root directory containing lib
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory, please ensure script is located in Hik-Connect_Team Skills directory structure")
sys.exit(1)
from token_manager import HCTOpenClient
class DoorListClient(HCTOpenClient):
"""Door access resource list query client"""
def fetch_doors(self, device_serial: str):
"""Get door access resource list and print"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Getting door access resource list (Device serial number: {device_serial if device_serial else 'All'})...")
endpoint = "/api/hccgw/resource/v1/areas/doors/get"
# pageSize=100, pageIndex=1, includeSubArea=1 are fixed values
payload = {
"pageIndex": 1,
"pageSize": 100,
"filter": {
"includeSubArea": "1",
"deviceSerialNo": device_serial
}
}
# Resource module API uses "Token" as Header Key
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
data = result.get("data", {})
doors = data.get("door", [])
total = len(doors)
headers = ["No.", "Door Resource ID", "Door Access Name", "Status"]
rows = []
simplified_doors = []
for i, door in enumerate(doors, 1):
status = "Online" if door.get("online") == "1" else "Offline"
rows.append([
i,
door.get("id"),
door.get("name", "Unknown"),
status
])
# Only keep id, name, online status
simplified_doors.append({
"resourceId": door.get("id"),
"name": door.get("name"),
"online": door.get("online")
})
self.print_table(f"HCTOpen Door Access Resource List (Count: {total})", headers, rows)
self.exit_with_json({
"success": True,
"total": total,
"doors": simplified_doors
})
else:
# Use unified message field
print(f"[ERROR] Failed to get door access resource list: {result.get('message', 'Unknown error')}")
self.exit_with_json({
"success": False,
"error": result.get("message", "Unknown error"),
"errorCode": result.get("errorCode")
})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Get Door Access Resource List")
parser.add_argument("device_serial", help="Device serial number (optional)")
args = parser.parse_args()
client = DoorListClient()
client.fetch_doors(device_serial=args.device_serial)
if __name__ == "__main__":
main()
FILE:modules/Hik-Connect_Team_Capture/SKILL.md
---
name: hctopen-capture
description: |
HCTOpen device capture and decryption skill. Supports capture for specified device channel, and provides encrypted image decryption functionality. The returned capture address is cloud address instead of local address, can be accessed directly.
Use when: Need to get device real-time image or decrypt encrypted device image.
⚠️ Security Requirement: Must set environment variables: Hik-Connect Team OpenAPI AppKey, Hik-Connect Team OpenAPI SecretKey. (API Domain is automatically obtained from token response)
parameters:
- name: device-serial
type: string
description: "Device serial number"
required: true
- name: channel-no
type: string
description: "Channel number, default is 1"
default: "1"
responses:
- success: true
template: "Preview image generated for you, click link below to view:"
media: "image_card"
metadata:
openclaw:
emoji: "📸"
requires:
env: ["HIK_CONNECT_TEAM_OPENAPI_APP_KEY", "HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY"]
pip: ["requests", "pycryptodome", "Pillow"]
primaryEnv: "HIK_CONNECT_TEAM_OPENAPI_APP_KEY"
warnings:
- "May read ~/.openclaw/*.json for credentials (env vars have priority)"
config:
configFileRead:
paths:
- "~/.openclaw/config.json"
- "~/.openclaw/gateway/config.json"
- "~/.openclaw/channels.json"
priority: "lower than environment variables"
description: "Reads Hik-Connect Team credentials from OpenClaw config files as fallback"
---
# HCTOpen Capture
HCT is short for Hik-Connect for Teams, meaning Hik-Connect Team mode.
HCTOpen is short for Hik-Connect for Teams OpenAPI.
This Skill provides device real-time capture functionality, suitable for anomaly verification, real-time screen preview and other scenarios.
> **Note!!!**: This skill only provides capture capability. If device has stream encryption enabled causing image not viewable, user needs to manually decrypt in HCT!!! Skill has no decryption capability.
> **Important Pre-check Information**:
> - **Check device status before capturing**: Use the device detail function in the resource management module to verify if stream encryption is enabled
> - **Example command**: `python scripts/device_detail.py {device_serial}`
> - If `Stream Encryption` shows `Enabled`, you must disable it first before capture
---
## ⚠️ Security Warning (Read Before Use)
| # | Check Item | Status | Description |
|---|---------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------|
| 1 | **Credential Permission** | ⚠️ Required | Please use credentials with **capture permission**, avoid using super admin credentials |
| 2 | **Image Encryption** | ⚠️ Note | If device has image encryption enabled, returned URL may not be directly viewable, user needs to manually decrypt in HCT |
| 3 | **Token Cache** | ✅ Encrypted | Token cached in system temp directory, only current user can read (600 permission) |
| 4 | **API Domain** | ✅ Auto | API domain is automatically obtained from token response (no longer requires manual configuration) |
---
## 🚀 Quick Start
```bash
# Scenario 1: Capture image for specified device serial number (channel number defaults to 1)
python scripts/capture_pic.py L33721705
# Scenario 2: Capture image for specified device serial number and channel number
python scripts/capture_pic.py D72821502,2
```
---
## 🛠 Workflow
```mermaid
graph TD
A[Start Script] --> B{Check Environment Variables}
B -- Missing --> C[Report Error and Exit]
B -- Pass --> D[Get AccessToken]
D --> E{Is Token Valid?}
E -- Cache Valid --> F[Use Cache Directly]
E -- Expired/No Cache --> G[Call API to Get New Token]
G --> H[Save to Local Cache]
F --> I[Send Capture Request]
H --> I
I --> J{Parse Return Result}
J -- Success --> K[Print Capture URL and Encryption Status]
J -- Failed --> L[Print Error Message]
K --> M[Output JSON Result]
L --> M
M --> N[End]
```
---
## 📋 API Parameter Details
### 1. Device Capture Request Parameters
**Endpoint**: `POST /api/hccgw/resource/v1/device/capturePic`
| Parameter Name | Type | Description | Required | Default | Notes |
|----------------|--------|----------------------|----------|---------|--------------------------|
| `deviceSerial` | String | Device serial number | **Yes** | - | Device unique identifier |
| `channelNo` | String | Channel number | No | "1" | Default is 1 |
### 2. API Return Data Description
| Field Name | Type | Description | Notes |
|---------------|---------|-------------------------|--------------------------------------------------|
| `captureUrl` | String | Capture preview address | Directly accessible image URL (if not encrypted) |
| `isEncrypted` | Integer | Is encrypted | 0-not encrypted, 1-encrypted |
---
## 📝 Output Example
### Capture Success Example:
```text
[2026-04-25 22:25:18] Requesting capture: Device=D72821502, Channel=1
[SUCCESS] Capture successful: https://hpc-sgp-prod-s3-hccvis.oss-ap-southeast-1.aliyuncs.com/hccopen/capture/2026-04-25/D72821502/1/c4d29884-5d0c-47d9-8db7-3ccccd6eaf3b.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260425T142521Z&X-Amz-SignedHeaders=host&X-Amz-Expires=900&X-Amz-Credential=LTAI5tQckMpJxMb4qoHXJySP%2F20260425%2Foss-ap-southeast-1%2Fs3%2Faws4_request&X-Amz-Signature=6dbe52fb30120e3fb65a9e5bed420e5e1dea07c5a78a15eca47f105809babb69
[JSON Output]
{
"success": true,
"captureUrl": "https://hpc-sgp-prod-s3-hccvis.oss-ap-southeast-1.aliyuncs.com/hccopen/capture/2026-04-25/D72821502/1/c4d29884-5d0c-47d9-8db7-3ccccd6eaf3b.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260425T142521Z&X-Amz-SignedHeaders=host&X-Amz-Expires=900&X-Amz-Credential=LTAI5tQckMpJxMb4qoHXJySP%2F20260425%2Foss-ap-southeast-1%2Fs3%2Faws4_request&X-Amz-Signature=6dbe52fb30120e3fb65a9e5bed420e5e1dea07c5a78a15eca47f105809babb69",
"isEncrypted": 0
}
======================================================================
Done
======================================================================
```
---
## 📂 File Structure
```text
├── scripts/
│ └── capture_pic.py # Device capture core execution script
└── SKILL.md # Skill usage documentation
```
---
## ❓ FAQ
- **Q: Why can't the image be opened?**
- A: **There are two main possible reasons:**
1. **Device has stream encryption enabled**: First check using device detail script (`python scripts/device_detail.py {device_serial}`). If it shows `Stream Encryption: Enabled`, you must disable it in HCT platform first
2. **The returned image's `isEncrypted` field is 1**: This means the captured image is encrypted, same solution - disable stream encryption and retry
- **Q: How long is capture URL valid?**
- A: Valid for 15 minutes, please view or download as soon as possible.
- **Q: What if "Device offline" is shown?**
- A: Capture function requires device to be online, please first confirm device status through resource management module.
- **Q: Returned image is a URL address?**
- A: If user didn't explicitly mention needing URL address, default to returning image to user.
---
---
**Error Codes**:
| Return Code | Return Message | Description |
|-------------|-------------------------|-------------------------------------------------------------|
| OPEN000554 | Device Offline | Device is offline, please check device online status |
| OPEN000555 | Device Response Timeout | Device response timeout, please check device network status |
| OPEN000556 | Device Capture Failed | Device capture failed |
---
FILE:modules/Hik-Connect_Team_Capture/scripts/capture_pic.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Device Picture Capture
"""
import sys
import os
import json
import argparse
from datetime import datetime
# Robust lib directory import logic: search upward until lib directory is found
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Search upward 3 levels for root directory containing lib
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory, please ensure script is located in Hik-Connect_Team Skills directory structure")
sys.exit(1)
from token_manager import HCTOpenClient
class CaptureClient(HCTOpenClient):
"""Device capture client"""
def capture(self, device_serial: str, channel_no: int = 1):
"""Execute capture operation"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Requesting capture: Device={device_serial}, Channel={channel_no}")
endpoint = "/api/hccgw/resource/v1/device/capturePic"
payload = {
"deviceSerial": device_serial,
"channelNo": str(channel_no)
}
# Capture module API uses "Token" as Header Key
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
data = result.get("data", {})
capture_url = data.get("captureUrl")
is_encrypted = data.get("isEncrypted")
if capture_url:
print(f"[SUCCESS] Capture successful: {capture_url}")
if is_encrypted == 1:
print("[INFO] Note: Image is encrypted, need to use key to decrypt before viewing")
self.exit_with_json({
"success": True,
"captureUrl": capture_url,
"isEncrypted": is_encrypted
})
else:
print("[ERROR] Response does not contain capture URL")
self.exit_with_json({"success": False, "error": "Capture URL not found"})
else:
# Use unified message field
print(f"[ERROR] Capture failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({
"success": False,
"error": result.get("message", "Unknown error"),
"errorCode": result.get("errorCode")
})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Device Capture")
parser.add_argument("device_info", help="Device serial number, optional comma-separated channel number (e.g. D72821502,1)")
args = parser.parse_args()
parts = args.device_info.split(",")
device_serial = parts[0].strip()
channel_no = 1
if len(parts) > 1:
try:
channel_no = int(parts[1].strip())
except ValueError:
print("[ERROR] Channel number must be integer")
sys.exit(1)
client = CaptureClient()
client.capture(device_serial, channel_no)
if __name__ == "__main__":
main()
FILE:modules/Hik-Connect_Team_Alarm/EVENT_CODES.md
# HCT Alarm Event Codes & Descriptions
This document lists the alarm event codes supported by HCT platform and their corresponding detailed descriptions for reference when subscribing.
## 1. Video Intercom
| Event Code | Description |
|:------------|:-------------------------------------|
| `Msg140001` | Messages about video intercom events |
## 2. On-Board Monitoring
| Event Code | Description |
|:------------|:----------------------------------|
| `Msg330001` | GPS Data Report |
| `Msg330101` | Alarm Triggered by Panic Button |
| `Msg330102` | Alarm Input |
| `Msg330201` | Forward Collision Warning |
| `Msg330202` | Headway Monitoring Warning |
| `Msg330203` | Lane Deviation Warning |
| `Msg330204` | Pedestrian Collision Warning |
| `Msg330205` | Speed Limit Warning |
| `Msg330301` | Blind Spot Warning |
| `Msg330401` | Sharp Turn |
| `Msg330402` | Sudden Brake |
| `Msg330403` | Sudden Acceleration |
| `Msg330404` | Rollover |
| `Msg330405` | Speeding |
| `Msg330406` | Collision |
| `Msg330407` | ACC ON |
| `Msg330408` | ACC OFF |
| `Msg330501` | Smoking |
| `Msg330502` | Using Mobile Phone |
| `Msg330503` | Fatigue Driving |
| `Msg330504` | Distraction |
| `Msg330505` | Seatbelt Unbuckled |
| `Msg330506` | Video Tampering |
| `Msg330507` | Yawning |
| `Msg330508` | Wearing IR Interrupted Sunglasses |
| `Msg330509` | Absence |
| `Msg330510` | Front Passenger Detection |
| `Msg335000` | Person and Vehicle Match |
| `Msg335001` | Person and Vehicle Mismatch |
## 3. Authentication Event
| Event Code | Description |
|:------------|:-------------------------------------------------|
| `Msg110001` | Access Granted by Card and Fingerprint |
| `Msg110002` | Access Granted by Card, Fingerprint, and PIN |
| `Msg110003` | Access Granted by Card |
| `Msg110004` | Access Granted by Card and PIN |
| `Msg110005` | Access Granted by Fingerprint |
| `Msg110006` | Access Granted by Fingerprint and PIN |
| `Msg110007` | Duress Alarm |
| `Msg110008` | Access Granted by Face and Fingerprint |
| `Msg110009` | Access Granted by Face and PIN |
| `Msg110010` | Access Granted by Face and Card |
| `Msg110011` | Access Granted by Face, PIN, and Fingerprint |
| `Msg110012` | Access Granted by Face, Card, and Fingerprint |
| `Msg110013` | Access Granted by Face |
| `Msg110018` | Access Granted via Combined Authentication Modes |
| `Msg110019` | Skin-Surface Temperature Measured |
| `Msg110020` | Password Authenticated |
| `Msg110022` | Access Granted by Bluetooth |
| `Msg110023` | Access Granted via QR Code |
| `Msg110024` | Access Granted via Keyfob |
| `Msg110501` | Verifying Card Encryption Failed |
| `Msg110502` | Max. Card Access Failed Attempts |
| `Msg110505` | Card No. Expired |
| `Msg110506` | Access Timed Out by Card and PIN |
| `Msg110507` | Access Denied - Door Remained Locked or Inactive |
| `Msg110509` | Access Denied by Card and PIN |
| `Msg110510` | Access Timed Out by Card, Fingerprint, and PIN |
| `Msg110511` | Access Denied by Card, Fingerprint, and PIN |
| `Msg110512` | Access Denied by Card and Fingerprint |
| `Msg110513` | Access Timed Out by Card and Fingerprint |
| `Msg110514` | No Access Level Assigned |
| `Msg110515` | Card No. Does Not Exist |
| `Msg110516` | Invalid Time Period |
| `Msg110517` | Fingerprint Does Not Exist |
| `Msg110518` | Access Denied by Fingerprint |
| `Msg110519` | Access Denied by Fingerprint and PIN |
| `Msg110520` | Access Timed Out by Fingerprint and PIN |
| `Msg110521` | Access Denied by Face and Fingerprint |
| `Msg110522` | Access Timed Out by Face and Fingerprint |
| `Msg110523` | Access Denied by Face and PIN |
| `Msg110524` | Access Timed Out by Face and PIN |
| `Msg110525` | Access Denied by Face and Card |
| `Msg110526` | Access Timed Out by Face and Card |
| `Msg110527` | Access Denied by Face, PIN, and Fingerprint |
| `Msg110528` | Access Timed Out by Face, PIN, and Fingerprint |
| `Msg110529` | Access Denied by Face, Card, and Fingerprint |
| `Msg110530` | Access Timed Out by Face, Card, and Fingerprint |
| `Msg110531` | Access Denied by Face |
| `Msg110533` | Live Facial Detection Failed |
| `Msg110545` | Combined Authentication Timed Out |
| `Msg110546` | Access Denied by Invalid M1 Card |
| `Msg110547` | Verifying CPU Card Encryption Failed |
| `Msg110548` | Access Denied - NFC Card Reading Disabled |
| `Msg110549` | EM Card Reading Not Enabled |
| `Msg110550` | M1 Card Reading Not Enabled |
| `Msg110551` | CPU Card Reading Disabled |
| `Msg110552` | Authentication Mode Mismatch |
| `Msg110554` | Max. Card and Password Authentication Times |
| `Msg110555` | Password Mismatches |
| `Msg110556` | Employee ID Does Not Exist |
| `Msg110557` | Access Denied: Scheduled Sleep Mode |
| `Msg110559` | Verifying Desfire Card Encryption Failed |
| `Msg110560` | Absence |
| `Msg110561` | Authentication Failed Due to Abnormal Features |
| `Msg110564` | Access Denied by Bluetooth |
| `Msg110565` | Access Denied by QR Code |
| `Msg110566` | Verifying QR Code Secret Key Failed |
| `Msg110567` | Access Denied via Keyfob |
FILE:modules/Hik-Connect_Team_Alarm/SKILL.md
---
name: hctopen-alarm
description: |
HCTOpen alarm webhook subscription and push management skill. Supports subscribing to alarm events and receiving real-time notifications via Webhook.
Use when: Need to configure webhook for receiving HCT alarm pushes, subscribe/unsubscribe to alarm events.
⚠️ Security Requirement: Must set environment variables: Hik-Connect Team OpenAPI AppKey, Hik-Connect Team OpenAPI SecretKey. (API Domain is automatically obtained from token response)
⚠️ Prerequisites:
- Requires public HTTPS URL (self-owned server or tunnel like ngrok) to receive webhook pushes from HCT platform.
- ⚠️ Key Constraint: The server hosting the public HTTPS URL must be able to reach OpenClaw Gateway's port (dynamically detected from openclaw.json). If OpenClaw is on a different server behind NAT/firewall and unreachable externally, third-party webhook receiver services (e.g., Pipedream, AWS Lambda URL) will NOT work — they only receive and cannot forward to internal OpenClaw. In that case, you must use a tunnel tool (ngrok/cpolar) on the OpenClaw server to create a public entry point instead.
metadata:
openclaw:
emoji: "🔔"
requires:
env: ["HIK_CONNECT_TEAM_OPENAPI_APP_KEY", "HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY"]
npm: ["nodejs"]
primaryEnv: "HIK_CONNECT_TEAM_OPENAPI_APP_KEY"
warnings:
- "May read ~/.openclaw/*.json for credentials (env vars have priority)"
config:
configFileRead:
paths:
- "~/.openclaw/config.json"
- "~/.openclaw/gateway/config.json"
- "~/.openclaw/channels.json"
priority: "lower than environment variables"
description: "Reads Hik-Connect Team credentials from OpenClaw config files as fallback"
---
# Hik-Connect_Team_Alarm (HCT Alarm Push Management)
## 1. Module Introduction
`Hik-Connect_Team_Alarm` module is designed to help users implement real-time push of HCT alarm messages through Webhook mechanism. This module integrates a complete closed-loop process of **public network access guidance**, **Webhook receiving service**, **OpenClaw Hooks configuration** and **HCT platform subscription**.
This document details how to configure the HCT Open platform Webhook alarm push process. The core idea is to use public network address to receive alarm data pushed by HCT Open platform, forward it to internal network Webhook receiving service, and finally have OpenClaw agent organize and send message notifications.
The overall architecture flow is as follows: HCT Open Platform → Public Network Tunnel/Self-owned Server → Webhook Service(:3090) → OpenClaw Hooks → Message Notification
> **Core Logic**: HCT Platform pushes messages to public network Webhook address -> Webhook service receives and verifies signature -> Forward to OpenClaw Hooks -> Agent organizes and sends notification.
---
### 1.1 Complete Data Flow and Port Responsibilities
The full alarm push data flow spans multiple components. Understanding which port belongs to which process is critical for troubleshooting:
```
HCT Platform (port 443 HTTPS)
↓ sends POST / GET
[Public Internet]
↓
Reverse Proxy / Tunnel (server:443) — receives on public HTTPS address
↓ forwards internally
Webhook Receiving Service (server.js, port 3090 by default) — validates signature, extracts alarm data
↓ forwards internally
OpenClaw Gateway (port shown as gateway.port in openclaw.json, dynamically detected) — receives via /hooks/agent
↓ triggers
OpenClaw Agent → formats message → sends to notification channel (Feishu/Telegram/etc.)
```
**Port Responsibilities Table:**
| Port | Process | Role | Who Owns It |
|------------------------------|-------------------------------------------------|----------------------------------------------------------------------------|---------------------------------|
| 443 (HTTPS) | Reverse proxy (Caddy/nginx/etc.) or tunnel tool | Public entry point, receives from HCT platform | User's server or tunnel service |
| 3090 (default) | server.js (webhook receiving service) | Receives from reverse proxy, validates HMAC signature, extracts alarm data | OpenClaw server |
| gateway port (auto-detected) | OpenClaw Gateway | Receives via `/hooks/agent`, triggers agent processing | OpenClaw server |
---
## 2. Core Workflow (Detailed Version)
### 2.1 Flowchart
```mermaid
sequenceDiagram
participant User as User
participant Agent as Agent (AI Assistant)
participant Tool as Alarm Module (Python)
participant HCT as HCT Open Platform
participant Proxy as Public Network Tunnel (Optional)
participant Srv as Webhook Receiving Service (server.js)
participant OpenClaw as OpenClaw Hooks
participant Notify as OpenClaw Agent
Note over User, Agent: **Phase 0: OpenClaw Hooks Readiness Check**
Agent->>Agent: Run pre_check.py
alt hooks not ready
Agent->>User: Ask: "Modify hooks config and restart gateway? (yes/no)"
User->>Agent: User confirms
end
Note over User, Agent: **⚠️ Gate 1: Public URL Plan (NO TUNNEL without explicit Option B)**
Agent->>User: Ask: "Who hosts the public HTTPS URL? (A: own server / B: tunnel / C: own URL)"
User->>Agent: User confirms plan
Note right of Agent: **🚨 ABSOLUTE RULE: Tunnel only if user explicitly chose Option B**
Note over User, Agent: **⚠️ Gate 2: Signing Secret**
Agent->>User: Ask: "Provide an 8-32 character signing secret"
User->>Agent: User provides secret (BLOCK if no answer)
Note over User, Srv: **Phase 3: Service Startup**
User->>Srv: Start Webhook receiving service
User->>Srv: Verify public URL is reachable
Note over User, Srv: **Phase 4: Webhook Registration**
User->>Tool: Run `webhook_manager.py save --url <public URL> --secret <secret>`
Tool->>HCT: POST `/api/hccgw/webhook/v1/config/save`
HCT->>Srv: GET `<public URL>` (verification request)
Srv-->>HCT: Return `200 OK` + signature Header
HCT-->>Tool: Return `errorCode: "0"`
Tool-->>User: Prompt Webhook registration successful
Note over User, Srv: **Phase 5: Event Subscription**
Agent->>User: Present event types from EVENT_CODES.md
User->>Agent: User confirms which events
Agent->>Tool: Run `event_manager.py subscribe --types "chosen_types"`
Tool->>HCT: POST `/api/hccgw/rawmsg/v1/mq/subscribe`
HCT-->>Tool: Return `errorCode: "0"`
Note over User, Srv: **Phase 6: Alarm Push and Message Processing**
HCT->>Srv: POST `<public URL>` (alarm data)
Srv-->>HCT: Return `200 OK`
Srv->>OpenClaw: POST `/hooks/agent`
OpenClaw->>Notify: Trigger Agent processing
Notify->>User: Send notification via the configured channel
```
### 2.2 Stage-by-Stage Operation Guide
---
## ⚠️ 2.2.0 Phase 0: OpenClaw Hooks Readiness Check (ALWAYS RUN FIRST)
> **Important**: Before doing ANYTHING else, you MUST verify that OpenClaw hooks is properly configured. This is a hard prerequisite. If hooks is not set up, the alarm push chain will break silently.
### Step 0-1: Run Pre-Check Script
```bash
cd <skill-directory>/modules/Hik-Connect_Team_Alarm/scripts
python pre_check.py
```
The script checks all 6 items automatically.
### Step 0-2: If Hooks Needs Configuration — Ask User FIRST
If `hooks.enabled` is not `true` or `hooks.token` is missing:
**STOP and ask the user explicitly:**
> "OpenClaw hooks is not configured on this server. To receive alarm pushes, I need to:
> 1. Add a `hooks` section to `~/.openclaw/openclaw.json`
> 2. Restart the OpenClaw Gateway
>
> This will cause a brief interruption to the OpenClaw service (typically a few seconds).
>
> Do you want me to proceed? (yes/no)"
Only proceed if the user confirms. If confirmed:
- Generate a new token: `openssl rand -hex 24`
- Add to `~/.openclaw/openclaw.json`:
```json
{
"hooks": {
"enabled": true,
"token": "<generated token>"
}
}
```
- Restart gateway: `openclaw gateway restart`
- Verify: `curl http://127.0.0.1:<port>/hooks/agent` returns 200 or 400 (not 404)
> ⚠️ Do NOT add `defaultSessionKey` to hooks config — it causes `Malformed agent session key` errors.
Only proceed to Phase 1 after pre-check passes or after hooks is confirmed ready.
---
## ⚠️ 2.2.1 Phase 1: Public URL Plan — MUST Confirm Before Taking Action
### Step 1-1: Query Current Status
Show the user their existing webhook and subscription state:
```bash
cd <skill-directory>/modules/Hik-Connect_Team_Alarm/scripts
python webhook_manager.py query
python event_manager.py query
```
### Step 1-2: Ask About Public URL Plan (CONFIRMATION GATE — ABSOLUTE BLOCKER)
**Ask the user the following question and WAIT for their answer before proceeding:**
> "To receive alarm pushes from HCT, I need a public HTTPS URL that HCT can call. How do you want to handle this?"
>
> Please choose one of the following options:
>
> **Option A — Use your own server (most stable)**
> You have a public IP (124.222.61.228). If you have a domain name pointing to this IP, I can help you set up a reverse proxy (nginx/Caddy) to route HTTPS traffic to the webhook service.
>
> **Option B — Use a tunnel tool on this server**
> I can set up ngrok, cloudflared, or similar on this server to create a public HTTPS URL. This is free but the tunnel may occasionally disconnect.
>
> **Option C — You have your own public HTTPS URL**
> Provide your own URL and I'll configure the webhook service to use it.
>
> Which option do you prefer? (A / B / C, or describe your situation)"
**🚨 ABSOLUTE RULE — No Tunnel Tool Without Explicit User Request:**
> **UNDER NO CIRCUMSTANCES may the Agent install, configure, or start any tunnel tool (ngrok, cloudflared, cpolar, serveo, localtunnel, bore, etc.) unless the user has explicitly and affirmatively chosen Option B or otherwise explicitly asked for a tunnel tool in their own words.**
>
> This rule is absolute and non-negotiable. Violations include:
> - Installing a tunnel tool before the user has chosen Option B
> - Starting a tunnel without the user's explicit consent
> - Creating a public URL without the user confirming tunnel as their chosen approach
> - Using a tunnel as a "temporary" or "quick test" solution without explicit approval
>
> If the user does not respond to the question, re-ask. If the user is unclear, ask follow-up questions. Do not proceed.
**Decision Rules Based on User Response:**
| User Response | Agent Action |
|:---|:---|
| Option A (has domain) | Ask for domain → help configure reverse proxy → proceed |
| **Option B (tunnel)** | **Only then** install/configure tunnel tool → proceed |
| Option C (own URL) | Ask for the URL → verify it points to this server's 3090 → proceed |
| Says nothing / unclear | Ask follow-up question — do NOT proceed until clarified |
| Has no domain, no tunnel preference | Recommend Option A if public IP exists, otherwise explain limitation |
> ⚠️ **Critical**: Do NOT generate any public URL, do NOT install any tunnel tool, do NOT start any tunnel process until the user has explicitly chosen Option B (or equivalent explicit tunnel request). If the user does not respond, ask again.
---
## ⚠️ 2.2.2 Phase 2: Collect Signing Secret — Must Have Before Service Start
**Ask the user:**
> "Provide an 8-32 character signing secret for webhook verification. This will be used to verify that alarm pushes are genuinely from Hik-Connect. Please provide a secret now (e.g. yourname2026):"
**Rules:**
- **Do NOT generate or invent a default secret** — the user MUST provide this.
- **BLOCK on this step** — do not proceed to Phase 3 until the user provides a secret.
- Record the secret. It will be used in:
- `webhook_manager.py save --secret <secret>`
- `HIK_SIGN_SECRET` environment variable for server.js
---
## ⚠️ 2.2.3 Phase 3: Start Webhook Service — Only After Phases 1 & 2 Are Complete
### Step 3-1: Install Dependencies
```bash
cd <skill-directory>/modules/Hik-Connect_Team_Alarm/scripts
npm install
```
### Step 3-2: Get OpenClaw Gateway Port
```bash
PORT=$(cat ~/.openclaw/openclaw.json | grep -oP '"port":\s*\K\d+')
echo "OpenClaw Gateway port: $PORT"
```
### Step 3-3: Start Webhook Receiving Service
**Ask the user for their Feishu open_id (or target user ID) if not already known.**
```bash
cd <skill-directory>/modules/Hik-Connect_Team_Alarm/scripts
HIK_SIGN_SECRET="<user-provided-secret>" \
OPENCLAW_HOOKS_TOKEN="<from openclaw.json hooks.token>" \
OPENCLAW_HOOKS_URL="http://127.0.0.1:<gateway-port>/hooks/agent" \
OPENCLAW_CHANNEL="feishu" \
OPENCLAW_TO="<user's Feishu open_id>" \
PORT="3090" \
node server.js
```
### Step 3-4: Verify Service Is Running
```bash
curl http://localhost:3090/health
```
Expected: `{"status":"ok",...}`
### Step 3-5: Verify Public URL Is Reachable
```bash
curl -sL -o /dev/null -w "%{http_code}" https://<your-public-url>/hikvision/webhook
```
Expected: `200` or `302` (redirect). If `000` or timeout → tunnel/proxy is not working.
Only proceed to Phase 4 if both service health and public URL are confirmed working.
---
## ⚠️ 2.2.4 Phase 4: Register Webhook with HCT Platform
> "Now I'll register the Webhook URL with HCT. Make sure the service is running and the public URL is accessible from the internet."
```bash
cd <skill-directory>/modules/Hik-Connect_Team_Alarm/scripts
python webhook_manager.py save \
--url "https://<your-public-url>/hikvision/webhook" \
--secret "<user-provided-secret>"
```
- **Success**: Tell user "Webhook registered successfully! HCT will now push alarms to your URL."
- **Failure**: Tell user the error reason and checklist (service running? URL accessible from internet? secret correct?)
---
## ⚠️ 2.2.5 Phase 5: Event Subscription — Must Have Explicit User Confirmation
> **⚠️ MANDATORY: Only subscribe when user explicitly asks.**
> Never call `event_manager.py subscribe` without explicit user confirmation.
**Ask the user:**
> "Webhook registration successful! Now let's subscribe to alarm events. Which events do you want to subscribe to? You can find the full list in `EVENT_CODES.md`. Options:
> - 'full' — subscribe to all events
> - Or provide specific event codes (e.g. 'Msg110001,Msg110002')
>
> Which would you like?"
**Wait for the user's answer.** Only then run:
```bash
# All events:
python event_manager.py subscribe
# Specific events:
python event_manager.py subscribe --types "Msg110001,Msg110002,..."
```
**After execution:**
- **Success**: Tell the user "Event subscription complete! `{count}` event types subscribed."
- **Failure**: Tell the user "Event subscription failed: `{reason}`"
---
## 6. Signature Verification Mechanism (Security)
HCT platform and Webhook service ensure communication security through HMAC-SHA256 algorithm.
### 3.1 Verification Request (GET)
When you save Webhook configuration on platform, platform will send verification request:
* **HCT Platform Sends Header**:
* `X-Hook-Batch-Id`: Batch ID
* `X-Hook-Timestamp`: Timestamp (milliseconds)
* **Webhook Service Processing**:
* Service calculates HMAC-SHA256 signature based on configured `HIK_SIGN_SECRET`, `X-Hook-Timestamp` and `X-Hook-Batch-Id`.
* **Signature Algorithm**: `signature = HMAC-SHA256(secret, timestamp.batchId)`, result is `sha256=<hex_string>`.
* **Webhook Service Returns**: Carries `X-Hook-Signature: sha256=<calculated_signature>` in Response Header, status code `200 OK`.
### 3.2 Push Request (POST)
When alarm occurs, platform pushes data:
* **HCT Platform Sends Header**:
* `X-Hook-Signature`: Signature calculated by platform
* `X-Hook-Timestamp`: Push timestamp
* **Webhook Service Processing**:
* Service uses same `HIK_SIGN_SECRET` and `timestamp.batchId` (obtained from request Body) to calculate signature, and compares with `X-Hook-Signature` in Header.
* If signature matches and timestamp is within acceptable range, request is considered legitimate and processed further, otherwise request is rejected.
---
## 7. Script and API Parameter Details
### 4.1 Webhook Management (`webhook_manager.py`)
This script is used to manage HCT platform's Webhook configuration, including query, save and delete.
#### 4.1.1 Running Examples
* **Query Current Webhook Configuration**:
```bash
python scripts/webhook_manager.py query
```
* **Save/Subscribe Webhook Configuration**:
```bash
python scripts/webhook_manager.py save --url "https://your-public-domain.com/hikvision/webhook" --secret "YourSignSecret123" --retries 5 --delay 2000
```
* **Delete Webhook Configuration**:
```bash
python scripts/webhook_manager.py delete
```
#### 4.1.2 Request Parameters
| Parameter | Type | Required | Default | Description |
|:------------|:--------|:----------------------------|:--------|:--------------------------------------------------------------------------------------------------------|
| `command` | String | Yes | - | Operation command, options: `query`, `save`, `delete` |
| `--url` | String | Required for `save` command | - | Public HTTPS callback address, must start with `https://`, max length 256 characters. |
| `--secret` | String | Optional for `save` command | - | Signing secret, used to verify legitimacy of pushed messages. 8-32 alphanumeric combination. |
| `--retries` | Integer | Optional for `save` command | 3 | Number of retries after message push failure. Range `[-1, 5]`, -1 means unlimited retry within 2 hours. |
| `--delay` | Integer | Optional for `save` command | 1000 | Retry interval after message push failure, in milliseconds. |
#### 4.1.3 Output Field Description
| Field | Type | Description |
|:-------------------|:--------|:-------------------------------------------------------------------------------|
| `success` | Boolean | Whether operation was successful. `true` means success, `false` means failure. |
| `message` | String | Operation result or error description information. |
| `errorCode` | String | Error code, `0` means success, other values are specific error codes. |
| `data` | Object | Webhook configuration details returned on successful `query` command. |
| `data.callbackUrl` | String | Webhook callback address. |
| `data.retryTimes` | Integer | Webhook retry count. |
| `data.retryDelay` | Long | Webhook retry interval (milliseconds). |
### 4.2 Event Subscription Management (`event_manager.py`)
This script is used to subscribe, unsubscribe, or query HCT platform alarm event subscription status.
#### 4.2.1 Running Examples
* **Subscribe to Specific Event Types**:
```bash
python scripts/event_manager.py subscribe --types "Msg330001,Msg330002"
```
* **Subscribe to All Event Types**:
```bash
python scripts/event_manager.py subscribe
```
* **Unsubscribe from Specific Event Types**:
```bash
python scripts/event_manager.py unsubscribe --types "Msg330001"
```
* **Unsubscribe from All Event Types**:
```bash
python scripts/event_manager.py unsubscribe
```
* **Query Current Subscription Status**:
```bash
python scripts/event_manager.py query
```
#### 4.2.2 Request Parameters
| Parameter | Type | Required | Default | Description |
|:----------|:-------|:---------|:-----------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------|
| `command` | String | Yes | - | Operation command, options: `subscribe`, `unsubscribe`, `query` |
| `--types` | String | Optional | Empty (subscribe/unsubscribe all events) | Comma-separated event type list (e.g. `Msg330001,Msg330002`). For specific event types please refer to EVENT_CODES.md document. |
> **Important Note**: Even without Webhook configuration, you can still execute subscribe/unsubscribe/query operations. But note that without proper Webhook service configuration and registration to HCT platform, you will not receive any alarm message pushes.
#### 4.2.3 Output Field Description
**For `subscribe` and `unsubscribe` commands:**
| Field | Type | Description |
|:------------|:--------|:-------------------------------------------------------------------------------|
| `success` | Boolean | Whether operation was successful. `true` means success, `false` means failure. |
| `message` | String | Operation result or error description information. |
| `errorCode` | String | Error code, `0` means success, other values are specific error codes. |
**For `query` command:**
| Field | Type | Description |
|:-------------------------|:--------|:---------------------------------------------------------------------------|
| `success` | Boolean | Whether operation was successful. |
| `data.isSubscribe` | Boolean | Whether subscribed. `true` means subscribed, `false` means not subscribed. |
| `data.subscribeType` | Integer | Subscription type. `0`: Partial subscription, `1`: Full subscription. |
| `data.subscribeTypeText` | String | Text description of subscription type. |
| `data.events` | Array | List of event type codes user has subscribed to. |
---
## 8. Message Display Format and Agent Processing
### 5.1 Message Structure
Webhook service will format received raw JSON into readable text, and forward through OpenClaw Hooks. Below is formatted message example:
```text
📦 HCT Open Webhook Push (batchId: 1a2b3c4d...)
Alarms: 1
---
🚨 Alarm: Human Detection
Device: Small Cup
Type: alarmCategoryVideo/alarmSubCategoryPersonDetect
Time: 2026-04-16 14:20:00
Level: High
Serial Number: D72821502
```
---
## 5.2 Agent Message Processing Rules
> **⚠️ Critical: Agent Must Forward Messages Exactly As-Received**
> When the Agent receives a webhook message via OpenClaw Hooks, it must **forward the message content exactly as-is** to the notification channel.
**Required Behavior:**
- ✅ **DO**: Forward the received message content verbatim to the user
- ✅ **DO**: Send the message to the configured notification channel (Feishu/Telegram/etc.)
- ❌ **DO NOT**: Summarize, interpret, or rephrase the message
- ❌ **DO NOT**: Translate the message (e.g., from Chinese to English or vice versa)
- ❌ **DO NOT**: Add comments, analysis, or extra explanations
- ❌ **DO NOT**: Reformat or restructure the message content
**Why This Matters:**
The `server.js` service already formats the webhook payload into a human-readable format. The Agent's only job is to deliver this formatted message to the user without any further processing. Adding summaries or translations introduces noise and delays, and may strip important technical details that the user needs.
**If the Agent fails to follow these rules**, it means the instruction was not clear enough — please report this so the skill documentation can be improved.
---
## 9. Troubleshooting
| Symptom | Most Likely Cause | Fix |
|---------------------------------------------------------|-------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| Webhook registration fails | server.js not running or URL not accessible | `curl http://localhost:3090/health`; verify public URL from outside |
| Registration succeeds but no alarms | Third-party service (Pipedream/Lambda) used | ❌ Cannot forward to internal OpenClaw. Use tunnel or same-server setup |
| Hooks returns 404 | basePath incorrectly included in `OPENCLAW_HOOKS_URL` | Use `http://127.0.0.1:<port>/hooks/agent` — no basePath |
| Hooks returns `[RELAY NETWORK ERROR]` | Wrong port, wrong token, or gateway down | Verify `OPENCLAW_HOOKS_URL` port matches `gateway.port`; token matches `hooks.token` |
| User gets no notification | server.js stopped, or Feishu card permission missing | Check `curl localhost:3090/health`; enable "card messages" permission in Feishu Open Platform |
| ngrok shows ERR_NGROK_4018 | Missing authtoken | Run `ngrok config add-authtoken <your-authtoken>` |
| gateway "hooks.token must not match gateway auth.token" | tokens are identical | `openssl rand -hex 24` → update hooks.token in openclaw.json → restart gateway |
**Quick verification:**
```bash
curl http://localhost:3090/health
curl -s -o /dev/null -w "%{http_code}" https://<your-url>/hikvision/webhook
curl -X POST http://127.0.0.1:<port>/hooks/agent -H "Authorization: Bearer <token>" -d '{"test":"ping"}'
```
---
## 10. File Structure
```
Hik-Connect_Team_Alarm/
├── SKILL.md # This document
├── EVENT_CODES.md # Event type reference
└── scripts/
├── pre_check.py # Phase 0: OpenClaw hooks pre-check (run FIRST)
├── server.js # Webhook receiving service
├── webhook_manager.py # Webhook configuration management
├── event_manager.py # Event subscription management
└── package.json # Node.js dependencies
```
---
**Error Codes**:
| Return Code | Return Message | Description |
|-------------|------------------------------|-------------------------------------------------------------------------------------------------|
| CCF000001 | Webhook configuration failed | Webhook configuration failed. Please check the correctness of the public URL and the signature. |
---
FILE:modules/Hik-Connect_Team_Alarm/scripts/event_manager.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Event Manager
"""
import sys
import os
import json
import argparse
from datetime import datetime
# Robust lib directory import logic
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory")
sys.exit(1)
from token_manager import HCTOpenClient
class EventManager(HCTOpenClient):
"""Event subscription management client"""
def subscribe(self, msg_types: list = None):
"""Subscribe to events"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Subscribing to events: {msg_types if msg_types else 'All events'}")
endpoint = "/api/hccgw/rawmsg/v1/mq/subscribe"
payload = {
"subscribeType": 1,
"msgType": msg_types if msg_types else []
}
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
print("[SUCCESS] Event subscription successful")
self.exit_with_json({"success": True, "message": "Event subscription successful"})
else:
print(f"[ERROR] Subscription failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({"success": False, "message": result.get("message", "Unknown error"), "errorCode": result.get("errorCode")})
def unsubscribe(self, msg_types: list = None):
"""Unsubscribe from events"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Unsubscribing from events: {msg_types if msg_types else 'All events'}")
endpoint = "/api/hccgw/rawmsg/v1/mq/subscribe"
payload = {
"subscribeType": 0,
"msgType": msg_types if msg_types else []
}
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
print("[SUCCESS] Event unsubscription successful")
self.exit_with_json({"success": True, "message": "Event unsubscription successful"})
else:
print(f"[ERROR] Unsubscription failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({"success": False, "message": result.get("message", "Unknown error"), "errorCode": result.get("errorCode")})
def query(self):
"""Query current subscription status"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Querying current subscription status...")
endpoint = "/api/hccgw/rawmsg/v1/mq/info/subscribe"
result = self.request("GET", endpoint, token_header_key="Token")
if result.get("errorCode") == "0":
data = result.get("data", {})
is_sub = data.get("isSubscribe", False)
sub_type = "Full subscription" if data.get("subscribeType") == 1 else "Partial subscription"
events = data.get("events", [])
print(f"[SUCCESS] Query successful: {'Subscribed' if is_sub else 'Not subscribed'} ({sub_type})")
if events:
print(f"Subscribed event list: {', '.join(events)}")
self.exit_with_json({
"success": True,
"data": {
"isSubscribe": is_sub,
"subscribeType": data.get("subscribeType"),
"subscribeTypeText": sub_type,
"events": events
}
})
else:
print(f"[ERROR] Query failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({"success": False, "message": result.get("message", "Unknown error"), "errorCode": result.get("errorCode")})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Event Subscription Management")
subparsers = parser.add_subparsers(dest="command", help="Operation command")
# Subscribe command
sub_parser = subparsers.add_parser("subscribe", help="Subscribe to events")
sub_parser.add_argument("--types", help="Comma-separated event type list (e.g. Msg330001,Msg330002), leave empty to subscribe to all")
# Unsubscribe command
unsub_parser = subparsers.add_parser("unsubscribe", help="Unsubscribe from events")
unsub_parser.add_argument("--types", help="Comma-separated event type list, leave empty to unsubscribe from all")
# Query command
subparsers.add_parser("query", help="Query current subscription status")
args = parser.parse_args()
client = EventManager()
if args.command == "subscribe":
msg_types = [t.strip() for t in args.types.split(',') if t.strip()] if args.types else []
client.subscribe(msg_types)
elif args.command == "unsubscribe":
msg_types = [t.strip() for t in args.types.split(',') if t.strip()] if args.types else []
client.unsubscribe(msg_types)
elif args.command == "query":
client.query()
else:
parser.print_help()
if __name__ == "__main__":
main()
FILE:modules/Hik-Connect_Team_Alarm/scripts/package.json
{
"name": "hikvision-webhook",
"version": "1.0.0",
"description": "HikCentral Connect OpenAPI Webhook receiver with dedup & OpenClaw relay",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^4.21.0"
}
}
FILE:modules/Hik-Connect_Team_Alarm/scripts/pre_check.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HCT Alarm - OpenClaw Hooks Pre-Check
Checks whether OpenClaw hooks are properly configured and reachable.
Run this BEFORE any other alarm configuration steps.
"""
import sys
import os
import json
import urllib.request
import urllib.error
import argparse
from datetime import datetime
OPENCLAW_CFG = os.path.expanduser("~/.openclaw/openclaw.json")
CHECKS = []
def log(status, msg):
symbol = {"OK": "✓", "FAIL": "✗", "SKIP": "⊘", "INFO": "ℹ"}.get(status, "?")
print(f" [{status}] {msg}")
CHECKS.append({"status": status, "msg": msg})
def check_config_file():
if not os.path.exists(OPENCLAW_CFG):
log("FAIL", f"Config file not found: {OPENCLAW_CFG}")
return False
try:
with open(OPENCLAW_CFG, "r") as f:
json.load(f)
log("OK", "Config file is valid JSON")
return True
except json.JSONDecodeError as e:
log("FAIL", f"Config file is not valid JSON: {e}")
return False
def check_hooks_enabled():
with open(OPENCLAW_CFG, "r") as f:
config = json.load(f)
hooks = config.get("hooks", {})
if hooks.get("enabled") is True:
log("OK", "hooks.enabled = true")
return True
log("FAIL", "hooks.enabled is not true (or hooks section missing)")
return False
def check_hooks_token():
with open(OPENCLAW_CFG, "r") as f:
config = json.load(f)
hooks = config.get("hooks", {})
token = hooks.get("token", "")
if token and isinstance(token, str) and len(token) > 0:
log("OK", f"hooks.token is set ({len(token)} chars)")
return True, token
log("FAIL", "hooks.token is missing or empty")
return False, None
def check_token_not_same_as_gateway():
with open(OPENCLAW_CFG, "r") as f:
config = json.load(f)
hooks = config.get("hooks", {})
gateway = config.get("gateway", {})
hooks_token = hooks.get("token", "")
gateway_token = gateway.get("auth", {}).get("token", "")
if hooks_token and gateway_token and hooks_token == gateway_token:
log("FAIL", "hooks.token must be different from gateway.auth.token")
return False
log("OK", "hooks.token differs from gateway.auth.token")
return True
def check_gateway_port():
with open(OPENCLAW_CFG, "r") as f:
config = json.load(f)
gateway = config.get("gateway", {})
port = gateway.get("port", "")
if port:
log("OK", f"gateway.port = {port}")
return True, port
log("FAIL", "gateway.port is not set")
return False, None
def check_hooks_reachable(hooks_token, port):
url = f"http://127.0.0.1:{port}/hooks/agent"
body = json.dumps({"source": "pre_check"}).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={
"Authorization": f"Bearer {hooks_token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
code = resp.status
data = resp.read().decode("utf-8")
log("OK", f"Hooks endpoint reachable (HTTP {code})")
return True
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
if e.code == 400 and "message required" in body.lower():
log("OK", f"Hooks endpoint reachable (HTTP 400 — endpoint alive, needs message body)")
return True
log("FAIL", f"HTTP {e.code}: {body[:100]}")
return False
except urllib.error.URLError as e:
log("FAIL", f"Cannot reach OpenClaw gateway: {e.reason}")
return False
except Exception as e:
log("FAIL", f"Unexpected error: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="OpenClaw Hooks Pre-Check for HCT Alarm")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
args = parser.parse_args()
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] OpenClaw Hooks Pre-Check")
print("=" * 50)
all_passed = True
if not check_config_file():
all_passed = False
if not check_hooks_enabled():
all_passed = False
token_ok, hooks_token = check_hooks_token()
if not token_ok:
all_passed = False
if not check_token_not_same_as_gateway():
all_passed = False
port_ok, port = check_gateway_port()
if not port_ok:
all_passed = False
if token_ok and port_ok:
if not check_hooks_reachable(hooks_token, port):
all_passed = False
else:
log("SKIP", "Skipping reachability check (config not ready)")
print("=" * 50)
if all_passed:
print("[RESULT] ✓ All checks passed. OpenClaw hooks are ready.")
else:
print("[RESULT] ✗ Some checks failed. Fix the issues above before proceeding.")
if args.json:
print(json.dumps({"ok": all_passed, "checks": CHECKS}, indent=2, ensure_ascii=False))
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())
FILE:modules/Hik-Connect_Team_Alarm/scripts/server.js
/**
* HikCentral Connect OpenAPI V2.15 — Webhook Receiving Service
*
* Features:
* 1. Receive HCT Open Webhook pushes (Alarms + Events)
* 2. HMAC-SHA256 signature verification (X-Hook-Signature)
* 3. Configurable window deduplication (same device, same type)
* 4. Forward to OpenClaw hooks endpoint → Notification
* 5. Extract capture URLs, Agent sends images directly
* 6. Auto-detect OpenClaw Gateway port
* 7. Startup connection check
*/
import crypto from 'crypto';
import express from 'express';
import { readFileSync, existsSync } from 'fs';
import { homedir } from 'os';
// ============ Default Values ============
// DEFAULT_WEBHOOK_PORT
const DEFAULT_WEBHOOK_PORT = 3090;
// ============ Helper: Detect OpenClaw Gateway Port ============
function detectOpenClawPort() {
const configPath = `homedir()/.openclaw/openclaw.json`;
try {
if (existsSync(configPath)) {
const content = readFileSync(configPath, 'utf-8');
// Remove comments if any (simple JSON doesn't have comments, but just in case)
const json = JSON.parse(content);
const port = json?.gateway?.port;
if (port && typeof port === 'number') {
return port;
}
}
} catch (err) {
console.error(`[FATAL] Failed to read gateway port from configPath: err.message`);
}
throw new Error(`OpenClaw gateway port not found in configPath. Please ensure gateway is configured and hooks are enabled.`);
}
// ============ Helper: Check OpenClaw Connection ============
async function checkOpenClawConnection(url, token) {
console.log(`[CHECK] Testing OpenClaw connection at url...`);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer token` } : {}),
},
body: JSON.stringify({ test: 'ping' }),
signal: AbortSignal.timeout(5000),
});
// 400 means endpoint exists but missing required field (e.g. "message required"), indicating hooks middleware is registered
if (res.ok || res.status === 400) {
console.log(`[CHECK] ✓ OpenClaw hooks is reachable (status res.status)`);
return true;
} else {
console.error(`[CHECK] ✗ OpenClaw returned status res.status`);
return false;
}
} catch (err) {
console.error(`[CHECK] ✗ Cannot reach OpenClaw at url`);
console.error(`[CHECK] Error: err.message`);
console.error(`[CHECK] Please verify:`);
console.error(`[CHECK] 1. OpenClaw Gateway is running: openclaw gateway status`);
console.error(`[CHECK] 2. Port is correct (detected: detectOpenClawPort())`);
console.error(`[CHECK] 3. Or set OPENCLAW_HOOKS_URL environment variable`);
return false;
}
}
// ============ Configuration ============
const detectedPort = detectOpenClawPort();
const defaultOpenClawUrl = `http://127.0.0.1:detectedPort/hooks/agent`;
// Sign secret: MUST be provided via environment variable
const signSecretFromEnv = process.env.HIK_SIGN_SECRET;
if (!signSecretFromEnv) {
console.error('[FATAL] HIK_SIGN_SECRET environment variable is not set.');
console.error('[FATAL] Please set HIK_SIGN_SECRET before starting the webhook service.');
console.error('[FATAL] Example: HIK_SIGN_SECRET="your-custom-secret" node server.js');
process.exit(1);
}
const signSecret = signSecretFromEnv;
const CONFIG = {
port: parseInt(process.env.PORT || String(DEFAULT_WEBHOOK_PORT), 10),
// HCT Open Webhook Secret (signSecret specified when registering webhook)
signSecret: signSecret,
// OpenClaw hooks configuration
openclaw: {
url: process.env.OPENCLAW_HOOKS_URL || defaultOpenClawUrl,
token: process.env.OPENCLAW_HOOKS_TOKEN || '',
// Supports all OpenClaw channels: feishu, telegram, discord, slack, whatsapp, signal, etc.
channel: process.env.OPENCLAW_CHANNEL || '',
to: process.env.OPENCLAW_TO || '',
},
// Deduplication window (milliseconds), default 1 minute
dedupWindowMs: parseInt(process.env.DEDUP_WINDOW_MS || '60000', 10),
// Request timeout (HCT Open requires response within 5 seconds)
responseTimeoutMs: 4000,
};
// ============ Print Configuration ============
function printConfig() {
console.log('');
console.log('═══════════════════════════════════════════════════════════');
console.log(' CONFIGURATION SUMMARY ');
console.log('═══════════════════════════════════════════════════════════');
console.log(` Webhook Port: CONFIG.port`);
console.log(` Sign Secret: ***configured***`);
console.log(` OpenClaw URL: CONFIG.openclaw.url`);
console.log(` OpenClaw Token: 'NOT SET ⚠️'`);
console.log(` Notify Channel: CONFIG.openclaw.channel`);
console.log(` Notify Target: CONFIG.openclaw.to || 'NOT SET ⚠️'`);
console.log(` Dedup Window: CONFIG.dedupWindowMs / 1000s`);
console.log('═══════════════════════════════════════════════════════════');
if (!CONFIG.openclaw.to || !CONFIG.openclaw.channel) {
console.log('');
console.log('╔══════════════════════════════════════════════════════════╗');
console.log('║ [FATAL] Missing required configuration ║');
console.log('╠══════════════════════════════════════════════════════════╣');
if (!CONFIG.openclaw.channel) {
console.log('║ OPENCLAW_CHANNEL is not set ║');
console.log('║ Please set: export OPENCLAW_CHANNEL="feishu" ║');
}
if (!CONFIG.openclaw.to) {
console.log('║ OPENCLAW_TO is not set ║');
console.log('║ Please set: export OPENCLAW_TO="user_open_id" ║');
}
console.log('╚══════════════════════════════════════════════════════════╝');
console.log('');
console.log('Without these, notifications cannot be delivered. Exiting.');
process.exit(1);
}
console.log('');
}
// ============ Deduplication Cache ============
const dedupCache = new Map();
function dedupKey(item) {
if (item.type === 'alarm') {
const src = item.eventSource;
return `alarm:src?.sourceID || '':item.alarmMainCategory || '':item.alarmSubCategory || ''`;
}
if (item.type === 'event') {
return `event:item.basicInfo?.eventType || '':item.basicInfo?.device?.id || ''`;
}
return `item.type:item.guid || JSON.stringify(item).slice(0, 200)`;
}
function isDuplicate(key) {
const now = Date.now();
const cached = dedupCache.get(key);
if (cached && now - cached < CONFIG.dedupWindowMs) return true;
dedupCache.set(key, now);
// Periodically clean up expired cache
if (dedupCache.size > 1000) {
for (const [k, v] of dedupCache) {
if (now - v > CONFIG.dedupWindowMs) dedupCache.delete(k);
}
}
return false;
}
// ============ Signature Verification ============
function verifySignature(headers, batchId) {
if (!CONFIG.signSecret) {
console.warn('[WARN] HIK_SIGN_SECRET not set, skipping signature verification');
return true;
}
const signature = headers['x-hook-signature'] || headers['X-Hook-Signature'];
const timestamp = headers['x-hook-timestamp'];
if (!signature || !timestamp) {
console.warn('[WARN] Missing signature headers');
return false;
}
const tsDiff = Math.abs(Date.now() - parseInt(timestamp, 10));
if (tsDiff > 60 * 1000) {
console.warn(`[WARN] Timestamp drift too large: tsDiffms`);
return false;
}
const message = `timestamp.batchId`;
const mac = crypto.createHmac('sha256', CONFIG.signSecret).update(message).digest('hex');
const expected = `sha256=mac`;
if (signature !== expected) {
console.warn(`[WARN] Signature mismatch: expected=expected, got=signature`);
return false;
}
return true;
}
// ============ Format Alarm Messages ============
const LEVEL_MAP = { 1: 'High', 2: 'Medium', 3: 'Low' };
// Event code to description mapping
const EVENT_CODE_MAP = {
// Video Intercom
'Msg140001': 'Messages about video intercom events',
// On-Board Monitoring
'Msg330001': 'GPS Data Report',
'Msg330101': 'Alarm Triggered by Panic Button',
'Msg330102': 'Alarm Input',
'Msg330201': 'Forward Collision Warning',
'Msg330202': 'Headway Monitoring Warning',
'Msg330203': 'Lane Deviation Warning',
'Msg330204': 'Pedestrian Collision Warning',
'Msg330205': 'Speed Limit Warning',
'Msg330301': 'Blind Spot Warning',
'Msg330401': 'Sharp Turn',
'Msg330402': 'Sudden Brake',
'Msg330403': 'Sudden Acceleration',
'Msg330404': 'Rollover',
'Msg330405': 'Speeding',
'Msg330406': 'Collision',
'Msg330407': 'ACC ON',
'Msg330408': 'ACC OFF',
'Msg330501': 'Smoking',
'Msg330502': 'Using Mobile Phone',
'Msg330503': 'Fatigue Driving',
'Msg330504': 'Distraction',
'Msg330505': 'Seatbelt Unbuckled',
'Msg330506': 'Video Tampering',
'Msg330507': 'Yawning',
'Msg330508': 'Wearing IR Interrupted Sunglasses',
'Msg330509': 'Absence',
'Msg330510': 'Front Passenger Detection',
'Msg335000': 'Person and Vehicle Match',
'Msg335001': 'Person and Vehicle Mismatch',
// Authentication Event
'Msg110001': 'Access Granted by Card and Fingerprint',
'Msg110002': 'Access Granted by Card, Fingerprint, and PIN',
'Msg110003': 'Access Granted by Card',
'Msg110004': 'Access Granted by Card and PIN',
'Msg110005': 'Access Granted by Fingerprint',
'Msg110006': 'Access Granted by Fingerprint and PIN',
'Msg110007': 'Duress Alarm',
'Msg110008': 'Access Granted by Face and Fingerprint',
'Msg110009': 'Access Granted by Face and PIN',
'Msg110010': 'Access Granted by Face and Card',
'Msg110011': 'Access Granted by Face, PIN, and Fingerprint',
'Msg110012': 'Access Granted by Face, Card, and Fingerprint',
'Msg110013': 'Access Granted by Face',
'Msg110018': 'Access Granted via Combined Authentication Modes',
'Msg110019': 'Skin-Surface Temperature Measured',
'Msg110020': 'Password Authenticated',
'Msg110022': 'Access Granted by Bluetooth',
'Msg110023': 'Access Granted via QR Code',
'Msg110024': 'Access Granted via Keyfob',
'Msg110501': 'Verifying Card Encryption Failed',
'Msg110502': 'Max. Card Access Failed Attempts',
'Msg110505': 'Card No. Expired',
'Msg110506': 'Access Timed Out by Card and PIN',
'Msg110507': 'Access Denied - Door Remained Locked or Inactive',
'Msg110509': 'Access Denied by Card and PIN',
'Msg110510': 'Access Timed Out by Card, Fingerprint, and PIN',
'Msg110511': 'Access Denied by Card, Fingerprint, and PIN',
'Msg110512': 'Access Denied by Card and Fingerprint',
'Msg110513': 'Access Timed Out by Card and Fingerprint',
'Msg110514': 'No Access Level Assigned',
'Msg110515': 'Card No. Does Not Exist',
'Msg110516': 'Invalid Time Period',
'Msg110517': 'Fingerprint Does Not Exist',
'Msg110518': 'Access Denied by Fingerprint',
'Msg110519': 'Access Denied by Fingerprint and PIN',
'Msg110520': 'Access Timed Out by Fingerprint and PIN',
'Msg110521': 'Access Denied by Face and Fingerprint',
'Msg110522': 'Access Timed Out by Face and Fingerprint',
'Msg110523': 'Access Denied by Face and PIN',
'Msg110524': 'Access Timed Out by Face and PIN',
'Msg110525': 'Access Denied by Face and Card',
'Msg110526': 'Access Timed Out by Face and Card',
'Msg110527': 'Access Denied by Face, PIN, and Fingerprint',
'Msg110528': 'Access Timed Out by Face, PIN, and Fingerprint',
'Msg110529': 'Access Denied by Face, Card, and Fingerprint',
'Msg110530': 'Access Timed Out by Face, Card, and Fingerprint',
'Msg110531': 'Access Denied by Face',
'Msg110533': 'Live Facial Detection Failed',
'Msg110545': 'Combined Authentication Timed Out',
'Msg110546': 'Access Denied by Invalid M1 Card',
'Msg110547': 'Verifying CPU Card Encryption Failed',
'Msg110548': 'Access Denied - NFC Card Reading Disabled',
'Msg110549': 'EM Card Reading Not Enabled',
'Msg110550': 'M1 Card Reading Not Enabled',
'Msg110551': 'CPU Card Reading Disabled',
'Msg110552': 'Authentication Mode Mismatch',
'Msg110554': 'Max. Card and Password Authentication Times',
'Msg110555': 'Password Mismatches',
'Msg110556': 'Employee ID Does Not Exist',
'Msg110557': 'Access Denied: Scheduled Sleep Mode',
'Msg110559': 'Verifying Desfire Card Encryption Failed',
'Msg110560': 'Absence',
'Msg110561': 'Authentication Failed Due to Abnormal Features',
'Msg110564': 'Access Denied by Bluetooth',
'Msg110565': 'Access Denied by QR Code',
'Msg110566': 'Verifying QR Code Secret Key Failed',
'Msg110567': 'Access Denied via Keyfob'
};
function formatAlarmItem(item) {
const src = item.eventSource || {};
const dev = src.deviceInfo || {};
const time = item.timeInfo?.startTime || '';
const rule = item.alarmRule || {};
const priority = item.alarmPriority || {};
// Simplify time format
const shortTime = time;
return [
`🚨 Alarm: rule.name || item.alarmSubCategory || 'Unknown Alarm'`,
`Device: src.sourceName || dev.devName || 'Unknown Device'`,
`Type: item.alarmMainCategory/item.alarmSubCategory`,
`Time: shortTime`,
`Level: priority.levelName || LEVEL_MAP[priority.level] || 'Level ${priority.level'}`,
].filter(Boolean).join('\n');
}
function formatEventItem(item) {
const basic = item.basicInfo || {};
const dev = basic.device || {};
// Get event code and map to description
const eventCode = item.basicInfo?.msgType || '';
const eventDescription = EVENT_CODE_MAP[eventCode] || eventCode || 'Unknown';
return [
`📡 Event: eventDescription`,
`Device: dev.name || 'Unknown'`,
`Time: basic.occurrenceTime || ''`,
dev.deviceSerial ? `Serial: dev.deviceSerial` : '',
].filter(Boolean).join('\n');
}
function buildPayload(body) {
const { batchId, list } = body;
const messages = [];
let alarmCount = 0;
let eventCount = 0;
for (const item of list || []) {
const key = dedupKey(item);
if (isDuplicate(key)) {
console.log(`[DEDUP] Skipped duplicate: key`);
continue;
}
if (item.type === 'alarm') {
alarmCount++;
messages.push(formatAlarmItem(item));
} else if (item.type === 'event') {
eventCount++;
messages.push(formatEventItem(item));
}
}
if (messages.length === 0) return null;
return [
`📦 HCT Open Webhook Push (batchId: batchId?.slice(0, 8)...)`,
alarmCount ? `Alarms: alarmCount` : '',
eventCount ? `Events: eventCount` : '',
`---`,
...messages,
].filter(Boolean).join('\n\n');
}
// ============ Relay to OpenClaw ============
async function relayToOpenClaw(message) {
if (!CONFIG.openclaw.url || !CONFIG.openclaw.token) {
console.error('[RELAY] OPENCLAW_HOOKS_URL or OPENCLAW_HOOKS_TOKEN is not configured. Please set it before starting the webhook service.');
return;
}
try {
const res = await fetch(CONFIG.openclaw.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer CONFIG.openclaw.token`,
},
body: JSON.stringify({
message: message,
channel: CONFIG.openclaw.channel,
to: CONFIG.openclaw.to,
}),
signal: AbortSignal.timeout(10000),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
console.log('[RELAY] OpenClaw hooks OK, runId:', data.runId || data.id || 'unknown');
} else {
console.error('[RELAY] OpenClaw hooks error:', res.status, JSON.stringify(data));
}
} catch (err) {
console.error('[RELAY] OpenClaw hooks network error:', err.message);
}
}
// ============ Express App ============
const app = express();
app.use('/hikvision/webhook', express.json({ limit: '10mb' }));
// Health Check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
dedupCacheSize: dedupCache.size,
config: {
port: CONFIG.port,
openclawUrl: CONFIG.openclaw.url,
hasToken: !!CONFIG.openclaw.token,
hasTarget: !!CONFIG.openclaw.to,
}
});
});
// GET Request — HCT Open URL Verification Callback
app.get('/hikvision/webhook', (req, res) => {
const batchId = req.headers['x-hook-batch-id'];
const timestamp = req.headers['x-hook-timestamp'];
console.log(`[VERIFY] URL verification request (batchId=batchId)`);
if (!CONFIG.signSecret) {
console.error('[VERIFY] Cannot verify: HIK_SIGN_SECRET not configured');
return res.status(500).send('signSecret not configured');
}
if (!batchId || !timestamp) {
return res.status(400).send('Missing X-Hook-Batch-Id or X-Hook-Timestamp');
}
const message = `timestamp.batchId`;
const mac = crypto.createHmac('sha256', CONFIG.signSecret).update(message).digest('hex');
res.setHeader('x-hook-signature', `sha256=mac`);
res.status(200).send('OK');
});
// POST Request — Receive Alarm/Event Push
app.post('/hikvision/webhook', async (req, res) => {
const startTime = Date.now();
const batchId = req.body?.batchId;
const list = req.body?.list || [];
console.log(`[IN] batchId=batchId, items=list.length`);
// 1. Verify Signature
if (!verifySignature(req.headers, batchId)) {
console.warn('[REJECT] Invalid signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Return 200 immediately (HCT Open requires within 5s)
res.json({ received: true, batchId, count: list.length });
console.log(`[ACK] Responded in Date.now() - startTimems`);
// 3. Asynchronous processing
try {
const message = buildPayload(req.body);
if (message) {
await relayToOpenClaw(message);
} else {
console.log('[SKIP] All items were duplicates');
}
} catch (err) {
console.error(`[ERROR] Processing failed: err.message`);
}
});
// ============ Startup ============
async function main() {
// Print configuration summary (exits if required config missing)
printConfig();
// Additional startup validation
const missing = [];
if (!CONFIG.openclaw.url) missing.push('OPENCLAW_HOOKS_URL');
if (!CONFIG.openclaw.token) missing.push('OPENCLAW_HOOKS_TOKEN');
if (!CONFIG.openclaw.channel) missing.push('OPENCLAW_CHANNEL');
if (!CONFIG.openclaw.to) missing.push('OPENCLAW_TO');
if (missing.length > 0) {
console.error('[FATAL] Missing required environment variables:', missing.join(', '));
console.error('[FATAL] Cannot start webhook service. Please set them before running server.js');
process.exit(1);
}
// Check OpenClaw connection
await checkOpenClawConnection(CONFIG.openclaw.url, CONFIG.openclaw.token);
// Start server
app.listen(CONFIG.port, () => {
console.log('');
console.log('╔══════════════════════════════════════════╗');
console.log('║ HCT Open Webhook Receiver Started ║');
console.log('╠══════════════════════════════════════════╣');
console.log(`║ Port: String(CONFIG.port).padEnd(29)║`);
console.log('║ Endpoint: POST /hikvision/webhook ║');
console.log('║ Verify: GET /hikvision/webhook ║');
console.log('║ Health: GET /health ║');
console.log('╚══════════════════════════════════════════╝');
console.log('');
console.log('Waiting for HCT Open webhook pushes...');
console.log('');
});
}
main().catch(err => {
console.error('[FATAL] Startup failed:', err);
process.exit(1);
});
FILE:modules/Hik-Connect_Team_Alarm/scripts/webhook_manager.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Webhook Manager
"""
import sys
import os
import json
import argparse
from datetime import datetime
# Robust lib directory import logic
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory")
sys.exit(1)
from token_manager import HCTOpenClient
class WebhookManager(HCTOpenClient):
"""Webhook configuration management client"""
def query(self):
"""Query Webhook configuration"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Querying Webhook configuration...")
endpoint = "/api/hccgw/webhook/v1/config/query"
result = self.request("POST", endpoint, token_header_key="Token")
if result.get("errorCode") == "0":
data = result.get("data", {})
if data:
headers = ["Configuration Item", "Content"]
rows = [
["Callback URL (callbackUrl)", data.get("callbackUrl", "-")],
["Retry Count (retryTimes)", data.get("retryTimes", "-")],
["Retry Interval (retryDelay)", f"{data.get('retryDelay', '-')} ms"]
]
self.print_table("HCTOpen Webhook Current Configuration", headers, rows)
self.exit_with_json({"success": True, "data": data})
else:
print("[INFO] Webhook not currently configured")
self.exit_with_json({"success": True, "data": None, "message": "No webhook configuration found"})
else:
print(f"[ERROR] Query failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({"success": False, "message": result.get("message", "Unknown error"), "errorCode": result.get("errorCode")})
def save(self, callback_url: str, sign_secret: str = None, retry_times: int = 3, retry_delay: int = 1000):
"""Save/Subscribe Webhook configuration"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Saving Webhook configuration: {callback_url}")
if not callback_url.startswith("https://"):
print("[ERROR] Callback URL must use HTTPS protocol")
self.exit_with_json({"success": False, "message": "Callback URL must use HTTPS protocol"})
endpoint = "/api/hccgw/webhook/v1/config/save"
payload = {
"callbackUrl": callback_url,
"retryTimes": retry_times,
"retryDelay": retry_delay
}
if sign_secret:
payload["signSecret"] = sign_secret
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
print("[SUCCESS] Webhook configuration saved successfully")
self.exit_with_json({"success": True, "message": "Webhook configuration saved successfully"})
else:
print(f"[ERROR] Save failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({"success": False, "message": result.get("message", "Unknown error"), "errorCode": result.get("errorCode")})
def delete(self):
"""Delete Webhook configuration"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Deleting Webhook configuration...")
endpoint = "/api/hccgw/webhook/v1/config/delete"
result = self.request("POST", endpoint, token_header_key="Token")
if result.get("errorCode") == "0":
print("[SUCCESS] Webhook configuration deleted successfully")
self.exit_with_json({"success": True, "message": "Webhook configuration deleted successfully"})
else:
print(f"[ERROR] Delete failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({"success": False, "message": result.get("message", "Unknown error"), "errorCode": result.get("errorCode")})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Webhook Configuration Management")
subparsers = parser.add_subparsers(dest="command", help="Operation command")
# Query command
subparsers.add_parser("query", help="Query current Webhook configuration")
# Save command
save_parser = subparsers.add_parser("save", help="Save/Subscribe Webhook configuration")
save_parser.add_argument("--url", required=True, help="Callback URL (must be HTTPS)")
save_parser.add_argument("--secret", help="Signing secret (optional, 8-32 alphanumeric combination)")
save_parser.add_argument("--retries", type=int, default=3, help="Retry count (default: 3)")
save_parser.add_argument("--delay", type=int, default=1000, help="Retry interval ms (default: 1000)")
# Delete command
subparsers.add_parser("delete", help="Delete Webhook configuration")
args = parser.parse_args()
client = WebhookManager()
if args.command == "query":
client.query()
elif args.command == "save":
client.save(args.url, args.secret, args.retries, args.delay)
elif args.command == "delete":
client.delete()
else:
parser.print_help()
if __name__ == "__main__":
main()
FILE:modules/Hik-Connect_Team_ACS/SKILL.md
---
name: hctopen-acs-control
description: |
HCTOpen door access remote control skill. Supports remote open door, close door, normally open, normally closed operations for Hikvision HCT Team mode (HCT) door access devices.
Use when: Need to remotely control open/close status of one or more door access devices, supports specified device or full operations.
Before calling this Skill's script, please check if user provided operation type. If user didn't provide operation type, please clearly inform user in reply: 'Currently using default parameters (operation type is open door), if you need to adjust, please let me know'. After getting confirmation or ignoring, continue execution.
⚠️ Security Requirement: Must set environment variables: Hik-Connect Team OpenAPI AppKey, Hik-Connect Team OpenAPI SecretKey. (API Domain is automatically obtained from token response)
parameters:
- name: action-type
type: integer
description: "Operation type: 1-open door, 2-close door, 3-normally open, 4-normally closed"
required: true
enum: [1, 2, 3, 4]
- name: element-list
type: string
description: "Resource point list, comma-separated door access resource ID list"
required: true
responses:
- success: true
template: "Door access control operation executed, result as follows:"
media: "list_card"
metadata:
openclaw:
emoji: "🚪"
requires:
env: ["HIK_CONNECT_TEAM_OPENAPI_APP_KEY", "HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY"]
pip: ["requests"]
primaryEnv: "HIK_CONNECT_TEAM_OPENAPI_APP_KEY"
warnings:
- "Need Hik-Connect Team OpenAPI AppKey /Hik-Connect Team OpenAPI SecretKey with door access control permission"
- "Token automatically cached in system temp directory, permission 600"
- "May read ~/.openclaw/*.json for credentials (env vars have priority)"
config:
tokenCache:
default: true
envVar: "HIK_CONNECT_TEAM_TOKEN_CACHE"
description: "Enable Token cache (enabled by default). Set to 0 to disable."
configFileRead:
paths:
- "~/.openclaw/config.json"
- "~/.openclaw/gateway/config.json"
- "~/.openclaw/channels.json"
priority: "lower than environment variables"
description: "Reads Hik-Connect Team credentials from OpenClaw config files as fallback"
---
# HCTOpen ACS Control
HCT is short for Hik-Connect for Teams, meaning Hik-Connect Team mode.
HCTOpen is short for Hik-Connect for Teams OpenAPI.
---
## ⚠️ Security Warning (Read Before Use)
**Before executing door access control, please ensure the following security checks are completed:**
| # | Check Item | Status | Description |
|---|----------------------------|-------------|----------------------------------------------------------------------------------------------------------------|
| 1 | **Credential Permission** | ⚠️ Required | Please use credentials with **minimum control permission**, avoid using super admin credentials |
| 2 | **Operation Confirmation** | ⚠️ Note | Remote door open operation has physical security risk, please ensure site safety is confirmed before operation |
| 3 | **Token Cache** | ✅ Encrypted | Token cached in system temp directory, only current user can read (600 permission) |
| 4 | **API Domain** | ✅ Auto | API domain is automatically obtained from token response (no longer requires manual configuration) |
---
## Quick Start
### Run Control Script
Skill supports flexible command line parameters:
```bash
# Scenario 1: Execute door open operation for specified door access (actionType=1)
python scripts/acs_control.py --action-type 2 --element-list "2aabf37ad9804f66acc4ad4fb7bd4694"
# Scenario 2: Execute door close operation for specified door access (actionType=2)
python scripts/acs_control.py --action-type 2 --element-list "door_resource_id_1,door_resource_id_2"
# Scenario 3: Execute normally open operation for specified door access (actionType=3)
python scripts/acs_control.py --action-type 3 --element-list "door_resource_id_1"
# Scenario 4: Execute normally closed operation for specified door access (actionType=4)
python scripts/acs_control.py --action-type 4 --element-list "door_resource_id_1"
```
---
## API Parameter Details
### 1. Remote Control Request Parameters
**Endpoint**: `POST /api/hccgw/acs/v1/remote/control`
| Parameter Name | Type | Description | Required | Default | Notes |
|----------------|---------|---------------------|----------|---------|---------------------------------------------------------------|
| `actionType` | Integer | Operation type | **Yes** | - | 1-open door, 2-close door, 3-normally open, 4-normally closed |
| `elementlist` | Array | Resource point list | No | [] | Door logical resource ID list |
| `direction` | Integer | Traffic direction | No | 0 | 0-entry, 1-exit. Mainly for gates with direction distinction. |
### 2. API Return Data Description
API returns list of devices that failed execution. If `operationResult` is empty, it means all requested devices operated successfully.
| Field Name | Type | Description | Notes |
|---------------|--------|--------------------------|----------------------------------------|
| `elementId` | String | Door logical resource ID | Identifies which door operation failed |
| `elementName` | String | Door name | Human-readable device name |
| `areaId` | String | Area ID | Device area identifier |
| `areaName` | String | Area name | Device area name |
| `errorCode` | String | Error code | Specific reason code for failure |
---
## Environment Variables
| Variable Name | Required | Description |
|---------------------------------------|----------|-----------------------------------------|
| `HIK_CONNECT_TEAM_OPENAPI_APP_KEY` | Yes | Hik-Connect Team OpenAPI AppKey |
| `HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY` | Yes | Your Hik-Connect Team OpenAPI SecretKey |
| `HIK_CONNECT_TEAM_TOKEN_CACHE` | No | 1=Enable cache (default), 0=Disable |
---
## API Endpoints
| Function | Endpoint |
|---------------------|-----------------------------------------|
| Get Token | `POST /api/hccgw/platform/v1/token/get` |
| Door Access Control | `POST /api/hccgw/acs/v1/remote/control` |
**Domain**: Automatically obtained from token response (`areaDomain` field)
---
## Workflow
```mermaid
graph TD
A[Start Script] --> B{Check Environment Variables}
B -- Missing --> C[Report Error and Exit]
B -- Pass --> D[Get AccessToken]
D --> E{Is Token Valid?}
E -- Cache Valid --> F[Use Cache Directly]
E -- Expired/No Cache --> G[Call API to Get New Token]
G --> H[Save to Local Cache]
F --> I[Send Remote Control Command]
H --> I
I --> J{Parse Return Result}
J -- Failed Devices Exist --> K[Print Failed List Table]
J -- All Successful --> L[Print Success Message]
K --> M[Output Complete JSON Result]
L --> M
M --> N[End]
```
---
## Output Examples
### Partial Operation Failed Example:
```text
[2026-04-22 11:31:34] Executing door access control: Type=1, Count=1
[2026-04-22 11:31:34] Executing door access control: Type=1, Count=1
[WARNING] Some devices operation failed:
======================================================================
Failed Device List
======================================================================
No. Door Resource ID Door Name Area Error Code
------------------------------------------------------------------
1 2aabf37ad9804f66acc4ad4fb7bd4694 VMS000003
======================================================================
[JSON Output]
{
"success": false,
"operationResult": [
{
"elementId": "2aabf37ad9804f66acc4ad4fb7bd4694",
"elementName": "",
"areaId": "",
"areaName": "",
"errorCode": "VMS000003"
}
],
"error": "Some operations failed"
}
======================================================================
Done
======================================================================
```
### All Operations Successful Example:
```text
[2026-04-22 11:36:15] Executing door access control: Type=2, Count=1
[2026-04-22 11:36:15] Executing door access control: Type=2, Count=1
[SUCCESS] All door access operations executed successfully
[JSON Output]
{
"success": true,
"operationResult": []
}
======================================================================
Done
======================================================================
```
---
## File Structure
```
├── scripts/
│ └── acs_control.py # Door access control core execution script
└── SKILL.md # Skill usage documentation
```
---
## FAQ
- **Q: Why does it show "Credentials required"?**
- A: Please ensure `export` command has been correctly executed to set `HIK_CONNECT_TEAM_OPENAPI_APP_KEY` and other environment variables.
- **Q: How long is Token cache valid?**
- A: Follows HCT API standard, usually 7 days. Script will auto-refresh 5 minutes before expiration.
- **Q: How to operate all door access?**
- A: Cannot operate all door access, can only operate specific door access.
- **Q: Why did operation fail?**
- A: Please check device status, permission configuration and network connection. Failed device information will be listed in detail in output.
- **Q: How to get door access logical resource ID?**
- A: Must first use door access device serial number to call `modules/Hik-Connect_Team_Resource/scripts/list_doors.py <device serial number>`, get door access resource ID from returned list.
- **Q: How to get the correct door resource ID?**
- A: Use `list_doors.py` API,Example:
```bash
python scripts/list_doors.py L33721705
# Output: door resource ID is in "Door Access ID" column
```
---
## Security Notes
- Use Hik-Connect Team OpenAPI AppKey / Hik-Connect Team OpenAPI SecretKey with minimum permissions
- Token cached in system temp directory, enabled by default
- Automatic 4-second interval between device requests to avoid rate limiting
- All remote operations require permission authentication
---
## Other Notes
- If user didn't provide operation type, should first inform user and ask about default configuration
- Continue executing request after user confirmation
- Door access control operations all have physical security risks, please operate with caution
---
---
**Error Codes**:
| Return Code | Return Message | Description |
|-------------|---------------------------|--------------------------------------------------------------------------|
| VMS000003 | Resource operation failed | Resource operation failed: The access control resource ID does not exist |
---
FILE:modules/Hik-Connect_Team_ACS/scripts/acs_control.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen ACS Control
"""
import sys
import os
import json
import argparse
from datetime import datetime
# Robust lib directory import logic: search upward until lib directory is found
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Search upward 3 levels for root directory containing lib
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory, please ensure script is located in Hik-Connect_Team Skills directory structure")
sys.exit(1)
from token_manager import HCTOpenClient
class ACSControlClient(HCTOpenClient):
"""Door access control client"""
def control(self, action_type: int, element_list: list):
"""Execute door access control operation"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Executing door access control: Type={action_type}, Count={len(element_list) if element_list else 'All'}")
# Check if element_list is empty
if not element_list:
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] [ERROR] element_list cannot be empty. Please provide at least one resource ID.")
self.exit_with_json({
"success": False,
"error": "element_list is required and cannot be empty",
"errorCode": "PARAMETER_EMPTY"
})
print(
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Executing door access control: Type={action_type}, Count={len(element_list)}")
endpoint = "/api/hccgw/acs/v1/remote/control"
payload = {
"remoteControl": {
"actionType": action_type,
"elementlist": element_list
}
}
# ACS module API uses "Token" as Header Key
result = self.request("POST", endpoint, json_data=payload, token_header_key="Token")
if result.get("errorCode") == "0":
op_result = result.get("data", {}).get("operationResult", [])
if op_result:
print("[WARNING] Some devices operation failed:")
headers = ["No.", "Door Resource ID", "Door Name", "Area", "Error Code"]
rows = []
for i, res in enumerate(op_result, 1):
rows.append([
i,
res.get("elementId", "-"),
res.get("elementName", "-"),
res.get("areaName", "-"),
res.get("errorCode", "-")
])
self.print_table("Failed Device List", headers, rows)
self.exit_with_json({"success": False, "operationResult": op_result, "error": "Some operations failed"})
else:
print("[SUCCESS] All door access operations executed successfully")
self.exit_with_json({"success": True, "operationResult": []})
else:
# Use unified message field
print(f"[ERROR] Door access control failed: {result.get('message', 'Unknown error')}")
self.exit_with_json({"success": False, "error": result.get("message", "Unknown error"), "errorCode": result.get("errorCode")})
def main():
parser = argparse.ArgumentParser(description="HCTOpen Door Access Remote Control")
parser.add_argument("--action-type", type=int, required=True, choices=[1, 2, 3, 4], help="1-open door, 2-close door, 3-normally open, 4-normally closed")
parser.add_argument("--element-list", type=str, default="", help="Comma-separated resource ID list")
args = parser.parse_args()
elements = [e.strip() for e in args.element_list.split(',') if e.strip()] if args.element_list else []
client = ACSControlClient()
client.control(args.action_type, elements)
if __name__ == "__main__":
main()
FILE:lib/README_TOKEN_MANAGER.md
# HCT Global Token Manager
🔐 Provides unified Token cache management for all HCT skills.
## 📁 Location
```
/Users/jony/.openclaw/workspace/skills/Hik-Connect Team Skills/lib/token_manager.py
```
## ✨ Features
- **Global Cache**: All skills share the same Token, avoiding repeated acquisition
- **Smart Reuse**: Use cache directly during Token validity period, no API calls
- **Safe Buffer**: Auto-refresh 5 minutes before expiration to avoid boundary issues
- **Multi-Account Support**: Identify different accounts based on md5(appKey:appSecret)
- **Atomic Write**: Write to temporary file first then replace, ensuring data safety
- **Permission Protection**: Cache file permission set to 600 (owner read/write only)
## 🔐 Credential Configuration
**Credentials only need to be configured ONCE. The system will automatically find and use them.**
**Priority order (highest to lowest):**
```
┌─────────────────────────────────────────────────────────────┐
│ 1. Environment Variables (if set) │
│ ├─ HIK_CONNECT_TEAM_OPENAPI_APP_KEY │
│ └─ HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY │
├─────────────────────────────────────────────────────────────┤
│ 2. OpenClaw Config Files (if env vars not set) │
│ ├─ ~/.openclaw/config.json │
│ ├─ ~/.openclaw/gateway/config.json │
│ └─ ~/.openclaw/channels.json ⭐ Recommended │
└─────────────────────────────────────────────────────────────┘
```
### OpenClaw Config File Format
Config file format (same for all three files):
```json
{
"channels": {
"hik_connect_team_openapi": {
"appKey": "Your Hik-Connect Team OpenAPI AppKey",
"secretKey": "Your Hik-Connect Team OpenAPI SecretKey",
"enabled": true
}
}
}
```
**Recommended: Save to `~/.openclaw/channels.json`** — This is the dedicated file for channel credentials.
**⚠️ Security Note**: Before saving credentials to a config file, ask the user for confirmation. Storing credentials on disk is convenient but introduces some risk. Always inform the user of this option and let them choose.
---
## 🚀 Usage
### Method 1: Import and use in Python skills
```python
# Add lib directory to path
import os
import sys
script_dir = os.path.dirname(os.path.abspath(__file__))
workspace_dir = os.path.join(script_dir, "..", "..")
lib_dir = os.path.abspath(os.path.join(workspace_dir, "Hik-Connect Team Skills", "lib"))
if os.path.exists(lib_dir) and lib_dir not in sys.path:
sys.path.insert(0, lib_dir)
from token_manager import get_cached_token
# Get Token (prefer cache, auto-refresh if expired)
token_result = get_cached_token(app_key, app_secret, use_cache=True)
if token_result["success"]:
access_token = token_result["access_token"]
print(f"Token: {access_token}")
print(f"From Cache: {token_result['from_cache']}")
```
### Method 2: Command Line Tool
```bash
cd /Users/jony/.openclaw/workspace/skills/Hik-Connect Team Skills
# Get Token (use cache)
python lib/token_manager.py get --app-key "your_key" --app-secret "your_secret"
# Force refresh Token (no cache)
python lib/token_manager.py refresh --app-key "your_key" --app-secret "your_secret"
# View cache list
python lib/token_manager.py list
# Clear specific account cache
python lib/token_manager.py clear --app-key "your_key" --app-secret "your_secret"
# Clear all cache
python lib/token_manager.py clear
```
## 📊 Cache Location
```
/var/folders/xx/xxxx/T/hctopen_global_token_cache/global_token_cache.json
```
Cache file format:
```json
{
"3aa746c5ea5329ab...": {
"cache_key": "3aa746c5ea5329ab...",
"access_token": "at.ay4x6ris6kl61uao6a3qcjpa1ww...",
"area_domain": "https://ieu-team.hikcentralconnect.com",
"expire_time": 1774419637518,
"created_at": 1773816338280,
"app_key_prefix": "26810f3a..."
}
}
```
## 🔄 Workflow
```
Skill Startup
↓
Call get_cached_token(app_key, app_secret)
↓
Check cache file
├─ Cache exists and not expired → Return cached Token directly ✅
└─ Cache doesn't exist or expired → Call API to get new Token
↓
Save to cache file
↓
Return new Token
```
## 🎯 Integrated Skills
| Skill | Status | File |
|-------------------------------|--------------|----------------------------|
| Device List (device_list) | ✅ Integrated | `scripts/list_devices.py` |
| Device Detail (device_detail) | ✅ Integrated | `scripts/device_detail.py` |
## 🧪 Test Examples
```bash
# 1. Clear cache
python lib/token_manager.py clear
# 2. First acquisition (from API)
python lib/token_manager.py get --app-key "26810f3acd794862b608b6cfbc32a6b8" --app-secret "3155063e93f09f377eaf5ba9f321f8c2"
# Output: From Cache: False
# 3. Get again (from cache)
python lib/token_manager.py get --app-key "26810f3acd794862b608b6cfbc32a6b8" --app-secret "3155063e93f09f377eaf5ba9f321f8c2"
# Output: From Cache: True
# 4. View cache
python lib/token_manager.py list
```
## ⚠️ Notes
1. **Token Validity**: 7 days, auto-refresh 5 minutes before expiration
2. **Cache Cleanup**: System temp directory may be periodically cleaned
3. **Multi-Account**: Each appKey:appSecret combination has independent cache
4. **Security**: Cache file permission 600, owner read/write only
5. **Concurrency**: Supports multi-process simultaneous reading, atomic operation during writing
## 📝 API Functions
### get_cached_token(app_key, app_secret, use_cache=True)
Get Token, prefer using cache.
**Returns**:
```json
{
"success": True,
"access_token": "at.xxx",
"area_domain": "https://hpc-sgp-uat-5.hik-partner.com",
"expire_time": 1774419637518,
"from_cache": True
}
```
### refresh_token(app_key, app_secret, cache_key=None)
Force refresh Token, do not use cache.
### clear_token_cache(app_key=None, app_secret=None)
Clear cache (can specify account or clear all).
### list_cached_tokens()
List all cached Token information.
---
**Error Codes**:
| Return Code | Return Message | Description |
|-------------|-------------------|-------------------------------|
| OPEN000001 | AK does not exist | Please check if AK is correct |
| OPEN000002 | SK error | SK does not match current AK |
FILE:lib/token_manager.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
HCTOpen Global Token Manager & Base Client
Provides global Token cache management and base API request encapsulation.
"""
import os
import sys
import time
import json
import hashlib
import tempfile
import requests
from typing import Dict, Any, Optional, List, Union
from pathlib import Path
def _get_openclaw_config_paths():
"""Get list of OpenClaw config file paths to search"""
home = Path.home()
return [
home / ".openclaw" / "config.json",
home / ".openclaw" / "gateway" / "config.json",
home / ".openclaw" / "channels.json",
]
def _load_openclaw_config():
"""Load Hik-Connect Team credentials from OpenClaw config files
Searches for config in the following order:
1. ~/.openclaw/config.json
2. ~/.openclaw/gateway/config.json
3. ~/.openclaw/channels.json
Config format:
{
"channels": {
"hik_connect_team_openapi": {
"appKey": "your_app_key",
"secretKey": "your_secret_key",
"enabled": true
}
}
}
"""
for config_path in _get_openclaw_config_paths():
if config_path.exists():
try:
with open(config_path, "r") as f:
content = f.read().strip()
if not content:
continue
data = json.loads(content)
hct_config = data.get("channels", {}).get("hik_connect_team_openapi", {})
if hct_config.get("enabled", False) and hct_config.get("appKey") and hct_config.get("secretKey"):
return hct_config.get("appKey"), hct_config.get("secretKey")
except (json.JSONDecodeError, OSError):
continue
return None, None
class TokenManager:
"""Manage HCTOpen AccessToken acquisition and caching"""
CACHE_DIR_NAME = "hctopen_global_token_cache"
CACHE_FILE_NAME = "global_token_cache.json"
TOKEN_BUFFER_TIME = 5 * 60 * 1000 # 5-minute buffer
TOKEN_URL = "https://ieu-team.hikcentralconnect.com/api/hccgw/platform/v1/token/get"
def __init__(self):
self.token_url = self.TOKEN_URL
self.cache_file = os.path.join(tempfile.gettempdir(), self.CACHE_DIR_NAME, self.CACHE_FILE_NAME)
os.makedirs(os.path.dirname(self.cache_file), exist_ok=True)
def _get_cache_key(self, app_key: str, secret_key: str) -> str:
return hashlib.md5(f"{app_key}:{secret_key}".encode()).hexdigest()
def _load_cache(self) -> Dict[str, Any]:
if not os.path.exists(self.cache_file):
return {}
try:
with open(self.cache_file, "r") as f:
return json.load(f)
except Exception as e:
print(f"[WARNING] Failed to load Token cache: {e}", file=sys.stderr)
return {}
def _save_cache(self, cache_data: Dict[str, Any]):
try:
temp_file = self.cache_file + ".tmp"
with open(temp_file, "w") as f:
json.dump(cache_data, f, indent=2)
os.replace(temp_file, self.cache_file)
# Only apply permission on Unix systems (os.chmod has no effect on Windows)
if os.name != 'nt':
os.chmod(self.cache_file, 0o600)
except Exception as e:
print(f"[WARNING] Failed to save Token cache: {e}", file=sys.stderr)
def get_token(self, app_key: str, secret_key: str, force_refresh: bool = False) -> Dict[str, Any]:
"""Get Token, prefer using cache"""
use_cache = os.environ.get("HIK_CONNECT_TEAM_TOKEN_CACHE", "1") == "1" and not force_refresh
cache_key = self._get_cache_key(app_key, secret_key)
if use_cache:
cache = self._load_cache()
if cache_key in cache:
token_data = cache[cache_key]
# Handle expire_time in seconds or milliseconds
# HCT API returns expireTime in seconds (e.g., 3600),
# but cache stores it as-is. Convert to milliseconds for comparison.
# If value > 10^11, it's already in milliseconds (e.g., 1774419637518)
expire_time = token_data.get("expire_time", 0)
if expire_time < 10**11:
expire_time *= 1000
if time.time() * 1000 + self.TOKEN_BUFFER_TIME < expire_time:
return {"success": True, "access_token": token_data["access_token"], "area_domain": token_data.get("area_domain"), "from_cache": True}
# Request new Token
try:
resp = requests.post(self.token_url, json={"appKey": app_key, "secretKey": secret_key}, timeout=10)
result = resp.json()
if result.get("errorCode") == "0":
data = result.get("data", {})
access_token = data.get("accessToken")
expire_time = data.get("expireTime") # API usually returns seconds
area_domain = data.get("areaDomain", "").rstrip("/")
# Update cache
cache = self._load_cache()
cache[cache_key] = {
"access_token": access_token,
"expire_time": expire_time,
"area_domain": area_domain,
"app_key_prefix": app_key[:8]
}
self._save_cache(cache)
return {"success": True, "access_token": access_token, "area_domain": area_domain, "from_cache": False}
# Unify error field as message
return {"success": False, "message": result.get("message", "Unknown error"), "errorCode": result.get("errorCode")}
except Exception as e:
return {"success": False, "message": f"Request exception: {str(e)}"}
# Robust lib directory import logic: search upward until lib directory is found
def setup_lib_path():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Search upward 3 levels for root directory containing lib
root_dir = current_dir
for _ in range(3):
root_dir = os.path.dirname(root_dir)
potential_lib = os.path.join(root_dir, "lib")
if os.path.exists(potential_lib):
if potential_lib not in sys.path:
sys.path.insert(0, potential_lib)
return True
return False
if not setup_lib_path():
print("[ERROR] Cannot find lib directory, please ensure script is located in Hik-Connect_Team Skills directory structure")
sys.exit(1)
class HCTOpenClient:
"""HCTOpen API Base Client"""
def __init__(self):
# Priority 1: Environment variables (highest)
self.app_key = os.environ.get("HIK_CONNECT_TEAM_OPENAPI_APP_KEY")
self.secret_key = os.environ.get("HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY")
self._config_source = "environment variables"
# Priority 2: OpenClaw config files (only if env vars not set)
if not all([self.app_key, self.secret_key]):
config_app_key, config_secret_key = _load_openclaw_config()
if config_app_key and config_secret_key:
self.app_key = config_app_key
self.secret_key = config_secret_key
self._config_source = "OpenClaw config file"
if not all([self.app_key, self.secret_key]):
print("[ERROR] Credentials not found. Please set either:")
print(" 1. Environment variables: HIK_CONNECT_TEAM_OPENAPI_APP_KEY and HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY")
print(" 2. OpenClaw config file: ~/.openclaw/config.json with channels.hik_connect_team_openapi section")
sys.exit(1)
print(f"[INFO] Using credentials from: {self._config_source}")
self.token_manager = TokenManager()
self._access_token = None
self._area_domain = None
def get_access_token(self, force_refresh: bool = False) -> str:
if not self._access_token or force_refresh:
res = self.token_manager.get_token(self.app_key, self.secret_key, force_refresh)
if res["success"]:
self._access_token = res["access_token"]
self._area_domain = res.get("area_domain", "")
else:
# Unify error field as message
print(f"[ERROR] Failed to get Token: {res.get('message')}")
sys.exit(1)
return self._access_token
def get_area_domain(self) -> str:
"""Get the area domain from token response, must call get_access_token first"""
if not self._area_domain:
self.get_access_token()
return self._area_domain
def request(self, method: str, endpoint: str, json_data: Optional[Dict] = None, params: Optional[Dict] = None, token_header_key: str = "Token") -> Dict[str, Any]:
"""Send request with Token, supports auto retry (when Token expired)"""
# Use areaDomain from token response as the domain
area_domain = self.get_area_domain()
if not area_domain:
return {"errorCode": "-1", "message": "areaDomain not found in token response"}
url = f"{area_domain}{endpoint}"
for attempt in range(2):
headers = {
"Content-Type": "application/json",
token_header_key: self.get_access_token(force_refresh=(attempt > 0))
}
try:
response = requests.request(method, url, headers=headers, json=json_data, params=params, timeout=30)
result = response.json()
# Token invalid error codes - retry once with fresh token
# Common token error codes in Hikvision APIs: 10002 (token expired/invalid), 20004 (token malformed)
error_code = result.get("errorCode")
if error_code in ["10002", "20004"] and attempt == 0:
print("[INFO] Token may be invalid, trying to refresh Token and retry...")
continue
# Unify error field as message
if result.get("errorCode") != "0" and "errorMsg" in result:
result["message"] = result.pop("errorMsg")
return result
except requests.exceptions.RequestException as e:
# Unify error field as message
return {"errorCode": "-1", "message": f"Request exception: {str(e)}"}
except json.JSONDecodeError:
# Unify error field as message
return {"errorCode": "-1", "message": f"JSON parsing failed: {response.text}"}
except Exception as e:
# Unify error field as message
return {"errorCode": "-1", "message": f"Unknown error: {str(e)}"}
# Both attempts failed
return {"errorCode": "-1", "message": "Request failed, Token refresh still invalid or other issue encountered"}
@staticmethod
def print_table(title: str, headers: List[str], rows: List[List[Any]]):
"""Generic table printing utility"""
print("=" * 70)
print(title)
print("=" * 70)
if not rows:
print("No data found")
return
# Calculate max width for each column
col_widths = [len(h) for h in headers]
for row in rows:
for i, val in enumerate(row):
# Ensure val is string, avoid len() error
col_widths[i] = max(col_widths[i], len(str(val)))
# Print header
header_line = " ".join(f"{headers[i]:<{col_widths[i]}}" for i in range(len(headers)))
print(header_line)
print("-" * len(header_line))
# Print rows
for row in rows:
row_line = " ".join(f"{str(val):<{col_widths[i]}}" for i, val in enumerate(row))
print(f"{row_line}")
print("=" * 70)
@staticmethod
def exit_with_json(data: Dict[str, Any]):
"""Output in JSON format and exit"""
print("\n[JSON Output]")
print(json.dumps(data, indent=2, ensure_ascii=False))
print("=" * 70)
print("Done")
print("=" * 70)
sys.exit(0 if data.get("success", True) else 1)
# Backward compatibility (if external code calls get_cached_token directly)
def get_cached_token(app_key, secret_key, use_cache=True):
tm = TokenManager()
return tm.get_token(app_key, secret_key, force_refresh=not use_cache)
if __name__ == "__main__":
# Simple CLI test
# Ensure HIK_CONNECT_TEAM_OPENAPI_APP_KEY, HIK_CONNECT_TEAM_OPENAPI_SECRET_KEY environment variables are set
try:
client = HCTOpenClient()
token = client.get_access_token()
print(f"Test Token: {token[:10]}...")
# Simulate a request
test_endpoint = "/api/hccgw/resource/v1/devices/get" # Hypothetical test endpoint
test_result = client.request("POST", test_endpoint, json_data={"pageIndex":1, "pageSize":1}, token_header_key="Token")
print("Test request result:")
print(json.dumps(test_result, indent=2, ensure_ascii=False))
except Exception as e:
print(f"Test failed: {e}")
Control QQ Music playback in any browser that exposes a DevTools/CDP endpoint. Supports play/pause/next/prev, search songs/artists/albums, play liked songs,...
---
name: qq-music
description: "Control QQ Music playback in any browser that exposes a DevTools/CDP endpoint. Supports play/pause/next/prev, search songs/artists/albums, play liked songs, random play, like current song, playlist playback, screenshots, and browser-target discovery across platforms."
metadata:
openclaw:
emoji: "🎵"
---
# QQ Music Control
Use this skill to control QQ Music (y.qq.com) through a browser DevTools/CDP endpoint.
## What it supports
- Cross-platform operation on Windows, macOS, and Linux
- Cross-browser operation as long as the browser exposes a DevTools/CDP endpoint
- QQ Music search and playback flows
- Liked songs / favorites playback
- Playlist playback
- Pause / resume / next / previous
- Like current track
- Status lookup
- Screenshot capture
## Requirements
- A browser with remote debugging enabled, or a browser/profile already exposing a DevTools endpoint
- QQ Music logged in for playlist / liked-song actions
- A browser tab on `y.qq.com` or the ability to open one
## Controller script
All actions go through the bundled script:
```bash
node qq-music-ctl.js <action> [args...]
```
If the browser is not already connected, set one of these:
- `QQ_MUSIC_DEVTOOLS_URL` — explicit DevTools base URL, e.g. `http://127.0.0.1:9222`
- `QQ_MUSIC_DEVTOOLS_PORTS` — comma-separated ports to probe
- `QQ_MUSIC_DEVTOOLS_HOST` — host to probe
## Action map
| Action | Meaning |
|---|---|
| `play` | Resume playback |
| `pause` | Pause playback |
| `toggle` | Toggle play/pause |
| `next` | Next track |
| `prev` | Previous track |
| `status` | Current track/status |
| `search <keyword>` | Search song and play best match |
| `search-artist <name>` | Search artist and play top result |
| `search-album <name>` | Search album and play top result |
| `play-liked` | Play liked songs from the start |
| `play-liked-random` | Randomly play one liked song |
| `play-playlist <id>` | Play playlist by ID |
| `like` | Like current song |
| `screenshot [path]` | Capture a screenshot |
| `tabs` | List detectable browser tabs |
| `init` | Open QQ Music if needed |
## Selection rules
- Prefer the player tab when doing transport controls.
- Prefer the browse tab for search and playlist discovery.
- If there is no QQ Music tab, open a blank tab and navigate to `https://y.qq.com/`.
- For song search, the first exact or containing title match wins; otherwise use the first visible result.
- For liked songs, random play uses the currently visible page of liked songs.
## Notes
- The skill does not assume a specific browser brand.
- The skill does not assume Windows paths or `cmd /c`.
- If the browser exposes multiple DevTools endpoints, the controller probes common ports and prefers the endpoint that already has QQ Music tabs.
- Avoid hardcoding personal paths, usernames, tokens, or host-specific secrets in examples or bundled code.
- System audio volume is out of scope for this skill.
FILE:qq-music-ctl.js
#!/usr/bin/env node
/**
* QQ Music browser controller.
*
* Cross-platform and browser-agnostic as long as the browser exposes a
* DevTools / CDP endpoint.
*
* Usage:
* node qq-music-ctl.js <action> [args...]
*
* Environment:
* QQ_MUSIC_DEVTOOLS_URL Explicit DevTools base URL, e.g. http://127.0.0.1:9222
* QQ_MUSIC_DEVTOOLS_HOST Host to probe (default: 127.0.0.1)
* QQ_MUSIC_DEVTOOLS_PORTS Comma-separated probe ports (default: 19011,9222,9223,9224,9225,9333)
* QQ_MUSIC_SCREENSHOT_PATH Output path for screenshots (default: qq-music-screenshot.png)
* QQ_MUSIC_PROBE_TIMEOUT_MS Probe timeout per endpoint (default: 1200)
* QQ_MUSIC_PAGE_WAIT_MS Wait after navigation (default: 3500)
*/
const fs = require('fs');
const DEFAULT_HOST = process.env.QQ_MUSIC_DEVTOOLS_HOST || '127.0.0.1';
const DEFAULT_PORTS = parsePortList(process.env.QQ_MUSIC_DEVTOOLS_PORTS || '19011,9222,9223,9224,9225,9333');
const SCREENSHOT_PATH = process.env.QQ_MUSIC_SCREENSHOT_PATH || 'qq-music-screenshot.png';
const PROBE_TIMEOUT_MS = Number(process.env.QQ_MUSIC_PROBE_TIMEOUT_MS || 1200);
const PAGE_WAIT_MS = Number(process.env.QQ_MUSIC_PAGE_WAIT_MS || 3500);
function parsePortList(value) {
return [...new Set(String(value).split(',').map(s => Number(s.trim())).filter(n => Number.isInteger(n) && n > 0))];
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
function timeoutError(label) {
return new Error(`label timed out`);
}
async function fetchJson(url, timeoutMs = PROBE_TIMEOUT_MS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP res.status`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
function baseOrigin(input) {
const url = new URL(input);
return url.origin;
}
function scoreEndpoint(entry) {
const list = entry.list || [];
const urls = list.map(t => t.url || '');
let score = 0;
if (urls.some(u => u.includes('y.qq.com'))) score += 100;
if (urls.some(u => u.includes('/player'))) score += 30;
if (list.some(t => t.type === 'page')) score += 10;
return score;
}
async function discoverEndpoint() {
const candidates = [];
if (process.env.QQ_MUSIC_DEVTOOLS_URL) candidates.push(baseOrigin(process.env.QQ_MUSIC_DEVTOOLS_URL));
for (const port of DEFAULT_PORTS) candidates.push(`http://DEFAULT_HOST:port`);
const seen = new Set();
const discovered = [];
for (const baseUrl of candidates) {
if (seen.has(baseUrl)) continue;
seen.add(baseUrl);
try {
const [version, list] = await Promise.all([
fetchJson(`baseUrl/json/version`),
fetchJson(`baseUrl/json/list`),
]);
discovered.push({ baseUrl, version, list });
} catch {
// ignore and continue probing
}
}
if (!discovered.length) {
throw new Error(
`No DevTools endpoint found. Set QQ_MUSIC_DEVTOOLS_URL or start a browser with remote debugging. ` +
`Probed ports: DEFAULT_PORTS.join(', ')`
);
}
discovered.sort((a, b) => scoreEndpoint(b) - scoreEndpoint(a));
return discovered[0];
}
function pageTargets(entry) {
return (entry.list || []).filter(t => t.type === 'page');
}
function firstTarget(list, predicate) {
return list.find(predicate) || null;
}
function isQQMusicTarget(target) {
return target && typeof target.url === 'string' && target.url.includes('y.qq.com');
}
function isPlayerTarget(target) {
return isQQMusicTarget(target) && target.url.includes('/player');
}
function isBrowseTarget(target) {
return isQQMusicTarget(target) && !target.url.includes('/player');
}
function prettyUrl(target) {
return target ? target.url : '';
}
function connectCDP(wsUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
let seq = 0;
let closed = false;
const pending = new Map();
function failAll(err) {
if (closed) return;
closed = true;
for (const { reject: rej, timer } of pending.values()) {
clearTimeout(timer);
rej(err);
}
pending.clear();
}
function send(method, params = {}) {
if (closed) return Promise.reject(new Error('CDP session closed'));
return new Promise((resolveSend, rejectSend) => {
const id = ++seq;
const timer = setTimeout(() => {
pending.delete(id);
rejectSend(timeoutError(method));
}, 10000);
pending.set(id, { resolve: resolveSend, reject: rejectSend, timer });
ws.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const res = await send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
});
return res.result ? res.result.value : undefined;
}
ws.onopen = () => resolve({ ws, send, evaluate, close: () => { closed = true; ws.close(); } });
ws.onmessage = evt => {
const msg = JSON.parse(evt.data);
if (!msg.id || !pending.has(msg.id)) return;
const item = pending.get(msg.id);
pending.delete(msg.id);
clearTimeout(item.timer);
if (msg.error) item.reject(new Error(msg.error.message || 'CDP command failed'));
else item.resolve(msg.result);
};
ws.onerror = err => failAll(new Error(err.message || 'CDP connection error'));
ws.onclose = () => failAll(new Error('CDP connection closed'));
});
}
async function browserSession(entry) {
return connectCDP(entry.version.webSocketDebuggerUrl);
}
async function pageSession(target) {
return connectCDP(target.webSocketDebuggerUrl);
}
function output(obj) {
console.log(JSON.stringify(obj, null, 2));
}
function jsonString(expr) {
return `JSON.stringify(expr)`;
}
async function createTarget(entry, url = 'about:blank') {
const browser = await browserSession(entry);
try {
const result = await browser.send('Target.createTarget', { url });
return result.targetId;
} finally {
browser.close();
}
}
async function openOrReuseBrowseTarget(entry) {
const pages = pageTargets(entry);
const browse = firstTarget(pages, isBrowseTarget);
if (browse) return browse;
const anyQQ = firstTarget(pages, isQQMusicTarget);
if (anyQQ) return anyQQ;
const blank = firstTarget(pages, t => t.url === 'about:blank' || t.url.startsWith('chrome://'));
if (blank) return blank;
const newTargetId = await createTarget(entry, 'about:blank');
const refreshed = await fetchJson(`entry.baseUrl/json/list`);
return firstTarget(refreshed, t => t.id === newTargetId) || firstTarget(refreshed, t => t.url === 'about:blank') || null;
}
async function openMusicPage(entry) {
const target = await openOrReuseBrowseTarget(entry);
if (!target) throw new Error('No browser tab available');
const session = await pageSession(target);
try {
await session.send('Page.navigate', { url: 'https://y.qq.com/' });
await sleep(PAGE_WAIT_MS);
} finally {
session.close();
}
}
function songQueryJS(keyword) {
const q = JSON.stringify(String(keyword || '').trim().toLowerCase());
return `
(function() {
const want = q;
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return false, msg: "No search results"')};
function clean(s) { return String(s || '').trim().toLowerCase().replace(/\s+/g, ''); }
function titleOf(item) {
const el = item.querySelector('.songlist__songname_txt a[title]');
return el ? String(el.title || el.textContent || '').trim() : '';
}
function artistOf(item) {
const el = item.querySelector('.songlist__artist a');
return el ? String(el.title || el.textContent || '').trim() : '';
}
function play(item) {
const btn = item.querySelector('.list_menu__play');
if (btn) { btn.click(); return 'play-btn'; }
const song = item.querySelector('.songlist__songname_txt');
if (song) { song.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })); return 'dblclick'; }
return 'none';
}
let chosen = items[0];
if (want) {
const exact = items.find(item => clean(titleOf(item)) === want);
const contains = items.find(item => clean(titleOf(item)).includes(want));
chosen = exact || contains || items[0];
}
const name = titleOf(chosen);
const artist = artistOf(chosen);
const method = play(chosen);
return JSON.stringify({ ok: true, song: name, artist, results: items.length, method });
})()
`;
}
function firstVisibleSongJS() {
return `
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No songs found' });
const idx = Math.floor(Math.random() * items.length);
const item = items[idx];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
const song = nameEl ? String(nameEl.title || nameEl.textContent || '').trim() : '';
const artist = artistEl ? String(artistEl.title || artistEl.textContent || '').trim() : '';
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song, artist, index: idx, total: items.length });
})()
`;
}
function playlistPlayJS() {
return `
(function() {
const playAll = document.querySelector('.mod_btn_green');
if (playAll) {
playAll.click();
const items = Array.from(document.querySelectorAll('.songlist__item'));
const first = items[0] ? items[0].querySelector('.songlist__songname_txt a[title]') : null;
return JSON.stringify({ ok: true, action: 'play_all', firstSong: first ? String(first.title || '').trim() : '', total: items.length });
}
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'Playlist empty or not found' });
const btn = items[0].querySelector('.list_menu__play');
if (btn) btn.click(); else items[0].dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, action: 'first_song', total: items.length });
})()
`;
}
async function actionTabs() {
const entry = await discoverEndpoint();
output({
browser: entry.version.Browser || entry.version['Browser'] || '',
baseUrl: entry.baseUrl,
tabs: pageTargets(entry).map(t => ({
id: t.id,
title: t.title,
url: t.url,
isPlayer: isPlayerTarget(t),
isQQMusic: isQQMusicTarget(t),
})),
});
}
async function actionInit() {
const entry = await discoverEndpoint();
const browse = await openOrReuseBrowseTarget(entry);
if (!browse) throw new Error('No browser tab available');
output({ ok: true, baseUrl: entry.baseUrl, targetId: browse.id, url: prettyUrl(browse) });
}
async function withPlayer(fn) {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget);
if (!target) return output({ error: 'Player not found. Play a song first.' });
const session = await pageSession(target);
try {
return await fn(session, target, entry);
} finally {
session.close();
}
}
async function withBrowse(fn) {
const entry = await discoverEndpoint();
const target = await openOrReuseBrowseTarget(entry);
if (!target) throw new Error('No browser tab available');
const session = await pageSession(target);
try {
return await fn(session, target, entry);
} finally {
session.close();
}
}
async function actionStatus() {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget);
if (!target) return output({ status: 'no_player', msg: 'QQ Music player not open.' });
const session = await pageSession(target);
try {
const result = await session.evaluate(`
(function() {
const nameEl = document.querySelector('.player_music__name a, .player_music__name, .mod_player_song__name a');
const artistEl = document.querySelector('.player_music__artist a, .player_music__artist, .mod_player_song__artist a');
const timeEl = document.querySelector('.player_music__time_now');
const durationEl = document.querySelector('.player_music__time_max, .player_music__duration');
const playBtn = document.querySelector('.btn_big_play');
const isPlaying = playBtn ? playBtn.classList.contains('btn_big_play--pause') : null;
const activeSong = document.querySelector('.songlist__item--active .songlist__songname_txt a[title]');
const activeArtist = document.querySelector('.songlist__item--active .songlist__artist a');
return JSON.stringify({
song: (nameEl ? nameEl.textContent.trim() : '') || (activeSong ? String(activeSong.title || '').trim() : ''),
artist: (artistEl ? artistEl.textContent.trim() : '') || (activeArtist ? String(activeArtist.title || '').trim() : ''),
time: timeEl ? timeEl.textContent.trim() : '',
duration: durationEl ? durationEl.textContent.trim() : '',
isPlaying,
status: isPlaying === true ? 'playing' : isPlaying === false ? 'paused' : 'unknown'
});
})()
`);
output(JSON.parse(result));
} finally {
session.close();
}
}
async function actionPlay() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
if (!btn.classList.contains('btn_big_play--pause')) btn.click();
return JSON.stringify({ ok: true, action: btn.classList.contains('btn_big_play--pause') ? 'already_playing' : 'resumed' });
})()
`);
output(JSON.parse(result));
});
}
async function actionPause() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
if (btn.classList.contains('btn_big_play--pause')) btn.click();
return JSON.stringify({ ok: true, action: btn.classList.contains('btn_big_play--pause') ? 'already_playing' : 'paused' });
})()
`);
output(JSON.parse(result));
});
}
async function actionToggle() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_play');
if (!btn) return JSON.stringify({ ok: false, msg: 'Play button not found' });
const wasPlaying = btn.classList.contains('btn_big_play--pause');
btn.click();
return JSON.stringify({ ok: true, action: wasPlaying ? 'pause' : 'play' });
})()
`);
output(JSON.parse(result));
});
}
async function actionNext() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_next, [class*=btn_next], [title="下一首"]');
if (btn) { btn.click(); return JSON.stringify({ ok: true, action: 'next' }); }
const btns = document.querySelectorAll('.player_music__btn');
for (const b of btns) {
if (b.title && b.title.includes('下一首')) { b.click(); return JSON.stringify({ ok: true, action: 'next' }); }
}
return JSON.stringify({ ok: false, msg: 'Next button not found' });
})()
`);
output(JSON.parse(result));
});
}
async function actionPrev() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.btn_big_prev, [class*=btn_prev], [title="上一首"]');
if (btn) { btn.click(); return JSON.stringify({ ok: true, action: 'prev' }); }
const btns = document.querySelectorAll('.player_music__btn');
for (const b of btns) {
if (b.title && b.title.includes('上一首')) { b.click(); return JSON.stringify({ ok: true, action: 'prev' }); }
}
return JSON.stringify({ ok: false, msg: 'Prev button not found' });
})()
`);
output(JSON.parse(result));
});
}
async function actionSearch(keyword, type = 'song') {
await withBrowse(async session => {
const typeMap = { song: 'song', artist: 'singer', album: 'album' };
const t = typeMap[type] || 'song';
const url = `https://y.qq.com/n/ryqq/search?w=encodeURIComponent(String(keyword || '').trim())&t=t`;
await session.send('Page.navigate', { url });
await sleep(PAGE_WAIT_MS);
if (type === 'artist') {
const result = await session.evaluate(`
(function() {
const artistLinks = document.querySelectorAll('.singer_list__item a, .search_result__singer a, .mod_singer_list a');
if (artistLinks.length > 0) {
const href = artistLinks[0].href || '';
const name = String(artistLinks[0].textContent || '').trim();
return JSON.stringify({ found: true, href, name });
}
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (items.length > 0) {
const nameEl = items[0].querySelector('.songlist__songname_txt a[title]');
const playBtn = items[0].querySelector('.list_menu__play');
if (playBtn) playBtn.click(); else items[0].dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ found: true, played: nameEl ? String(nameEl.title || '').trim() : '', method: 'first_song' });
}
return JSON.stringify({ found: false });
})()
`);
output(JSON.parse(result));
return;
}
if (type === 'album') {
const result = await session.evaluate(`
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No results' });
const item = items[0];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song: nameEl ? String(nameEl.title || '').trim() : '', artist: artistEl ? String(artistEl.title || '').trim() : '' });
})()
`);
output(JSON.parse(result));
return;
}
const result = await session.evaluate(songQueryJS(keyword));
output(JSON.parse(result));
});
}
async function actionPlayLiked(random = false) {
await withBrowse(async session => {
await session.send('Page.navigate', { url: 'https://y.qq.com/n/ryqq_v2/profile/like/song' });
await sleep(PAGE_WAIT_MS);
const result = await session.evaluate(random ? firstVisibleSongJS() : `
(function() {
const items = Array.from(document.querySelectorAll('.songlist__item'));
if (!items.length) return JSON.stringify({ ok: false, msg: 'No liked songs found' });
const item = items[0];
const nameEl = item.querySelector('.songlist__songname_txt a[title]');
const artistEl = item.querySelector('.songlist__artist a');
const playBtn = item.querySelector('.list_menu__play');
if (playBtn) playBtn.click(); else item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
return JSON.stringify({ ok: true, song: nameEl ? String(nameEl.title || '').trim() : '', artist: artistEl ? String(artistEl.title || '').trim() : '', index: 0, total: items.length });
})()
`);
output(JSON.parse(result));
});
}
async function actionPlayPlaylist(playlistId) {
await withBrowse(async session => {
await session.send('Page.navigate', { url: `https://y.qq.com/n/ryqq/playlist/encodeURIComponent(String(playlistId || '').trim())` });
await sleep(PAGE_WAIT_MS);
const result = await session.evaluate(playlistPlayJS());
output(JSON.parse(result));
});
}
async function actionLike() {
await withPlayer(async session => {
const result = await session.evaluate(`
(function() {
const btn = document.querySelector('.player_music__btn_like, [class*=btn_like], [title="我喜欢"], [title="收藏"]');
if (btn) { btn.click(); return JSON.stringify({ ok: true, action: 'liked' }); }
const all = document.querySelectorAll('a, button, i, span');
for (const el of all) {
if (el.title && (el.title.includes('喜欢') || el.title.includes('收藏'))) {
el.click();
return JSON.stringify({ ok: true, action: 'liked', title: el.title });
}
}
return JSON.stringify({ ok: false, msg: 'Like button not found' });
})()
`);
output(JSON.parse(result));
});
}
async function actionScreenshot(pathArg) {
const entry = await discoverEndpoint();
const target = firstTarget(pageTargets(entry), isPlayerTarget) || firstTarget(pageTargets(entry), isBrowseTarget);
if (!target) return output({ error: 'No QQ Music tab found.' });
const session = await pageSession(target);
try {
await sleep(1000);
const result = await session.send('Page.captureScreenshot', { format: 'png' });
const outPath = pathArg || SCREENSHOT_PATH;
const buf = Buffer.from(result.data, 'base64');
fs.writeFileSync(outPath, buf);
output({ ok: true, path: outPath, bytes: buf.length });
} finally {
session.close();
}
}
function printHelp() {
output({
usage: 'node qq-music-ctl.js <action> [args...]',
actions: ['play','pause','toggle','next','prev','status','search <keyword>','search-artist <artist>','search-album <album>','play-liked','play-liked-random','play-playlist <id>','like','screenshot [path]','tabs','init'],
});
}
async function main() {
const action = process.argv[2];
const args = process.argv.slice(3);
if (!action || action === '--help' || action === '-h') {
return printHelp();
}
switch (action) {
case 'play': return actionPlay();
case 'pause': return actionPause();
case 'toggle': return actionToggle();
case 'next': return actionNext();
case 'prev': return actionPrev();
case 'status': return actionStatus();
case 'search': return actionSearch(args.join(' '), 'song');
case 'search-artist': return actionSearch(args.join(' '), 'artist');
case 'search-album': return actionSearch(args.join(' '), 'album');
case 'play-liked': return actionPlayLiked(false);
case 'play-liked-random': return actionPlayLiked(true);
case 'play-playlist': return actionPlayPlaylist(args[0]);
case 'like': return actionLike();
case 'screenshot': return actionScreenshot(args[0]);
case 'tabs': return actionTabs();
case 'init': return actionInit();
default:
return printHelp();
}
}
main().catch(err => {
output({ error: err.message || String(err) });
process.exit(1);
});
通过 NVIDIA NIM API 或 SiliconFlow API 生成图片。支持 Kolors (快手可图)、Qwen-Image (通义千问)、flux.2-klein-4b 等模型。当用户要求"生成图片"、"画一张图"、"AI绘图"或类似表达时调用。支持中文提示词,返回图片文件路径。
---
name: ai-photo-pro
version: 2.0.0
description: 通过 NVIDIA NIM API 或 SiliconFlow API 生成图片。支持 Kolors (快手可图)、Qwen-Image (通义千问)、flux.2-klein-4b 等模型。当用户要求"生成图片"、"画一张图"、"AI绘图"或类似表达时调用。支持中文提示词,返回图片文件路径。
---
# AiPhotoPro - AI 图片生成工具
支持双引擎:**NVIDIA NIM API**(flux.2-klein-4b)和 **SiliconFlow API**(Kolors / Qwen-Image)。
## 调用方式
### 命令行(推荐)
```bash
# SiliconFlow - 可图 Kolors(默认)
python /home/ubuntu/.openclaw/skills/ai-photo-pro/scripts/siliconflow_main.py "<提示词>" ["<负面提示词>"]
# SiliconFlow - 通义千问 Qwen-Image(付费模型,建议按需选取)
python /home/ubuntu/.openclaw/skills/ai-photo-pro/scripts/siliconflow_main.py "<提示词>" ["<负面提示词>"] --model Qwen/Qwen-Image
# NVIDIA NIM API - flux.2-klein-4b
python /home/ubuntu/.openclaw/skills/ai-photo-pro/scripts/nvid_main.py "<提示词>"
```
### Python 导入
```python
import sys
sys.path.insert(0, '/home/ubuntu/.openclaw/skills/ai-photo-pro/scripts')
# SiliconFlow
from siliconflow_main import generate_png
img_list = generate_png(model="Kwai-Kolors/Kolors", base_str="<提示词>", negative_prompt="<负面提示词>")
# NVIDIA
from nvid_main import run_pngvidapi
img_path = run_pngvidapi(model="flux.2-klein-4b", base_str="<提示词>")
```
## 参数说明
### SiliconFlow `generate_png()`
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `model` | string | ❌ | 模型名,默认 `Kwai-Kolors/Kolors`,可选 `Qwen/Qwen-Image`(付费模型,建议按需选取) |
| `base_str` | string | ✅ | 中文提示词 |
| `negative_prompt` | string | ❌ | 负面提示词,可空 |
| `batch_size` | int | ❌ | 批量大小,默认 1 |
| `num_inference_steps` | int | ❌ | 推理步骤数,默认 20 |
| `guidance_scale` | float | ❌ | 提示词匹配度,默认 2.5 |
### NVIDIA `run_pngvidapi()`
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `model` | string | ✅ | 固定填 `flux.2-klein-4b` |
| `base_str` | string | ✅ | 中文提示词 |
## 输出
- 图片保存路径:`/home/ubuntu/.openclaw/skills/ai-photo-pro/scripts/img_data/<model>_<timestamp>.png`
- 函数返回值为图片路径列表
## API Key 配置
首次使用需配置 API Key,运行交互式配置脚本:
```bash
python /home/ubuntu/.openclaw/skills/ai-photo-pro/scripts/config_json.py
```
或手动写入 `config.json`(位于 `scripts/` 目录):
```json
{
"NVID": "nvapi-你的NVID密钥",
"SILICONFLOW": "sk-你的SiliconFlow密钥"
}
```
### 获取 Key
- **NVID API Key**: https://nim.nvidia.com/ 注册获取
- **SiliconFlow API Key**: https://cloud.siliconflow.cn/i/IOo0eaWy 注册获取
## 示例提示词
**人物:**
```
一位美丽的短发东亚女性坐在高层公寓的落地窗前,身穿紧身的白色衬衫,(光线是午后柔和的定向自然光,在人物身上形成优美的明暗轮廓),脸上带着温暖而亲密的微笑,皮肤毛孔清晰,虹膜清晰锐利
```
**物体/场景:**
```
一个小苹果,红彤彤的,挂在绿叶树枝上,阳光照射,背景是模糊的果园,摄影风格,高清细节
```
**风格化:**
```
赛博朋克城市夜景,霓虹灯光,雨后街道,反射,高对比度,电影感
```
## 注意事项
- SiliconFlow 默认尺寸 1024×1024,steps=20
- NVIDIA 默认尺寸 1024×1024,steps=4(更快)
- 生成失败 SiliconFlow 会抛出异常;NVIDIA 会自动重试最多 5 次
- 图片路径通过函数返回值传递,方便 agent 捕获并发送
FILE:scripts/config_json.py
import json
import os
# 程序启动地址
ORGPATH = os.path.dirname(os.path.abspath(__file__))
os.chdir(ORGPATH)
config_file = "config.json"
a = input(
"您需要配置API Key才能使用本工具。\n\n"
"1. 配置NVID API Key\n"
"2. 配置硅基流动API Key\n"
"3. 退出\n"
"请输入您的选择: 选择指定数字,其他输入退出程序"
)
config_data = {}
if os.path.exists(config_file):
with open(config_file, "r") as f:
config_data = json.load(f)
if a == "1":
TOKEN_KEY = input("请输入您的NVID API Key: ")
config_data["NVID"] = TOKEN_KEY
with open(config_file, "w") as f:
json.dump(config_data, f, indent=2)
print("配置文件已创建")
elif a == "2":
TOKEN_KEY = input("请输入您的硅基流动API Key: ")
config_data["SILICONFLOW"] = TOKEN_KEY
with open(config_file, "w") as f:
json.dump(config_data, f, indent=2)
print("配置文件已创建")
else:
exit(1)
FILE:scripts/nvid_main.py
import requests
import base64
from io import BytesIO
from PIL import Image
import pandas as pd
import requests
import json
import os
from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception_type
# 程序启动地址
ORGPATH = os.path.dirname(os.path.abspath(__file__))
os.chdir(ORGPATH)
config_file = "config.json"
count_api = 0
if not os.path.exists(config_file):
raise FileNotFoundError("配置文件 config.json 不存在,请运行 config_json.py 创建配置文件")
else:
try:
with open(config_file, "r") as f:
config = json.load(f)
TOKEN_KEY = config["NVID"]
except:
raise ValueError("配置文件 config.json 至少应该存在NVID API Key配置,请运行 config_json.py 创建配置文件")
@retry(
stop=stop_after_attempt(5), # 重试3次后停止
wait=wait_fixed(2), # 每次重试间隔60秒
retry=retry_if_exception_type((requests.exceptions.HTTPError, requests.exceptions.RequestException)) # 仅针对特定异常重试
)
def run_pngvidapi(model,base_str=None):
invoke_url = f"https://ai.api.nvidia.com/v1/genai/black-forest-labs/{model}"
headers = {
"Authorization": f"Bearer {TOKEN_KEY}",
"Accept": "application/json",
}
payload = {
"prompt": base_str,
"width": 1024,
"height": 1024,
# "seed": 0,
"steps": 4
}
try:
response = requests.post(invoke_url, headers=headers, json=payload)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
print(f"进行尝试重启中...HTTP错误: {e}")
raise requests.exceptions.HTTPError(e)
response_body = response.json()
try:
# 解码 base64 数据
image_data = base64.b64decode(response_body['artifacts'][0]['base64'])
# 从字节数据创建图片
image = Image.open(BytesIO(image_data))
# 保存为 PNG
import time
timestamp = int(time.time())
image.save(f"{ORGPATH}/img_data/{model.replace('/', '_')}_{timestamp}.png", "PNG")
print(f"图片已保存为 {ORGPATH}/img_data/{model.replace('/', '_')}_{timestamp}.png")
return f"{ORGPATH}/img_data/{model.replace('/', '_')}_{timestamp}.png"
except Exception as e:
# 如果以上字段都不对,先打印响应结构看看
print("API响应结构:", response_body.keys())
# 或者保存整个响应供调试
with open(f"{ORGPATH}/response.json", "w") as f:
import json
json.dump(response_body, f, indent=2)
print("图片生成失败,已将响应保存为 response.json,请检查其中的图片数据字段")
print(f"进行尝试重启中...HTTP错误: {e}")
raise requests.exceptions.HTTPError(e)
def main():
"""命令行入口:通过 sys.argv[1] 传入提示词"""
import sys
if len(sys.argv) < 2:
print("用法: python nvid_main.py <提示词>")
sys.exit(1)
prompt = sys.argv[1]
img = run_pngvidapi(model="flux.2-klein-4b", base_str=prompt)
print(img)
return img
if __name__ == "__main__":
main()
FILE:scripts/siliconflow_main.py
import sys
import requests
import os
from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception_type
import numpy as np
import time
import json
ORGPATH = os.path.dirname(os.path.abspath(__file__))
os.chdir(ORGPATH)
config_file = "config.json"
count_api = 0
if not os.path.exists(config_file):
raise FileNotFoundError("配置文件 config.json 不存在,请运行 config_json.py 创建配置文件")
else:
try:
with open(config_file, "r") as f:
config = json.load(f)
TOKEN_KEY = config["SILICONFLOW"]
except:
raise ValueError("配置文件 config.json 至少应该存在SiliconFlow API Key配置,请运行 config_json.py 创建配置文件")
def post_gui(payload,key):
url = "https://api.siliconflow.cn/v1/images/generations"
try:
headers = {
"Authorization": key,
"Content-Type": "application/json"
}
response = requests.request("POST", url, json=payload, headers=headers)
return response.json()
except Exception as e:
raise ValueError(f"请求失败:{e} {response}")
def nan_check(value):
if value==np.nan or str(value)=="" or str(value)=="None"or value=="" or value is None:
return True
else:
return False
def generate_png(model="Kwai-Kolors/Kolors",batch_size=1,num_inference_steps=20,guidance_scale=2.5,base_str=None,negative_prompt=None):
"""
生成图片
:param model: 模型名称,可选(Kwai-Kolors/Kolors、Qwen/Qwen-Image),默认值为Kwai-Kolors/Kolors
:param batch_size: 批量大小,推荐值为1
:param num_inference_steps: 推理步骤数,推荐值为20
:param guidance_scale: 提示词之间的匹配度,推荐值为2.5
:param base_str: 提示词,不能为空
:param negative_prompt: 负提示词,可以为空
:return: 图片列表
"""
if nan_check(base_str):
raise ValueError("提示词不能为空")
payload = {
"model": model,
"prompt": base_str,
"image_size": "1024x1024",
"batch_size": batch_size,
"num_inference_steps": num_inference_steps, #推理步骤数
"guidance_scale": guidance_scale, #提示词之间的匹配度
}
if not nan_check(negative_prompt):
payload["negative_prompt"] = negative_prompt
try:
response = post_gui(payload,key=f"Bearer {TOKEN_KEY}")
img_list = []
for i in range(len(response["images"])):
image_url = response["images"][i]["url"]
# 下载图片
response_png = requests.get(image_url)
# 根据时间编码命名图片
timestamp = int(time.time())
with open(f"{ORGPATH}/img_data/siliconflow_{model.replace('/', '_')}_{timestamp}.png", "wb") as f:
f.write(response_png.content)
img_list.append(f"{ORGPATH}/img_data/siliconflow_{model.replace('/', '_')}_{timestamp}.png")
print(f"图片已保存在: {img_list}")
return img_list
except Exception as e:
print(f"生成图片失败:{e} 重试中...",time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())))
raise ValueError(f"生成图片失败:{e} {response}")
def main():
"""
命令行入口,供其他 agent 调用
用法: python siliconflow_main.py "<提示词>" ["<负面提示词>"] [--model <模型名>]
负面提示词可省略
模型可选: Kwai-Kolors/Kolors (默认), Qwen/Qwen-Image
"""
if len(sys.argv) < 2:
print("用法: python siliconflow_main.py <提示词> [负面提示词] [--model <模型名>]")
sys.exit(1)
model = "Kwai-Kolors/Kolors"
base_str = None
negative_prompt = None
i = 1
while i < len(sys.argv):
if sys.argv[i] == "--model" and i + 1 < len(sys.argv):
model = sys.argv[i + 1]
i += 2
elif base_str is None:
base_str = sys.argv[i]
i += 1
else:
negative_prompt = sys.argv[i]
i += 1
if base_str is None:
print("用法: python siliconflow_main.py <提示词> [负面提示词] [--model <模型名>]")
sys.exit(1)
return generate_png(model=model, base_str=base_str, negative_prompt=negative_prompt)
if __name__ == "__main__":
main()流式视频处理工具集 - 压缩、封面提取、音频转换,无需下载完整视频
---
name: ym-meidatoolkit
version: 1.1.0
description: 流式视频处理工具集 - 压缩、封面提取、音频转换,无需下载完整视频
author: your_name
tags:
- video
- compression
- thumbnail
- audio
- streaming
- ffmpeg
categories:
- media
- utility
clawhub:
entrypoint: python run.py
runtime: python3
http_port: 8080
---
# Video Streaming Toolkit
## 概述
一个高性能的流式视频处理 Skill,**无需下载完整视频文件**即可完成:
- ✅ **视频压缩** - 保持清晰度,体积可压缩至 1/10,根据情况输出多个尺寸小尺寸视频可供选择
- ✅ **封面提取** - 任意时间点或帧号提取封面
- ✅ **音频提取** - 转成 MP3 / WAV / AAC / M4A 格式
所有操作均采用**流式处理**,边下载边处理,大幅节省时间和磁盘空间。
---
## 快速开始
### 1. 安装依赖
```bash
pip install -r requirements.txt
FILE:video_compressor.py
"""
流式视频压缩 - 无需下载完整文件
"""
import subprocess
import requests
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
def get_remote_file_size(url: str) -> int:
"""获取远程文件大小(不下载)"""
try:
response = requests.head(url, timeout=10)
if 'content-length' in response.headers:
return int(response.headers['content-length'])
except Exception as e:
logger.warning(f"获取文件大小失败: {e}")
return 0
def compress_video_streaming(
video_url: str,
output_path: str = None,
target_ratio: float = 0.1,
crf: int = 24,
preset: str = 'veryfast'
) -> dict:
"""
流式压缩视频 - ffmpeg 直接处理 URL
Args:
video_url: 视频 URL
output_path: 输出路径(可选)
target_ratio: 目标体积比例(用于检查,不自动重试)
crf: CRF值(18-28,越大体积越小)
preset: 编码预设(ultrafast/veryfast/fast/medium/slow)
Returns:
{'status': 'success', 'output_path': str, 'original_size_mb': float,
'new_size_mb': float, 'ratio': float}
"""
if output_path is None:
output_path = f"compressed_{Path(video_url).stem}.mp4"
# 获取原始文件大小
original_size = get_remote_file_size(video_url)
# ffmpeg 流式压缩命令
cmd = [
'ffmpeg',
'-i', video_url, # 直接输入 URL
'-c:v', 'libx264',
'-preset', preset,
'-crf', str(crf),
'-g', '30',
'-keyint_min', '30',
'-sc_threshold', '0',
'-bf', '0',
'-refs', '1',
'-vsync', 'cfr',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
'-y',
output_path
]
logger.info(f"开始流式压缩: {video_url[:80]}...")
logger.info(f"输出文件: {output_path}")
try:
# 执行压缩,实时显示进度
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
# 实时打印 ffmpeg 进度
last_progress = ""
for line in process.stderr:
if 'frame=' in line and 'speed=' in line:
# 提取进度信息
progress = line.strip()
if progress != last_progress:
logger.info(f"进度: {progress}")
last_progress = progress
# 等待完成
return_code = process.wait()
if return_code != 0:
return {
'status': 'error',
'message': f'ffmpeg 错误,返回码: {return_code}'
}
# 检查输出文件
if not Path(output_path).exists():
return {'status': 'error', 'message': '输出文件未生成'}
new_size = Path(output_path).stat().st_size
actual_ratio = new_size / original_size if original_size > 0 else 0
logger.info(f"压缩完成: {original_size/(1024*1024):.2f}MB -> {new_size/(1024*1024):.2f}MB, 比例: {actual_ratio:.2f}")
return {
'status': 'success',
'output_path': output_path,
'original_size_mb': round(original_size / (1024 * 1024), 2) if original_size else 0,
'new_size_mb': round(new_size / (1024 * 1024), 2),
'ratio': round(actual_ratio, 3),
'crf_used': crf,
'streaming': True
}
except subprocess.TimeoutExpired:
return {'status': 'error', 'message': '压缩超时(300秒)'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
def compress_with_adaptive_crf(
video_url: str,
output_path: str = None,
target_ratio: float = 0.1,
max_attempts: int = 3
) -> dict:
"""
自适应 CRF 压缩 - 自动调整参数直到达到目标比例
"""
crf_values = [24, 26, 28, 30] # 依次尝试
best_result = None
for i, crf in enumerate(crf_values[:max_attempts]):
logger.info(f"尝试 {i+1}/{max_attempts}: CRF={crf}")
result = compress_video_streaming(
video_url=video_url,
output_path=output_path if i == 0 else f"{output_path}.try{i+1}.mp4",
target_ratio=target_ratio,
crf=crf
)
if result['status'] != 'success':
continue
if result['ratio'] <= target_ratio:
# 达到目标,移动文件到最终位置
if i > 0 and output_path:
import shutil
shutil.move(result['output_path'], output_path)
result['output_path'] = output_path
return result
best_result = result
# 未达到目标,返回最好的结果
if best_result:
logger.warning(f"未达到目标比例 {target_ratio},最佳比例: {best_result['ratio']}")
if best_result['output_path'] != output_path and output_path:
import shutil
shutil.move(best_result['output_path'], output_path)
best_result['output_path'] = output_path
return best_result
return {'status': 'error', 'message': '所有压缩尝试均失败'}
FILE:run.py
#!/usr/bin/env python3
"""
ClawHub Skill 统一入口 - 流式视频处理
支持:
1. 压缩: ffmpeg 流式处理,无需下载
2. 封面: 部分下载,只取需要的帧
3. 音频: 流式提取,转 MP3/WAV
"""
import sys
import json
import argparse
import logging
from pathlib import Path
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 导入模块
from frame_extractor import extract_thumbnail_from_url
from video_compressor import compress_video_streaming, compress_with_adaptive_crf
from audio_extractor import extract_audio_streaming, extract_audio_batch, get_audio_info
def handle_compress(params: dict) -> dict:
"""处理视频压缩请求"""
video_url = params.get('video_url')
if not video_url:
return {'status': 'error', 'message': 'Missing video_url'}
output_path = params.get('output_path')
target_ratio = params.get('target_ratio', 0.1)
adaptive = params.get('adaptive', True)
crf = params.get('crf', 24)
preset = params.get('preset', 'veryfast')
logger.info(f"压缩请求: {video_url[:80]}...")
if adaptive:
result = compress_with_adaptive_crf(
video_url=video_url,
output_path=output_path,
target_ratio=target_ratio,
max_attempts=params.get('max_attempts', 3)
)
else:
result = compress_video_streaming(
video_url=video_url,
output_path=output_path,
target_ratio=target_ratio,
crf=crf,
preset=preset
)
return result
def handle_thumbnail(params: dict) -> dict:
"""处理封面提取请求"""
video_url = params.get('video_url')
if not video_url:
return {'status': 'error', 'message': 'Missing video_url'}
time_seconds = params.get('time_seconds')
frame_number = params.get('frame_number')
if time_seconds is None and frame_number is None:
time_seconds = 0
save_path = params.get('save_path')
resize_width = params.get('resize_width')
quality = params.get('quality', 85)
logger.info(f"封面提取: {video_url[:80]}... time={time_seconds}, frame={frame_number}")
result = extract_thumbnail_from_url(
video_url=video_url,
time_seconds=time_seconds,
frame_number=frame_number,
save_path=save_path,
resize_width=resize_width,
quality=quality
)
return result
def handle_audio(params: dict) -> dict:
"""处理音频提取请求"""
video_url = params.get('video_url')
if not video_url:
return {'status': 'error', 'message': 'Missing video_url'}
output_path = params.get('output_path')
audio_format = params.get('format', 'mp3') # mp3, wav, aac, m4a
audio_bitrate = params.get('bitrate', '128k')
sample_rate = params.get('sample_rate', 44100)
channels = params.get('channels', 2)
start_time = params.get('start_time')
duration = params.get('duration')
# 格式验证
if audio_format not in ['mp3', 'wav', 'aac', 'm4a']:
return {'status': 'error', 'message': f'Unsupported format: {audio_format}. Supported: mp3, wav, aac, m4a'}
logger.info(f"音频提取: {video_url[:80]}... format={audio_format}, bitrate={audio_bitrate}")
result = extract_audio_streaming(
video_url=video_url,
output_path=output_path,
audio_format=audio_format,
audio_bitrate=audio_bitrate,
sample_rate=sample_rate,
channels=channels,
start_time=start_time,
duration=duration
)
return result
def handle_audio_batch(params: dict) -> dict:
"""批量音频提取"""
videos = params.get('videos', [])
if not videos:
return {'status': 'error', 'message': 'Missing videos list'}
output_dir = params.get('output_dir', './audio_output')
audio_format = params.get('format', 'mp3')
audio_bitrate = params.get('bitrate', '128k')
sample_rate = params.get('sample_rate', 44100)
logger.info(f"批量音频提取: {len(videos)} 个视频, 格式={audio_format}")
result = extract_audio_batch(
videos=videos,
output_dir=output_dir,
audio_format=audio_format,
audio_bitrate=audio_bitrate,
sample_rate=sample_rate
)
return result
def handle_audio_info(params: dict) -> dict:
"""获取视频音频流信息"""
video_url = params.get('video_url')
if not video_url:
return {'status': 'error', 'message': 'Missing video_url'}
logger.info(f"获取音频信息: {video_url[:80]}...")
result = get_audio_info(video_url)
return result
def handle_batch(params: dict) -> dict:
"""批量处理(压缩/封面)"""
videos = params.get('videos', [])
action = params.get('action', 'thumbnail')
if not videos:
return {'status': 'error', 'message': 'Missing videos list'}
results = []
for i, video in enumerate(videos):
logger.info(f"批量处理 [{i+1}/{len(videos)}]")
if action == 'compress':
res = handle_compress(video)
elif action == 'audio':
res = handle_audio(video)
else:
res = handle_thumbnail(video)
results.append(res)
success_count = sum(1 for r in results if r.get('status') == 'success')
return {
'status': 'success',
'total': len(results),
'success': success_count,
'failed': len(results) - success_count,
'results': results
}
def handle_info(params: dict) -> dict:
"""获取视频信息"""
video_url = params.get('video_url')
if not video_url:
return {'status': 'error', 'message': 'Missing video_url'}
from frame_extractor import RemoteVideoFrameExtractor
try:
extractor = RemoteVideoFrameExtractor(video_url, timeout=30)
info = extractor.get_video_info()
info['file_size_mb'] = round(extractor.file_size / (1024 * 1024), 2)
return {'status': 'success', 'info': info}
except Exception as e:
return {'status': 'error', 'message': str(e)}
# Action 映射
ACTIONS = {
'compress': handle_compress,
'thumbnail': handle_thumbnail,
'audio': handle_audio,
'audio_batch': handle_audio_batch,
'audio_info': handle_audio_info,
'batch': handle_batch,
'info': handle_info
}
def run_cli():
"""命令行模式"""
parser = argparse.ArgumentParser(description='Video Streaming Skill')
parser.add_argument('--input', '-i', required=True, help='Input JSON string or file path')
parser.add_argument('--action', '-a', choices=ACTIONS.keys(), help='Action to perform')
args = parser.parse_args()
try:
if Path(args.input).exists():
with open(args.input, 'r') as f:
params = json.load(f)
else:
params = json.loads(args.input)
except json.JSONDecodeError:
params = {'action': args.action} if args.action else {}
for pair in args.input.split():
if '=' in pair:
k, v = pair.split('=', 1)
params[k] = v
action = params.get('action')
if not action and args.action:
action = args.action
if not action or action not in ACTIONS:
print(json.dumps({'status': 'error', 'message': f'Invalid action: {action}'}))
sys.exit(1)
result = ACTIONS[action](params)
print(json.dumps(result, ensure_ascii=False, indent=2))
def run_http_server(host='0.0.0.0', port=8080):
"""HTTP 服务模式"""
try:
from flask import Flask, request, jsonify
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
@app.route('/health', methods=['GET'])
def health():
return jsonify({'status': 'ok', 'skill': 'video-streaming-toolkit'})
@app.route('/skill/compress', methods=['POST'])
def compress():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
return jsonify(handle_compress(data))
@app.route('/skill/thumbnail', methods=['POST'])
def thumbnail():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
return jsonify(handle_thumbnail(data))
@app.route('/skill/audio', methods=['POST'])
def audio():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
return jsonify(handle_audio(data))
@app.route('/skill/audio_batch', methods=['POST'])
def audio_batch():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
return jsonify(handle_audio_batch(data))
@app.route('/skill/audio_info', methods=['POST'])
def audio_info():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
return jsonify(handle_audio_info(data))
@app.route('/skill/batch', methods=['POST'])
def batch():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
return jsonify(handle_batch(data))
@app.route('/skill/info', methods=['POST'])
def info():
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
return jsonify(handle_info(data))
logger.info(f"Starting HTTP server on {host}:{port}")
app.run(host=host, port=port, threaded=True)
except ImportError:
logger.error("Flask not installed. Run: pip install flask flask-cors")
sys.exit(1)
if __name__ == '__main__':
if '--serve' in sys.argv or '-s' in sys.argv:
run_http_server()
else:
run_cli()
FILE:requirements.txt
requests>=2.28.0
opencv-python>=4.8.0
numpy>=1.24.0
aiohttp>=3.8.0
FILE:audio_extractor.py
"""
流式音频提取 - 从远程视频直接提取音频,无需下载完整视频
支持格式: MP3, WAV, AAC, M4A
"""
import subprocess
import requests
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
def get_remote_file_size(url: str) -> int:
"""获取远程文件大小(不下载)"""
try:
response = requests.head(url, timeout=10)
if 'content-length' in response.headers:
return int(response.headers['content-length'])
except Exception as e:
logger.warning(f"获取文件大小失败: {e}")
return 0
def extract_audio_streaming(
video_url: str,
output_path: str = None,
audio_format: str = 'mp3', # mp3, wav, aac, m4a
audio_bitrate: str = '128k', # 128k, 192k, 320k
sample_rate: int = 44100, # 44100, 48000
channels: int = 2, # 1=mono, 2=stereo
start_time: float = None, # 开始时间(秒)
duration: float = None, # 持续时间(秒)
) -> dict:
"""
流式提取音频 - ffmpeg 直接从 URL 提取,无需下载视频
Args:
video_url: 视频 URL
output_path: 输出路径(可选)
audio_format: 音频格式 (mp3, wav, aac, m4a)
audio_bitrate: 音频比特率 (128k, 192k, 320k)
sample_rate: 采样率 (44100, 48000)
channels: 声道数 (1=单声道, 2=立体声)
start_time: 开始时间(秒),提取片段
duration: 持续时间(秒)
Returns:
{
'status': 'success',
'output_path': str,
'format': str,
'size_mb': float,
'duration_sec': float,
'streaming': True
}
"""
# 自动生成输出路径
if output_path is None:
from urllib.parse import urlparse
video_name = Path(urlparse(video_url).path).stem
output_path = f"{video_name}.{audio_format}"
# 构建 ffmpeg 命令
cmd = ['ffmpeg', '-i', video_url]
# 片段提取参数
if start_time is not None:
cmd.extend(['-ss', str(start_time)])
if duration is not None:
cmd.extend(['-t', str(duration)])
# 音频参数
if audio_format == 'mp3':
cmd.extend([
'-c:a', 'libmp3lame',
'-b:a', audio_bitrate,
'-ar', str(sample_rate),
'-ac', str(channels)
])
elif audio_format == 'wav':
cmd.extend([
'-c:a', 'pcm_s16le', # WAV 无损格式
'-ar', str(sample_rate),
'-ac', str(channels)
])
elif audio_format == 'aac':
cmd.extend([
'-c:a', 'aac',
'-b:a', audio_bitrate,
'-ar', str(sample_rate),
'-ac', str(channels)
])
elif audio_format == 'm4a':
cmd.extend([
'-c:a', 'aac',
'-b:a', audio_bitrate,
'-ar', str(sample_rate),
'-ac', str(channels),
'-movflags', '+faststart'
])
else:
return {'status': 'error', 'message': f'Unsupported format: {audio_format}'}
# 输出参数
cmd.extend(['-y', output_path])
logger.info(f"开始流式音频提取: {video_url[:80]}...")
logger.info(f"输出格式: {audio_format}, 比特率: {audio_bitrate}, 采样率: {sample_rate}")
logger.info(f"命令: {' '.join(cmd[:5])}...")
try:
# 执行提取
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
# 实时显示进度
last_progress = ""
duration_sec = 0
for line in process.stderr:
if 'Duration:' in line and duration_sec == 0:
# 解析总时长
import re
match = re.search(r'Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})', line)
if match:
h, m, s = match.groups()
duration_sec = int(h) * 3600 + int(m) * 60 + float(s)
logger.info(f"视频总时长: {duration_sec:.2f}秒")
if 'size=' in line and 'time=' in line:
progress = line.strip()
if progress != last_progress:
logger.info(f"进度: {progress}")
last_progress = progress
return_code = process.wait()
if return_code != 0:
return {
'status': 'error',
'message': f'ffmpeg 错误,返回码: {return_code}'
}
# 检查输出文件
if not Path(output_path).exists():
return {'status': 'error', 'message': '输出文件未生成'}
file_size = Path(output_path).stat().st_size
# 获取提取的音频时长
audio_duration = duration_sec if duration_sec > 0 else None
logger.info(f"音频提取完成: {file_size/(1024*1024):.2f}MB")
return {
'status': 'success',
'output_path': output_path,
'format': audio_format,
'size_mb': round(file_size / (1024 * 1024), 2),
'duration_sec': audio_duration,
'bitrate': audio_bitrate,
'sample_rate': sample_rate,
'channels': channels,
'streaming': True
}
except subprocess.TimeoutExpired:
return {'status': 'error', 'message': '音频提取超时(300秒)'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
def extract_audio_batch(
videos: list,
output_dir: str = './audio_output',
audio_format: str = 'mp3',
audio_bitrate: str = '128k',
sample_rate: int = 44100
) -> dict:
"""
批量提取音频
Args:
videos: 视频列表 [{'url': 'https://...', 'name': 'video1'}, ...]
output_dir: 输出目录
audio_format: 音频格式
audio_bitrate: 比特率
sample_rate: 采样率
Returns:
批量结果
"""
import os
os.makedirs(output_dir, exist_ok=True)
results = []
success_count = 0
for i, video in enumerate(videos):
url = video.get('url')
name = video.get('name', f'audio_{i+1}')
if not url:
results.append({'name': name, 'status': 'error', 'message': 'Missing url'})
continue
output_path = os.path.join(output_dir, f"{name}.{audio_format}")
logger.info(f"批量处理 [{i+1}/{len(videos)}]: {name}")
result = extract_audio_streaming(
video_url=url,
output_path=output_path,
audio_format=audio_format,
audio_bitrate=audio_bitrate,
sample_rate=sample_rate
)
result['name'] = name
results.append(result)
if result.get('status') == 'success':
success_count += 1
return {
'status': 'success',
'total': len(videos),
'success': success_count,
'failed': len(videos) - success_count,
'results': results
}
def get_audio_info(video_url: str) -> dict:
"""
获取视频的音频流信息(不下载)
Args:
video_url: 视频 URL
Returns:
{
'has_audio': bool,
'audio_codec': str,
'audio_bitrate': str,
'sample_rate': int,
'channels': int,
'language': str
}
"""
import re
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', video_url]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return {'status': 'error', 'message': 'ffprobe failed'}
import json
data = json.loads(result.stdout)
for stream in data.get('streams', []):
if stream.get('codec_type') == 'audio':
return {
'status': 'success',
'has_audio': True,
'audio_codec': stream.get('codec_name', 'unknown'),
'audio_bitrate': stream.get('bit_rate', 'unknown'),
'sample_rate': int(stream.get('sample_rate', 0)) if stream.get('sample_rate') else 0,
'channels': stream.get('channels', 0),
'language': stream.get('tags', {}).get('language', 'unknown')
}
return {'status': 'success', 'has_audio': False, 'message': 'No audio stream found'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
FILE:utils.py
import os
import subprocess
import tempfile
from pathlib import Path
def get_file_size_mb(path: str) -> float:
"""获取文件大小(MB)"""
return Path(path).stat().st_size / (1024 * 1024)
def download_video_to_temp(url: str, timeout: int = 300) -> str:
"""下载视频到临时文件(用于压缩场景)"""
import requests
temp_file = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
temp_path = temp_file.name
temp_file.close()
response = requests.get(url, stream=True, timeout=timeout)
with open(temp_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return temp_path
def cleanup_temp_file(path: str):
"""清理临时文件"""
if path and os.path.exists(path):
os.unlink(path)
FILE:frame_extractor.py
"""
远程视频帧提取服务
支持从远程 URL 按时间/帧号提取视频帧,无需下载完整文件
"""
import requests
import struct
import logging
import os
import tempfile
from typing import Optional, Dict, List
import cv2
import numpy as np
logger = logging.getLogger(__name__)
class RemoteVideoFrameExtractor:
"""远程视频帧提取器 - 通过解析 MP4 结构实现部分下载"""
# MP4 Box 类型常量
BOX_TYPE_MOOV = b'moov'
BOX_TYPE_TRAK = b'trak'
BOX_TYPE_MDIA = b'mdia'
BOX_TYPE_MINF = b'minf'
BOX_TYPE_STBL = b'stbl'
BOX_TYPE_STSD = b'stsd'
BOX_TYPE_STSS = b'stss'
BOX_TYPE_STCO = b'stco'
BOX_TYPE_CO64 = b'co64'
BOX_TYPE_STSZ = b'stsz'
BOX_TYPE_STSC = b'stsc'
def __init__(self, video_url: str, timeout: int = 30):
self.video_url = video_url
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
})
self.file_size = 0
self.width = 0
self.height = 0
self.codec_type = None
self.timescale = 0
self.duration = 0
self.stts = []
self.stss = []
self.stco = []
self.stsz = []
self.stsc = []
self.nal_length_size = 4
self.vps_sps_pps_nalus = []
self._init_video_info()
def _init_video_info(self):
try:
self.file_size = self._get_file_size()
self._find_and_parse_moov()
except Exception as e:
logger.error(f"视频信息解析失败: {e}")
raise
def _get_file_size(self) -> int:
response = self.session.head(self.video_url, timeout=self.timeout)
if 'content-length' in response.headers:
return int(response.headers['content-length'])
response = self.session.get(self.video_url, stream=True, timeout=self.timeout)
return int(response.headers.get('content-length', 0))
def _download_range(self, start: int, end: int) -> bytes:
headers = {'Range': f'bytes={start}-{end}'}
response = self.session.get(self.video_url, headers=headers, timeout=self.timeout)
if response.status_code in [200, 206]:
return response.content
raise Exception(f"HTTP Range 请求失败: {response.status_code}")
def _find_and_parse_moov(self):
pos = 0
probe_size = min(64 * 1024, self.file_size)
probe_data = self._download_range(0, probe_size - 1)
while pos < self.file_size:
if pos + 8 > len(probe_data):
header_bytes = self._download_range(pos, min(pos + 7, self.file_size - 1))
if len(header_bytes) < 8:
break
else:
header_bytes = probe_data[pos:pos + 8]
box_size = struct.unpack('>I', header_bytes[0:4])[0]
box_type = header_bytes[4:8]
if box_size == 1:
if pos + 16 > len(probe_data):
ext_header = self._download_range(pos, min(pos + 15, self.file_size - 1))
else:
ext_header = probe_data[pos:pos + 16]
if len(ext_header) < 16:
break
box_size = struct.unpack('>Q', ext_header[8:16])[0]
if box_size == 0:
box_size = self.file_size - pos
if box_size < 8:
break
if box_type == self.BOX_TYPE_MOOV:
moov_data = self._download_range(pos, pos + box_size - 1)
self._parse_moov(moov_data)
return
pos += box_size
tail_size = min(5 * 1024 * 1024, self.file_size)
tail_data = self._download_range(self.file_size - tail_size, self.file_size - 1)
tail_base_offset = self.file_size - tail_size
scan_pos = 0
while scan_pos < len(tail_data) - 8:
box_size = struct.unpack('>I', tail_data[scan_pos:scan_pos + 4])[0]
box_type = tail_data[scan_pos + 4:scan_pos + 8]
if box_size == 1 and scan_pos + 16 <= len(tail_data):
box_size = struct.unpack('>Q', tail_data[scan_pos + 8:scan_pos + 16])[0]
if box_size < 8:
scan_pos += 1
continue
if box_type == self.BOX_TYPE_MOOV:
actual_offset = tail_base_offset + scan_pos
moov_data = self._download_range(actual_offset, actual_offset + box_size - 1)
self._parse_moov(moov_data)
return
scan_pos += box_size
raise Exception("未找到 moov box")
def _parse_moov(self, moov_data: bytes):
pos = 8
while pos < len(moov_data) - 8:
box_size = struct.unpack('>I', moov_data[pos:pos+4])[0]
if moov_data[pos+4:pos+8] == self.BOX_TYPE_TRAK:
self._parse_trak(moov_data[pos:pos+box_size])
pos += box_size if box_size > 0 else 1
def _parse_trak(self, trak_data: bytes):
is_video = False
mdia_offset, mdia_size = 0, 0
pos = 8
while pos < len(trak_data) - 8:
box_size = struct.unpack('>I', trak_data[pos:pos+4])[0]
if trak_data[pos+4:pos+8] == self.BOX_TYPE_MDIA:
mdia_offset, mdia_size = pos, box_size
m_pos = pos + 8
while m_pos < pos + box_size - 8:
m_size = struct.unpack('>I', trak_data[m_pos:m_pos+4])[0]
if trak_data[m_pos+4:m_pos+8] == b'hdlr':
if trak_data[m_pos+16:m_pos+20] == b'vide':
is_video = True
break
m_pos += m_size if m_size > 0 else 1
pos += box_size if box_size > 0 else 1
if is_video and mdia_size > 0:
self._parse_mdia(trak_data[mdia_offset:mdia_offset+mdia_size])
def _parse_mdia(self, mdia_data: bytes):
pos = 8
while pos < len(mdia_data) - 8:
box_size = struct.unpack('>I', mdia_data[pos:pos+4])[0]
box_type = mdia_data[pos+4:pos+8]
if box_type == b'mdhd':
version = mdia_data[pos+8]
if version == 0:
self.timescale = struct.unpack('>I', mdia_data[pos+20:pos+24])[0]
self.duration = struct.unpack('>I', mdia_data[pos+24:pos+28])[0]
else:
self.timescale = struct.unpack('>I', mdia_data[pos+28:pos+32])[0]
self.duration = struct.unpack('>Q', mdia_data[pos+32:pos+40])[0]
elif box_type == self.BOX_TYPE_MINF:
self._parse_minf(mdia_data[pos:pos+box_size])
pos += box_size if box_size > 0 else 1
def _parse_minf(self, minf_data: bytes):
pos = 8
while pos < len(minf_data) - 8:
box_size = struct.unpack('>I', minf_data[pos:pos+4])[0]
if minf_data[pos+4:pos+8] == self.BOX_TYPE_STBL:
self._parse_stbl(minf_data[pos:pos+box_size])
pos += box_size if box_size > 0 else 1
def _parse_stbl(self, stbl_data: bytes):
pos = 8
while pos < len(stbl_data) - 8:
box_size = struct.unpack('>I', stbl_data[pos:pos+4])[0]
box_type = stbl_data[pos+4:pos+8]
if box_type == self.BOX_TYPE_STSD:
self._parse_stsd(stbl_data[pos:pos+box_size])
elif box_type == self.BOX_TYPE_STSS:
entry_count = struct.unpack('>I', stbl_data[pos+12:pos+16])[0]
for i in range(entry_count):
self.stss.append(struct.unpack('>I', stbl_data[pos+16+i*4:pos+20+i*4])[0])
elif box_type == self.BOX_TYPE_STCO:
entry_count = struct.unpack('>I', stbl_data[pos+12:pos+16])[0]
for i in range(entry_count):
self.stco.append(struct.unpack('>I', stbl_data[pos+16+i*4:pos+20+i*4])[0])
elif box_type == self.BOX_TYPE_CO64:
entry_count = struct.unpack('>I', stbl_data[pos+12:pos+16])[0]
for i in range(entry_count):
self.stco.append(struct.unpack('>Q', stbl_data[pos+16+i*8:pos+24+i*8])[0])
elif box_type == self.BOX_TYPE_STSZ:
sample_size = struct.unpack('>I', stbl_data[pos+12:pos+16])[0]
sample_count = struct.unpack('>I', stbl_data[pos+16:pos+20])[0]
if sample_size == 0:
for i in range(sample_count):
self.stsz.append(struct.unpack('>I', stbl_data[pos+20+i*4:pos+24+i*4])[0])
else:
self.stsz = [sample_size] * sample_count
elif box_type == self.BOX_TYPE_STSC:
entry_count = struct.unpack('>I', stbl_data[pos+12:pos+16])[0]
for i in range(entry_count):
o = pos + 16 + i * 12
self.stsc.append({
'first_chunk': struct.unpack('>I', stbl_data[o:o+4])[0],
'samples_per_chunk': struct.unpack('>I', stbl_data[o+4:o+8])[0]
})
elif box_type == b'stts':
entry_count = struct.unpack('>I', stbl_data[pos+12:pos+16])[0]
for i in range(entry_count):
count = struct.unpack('>I', stbl_data[pos+16+i*8:pos+20+i*8])[0]
delta = struct.unpack('>I', stbl_data[pos+20+i*8:pos+24+i*8])[0]
self.stts.append({'count': count, 'delta': delta})
pos += box_size if box_size > 0 else 1
def _parse_stsd(self, stsd_data: bytes):
pos = 16
while pos < len(stsd_data) - 8:
box_size = struct.unpack('>I', stsd_data[pos:pos+4])[0]
box_type = stsd_data[pos+4:pos+8]
if box_type in [b'avc1', b'hvc1', b'hev1']:
self.codec_type = 'h264' if box_type == b'avc1' else 'h265'
self.width = struct.unpack('>H', stsd_data[pos+32:pos+34])[0]
self.height = struct.unpack('>H', stsd_data[pos+34:pos+36])[0]
v_pos = pos + 86
while v_pos < pos + box_size - 8:
v_size = struct.unpack('>I', stsd_data[v_pos:v_pos+4])[0]
v_type = stsd_data[v_pos+4:v_pos+8]
config_data = stsd_data[v_pos+8:v_pos+v_size]
if v_type == b'avcC':
self._parse_avcc(config_data)
elif v_type == b'hvcC':
self._parse_hvcc(config_data)
v_pos += v_size if v_size > 0 else 1
pos += box_size if box_size > 0 else 1
def _parse_avcc(self, data: bytes):
self.nal_length_size = (data[4] & 0x03) + 1
pos = 6
start_code = b'\x00\x00\x00\x01'
num_sps = data[5] & 0x1F
for _ in range(num_sps):
sps_len = struct.unpack('>H', data[pos:pos+2])[0]
pos += 2
self.vps_sps_pps_nalus.append(start_code + data[pos:pos+sps_len])
pos += sps_len
num_pps = data[pos]
pos += 1
for _ in range(num_pps):
pps_len = struct.unpack('>H', data[pos:pos+2])[0]
pos += 2
self.vps_sps_pps_nalus.append(start_code + data[pos:pos+pps_len])
pos += pps_len
def _parse_hvcc(self, data: bytes):
self.nal_length_size = (data[21] & 0x03) + 1
num_arrays = data[22]
pos = 23
start_code = b'\x00\x00\x00\x01'
for _ in range(num_arrays):
pos += 1
num_nalus = struct.unpack('>H', data[pos:pos+2])[0]
pos += 2
for _ in range(num_nalus):
nal_len = struct.unpack('>H', data[pos:pos+2])[0]
pos += 2
self.vps_sps_pps_nalus.append(start_code + data[pos:pos+nal_len])
pos += nal_len
def get_sample_position(self, sample_number: int) -> Optional[Dict]:
if not self.stsz or sample_number > len(self.stsz) or sample_number < 1:
return None
target_chunk, samples_so_far, first_sample_in_chunk = 1, 0, 1
for i in range(len(self.stsc)):
current = self.stsc[i]
next_chunk = self.stsc[i+1]['first_chunk'] if i+1 < len(self.stsc) else len(self.stco) + 1
chunks_in_rule = next_chunk - current['first_chunk']
samples_in_rule = chunks_in_rule * current['samples_per_chunk']
if samples_so_far + samples_in_rule >= sample_number:
chunks_to_target = (sample_number - samples_so_far - 1) // current['samples_per_chunk']
target_chunk = current['first_chunk'] + chunks_to_target
first_sample_in_chunk = samples_so_far + chunks_to_target * current['samples_per_chunk'] + 1
break
samples_so_far += samples_in_rule
if target_chunk > len(self.stco):
return None
offset = self.stco[target_chunk - 1]
for i in range(first_sample_in_chunk, sample_number):
offset += self.stsz[i - 1]
return {'offset': offset, 'size': self.stsz[sample_number - 1]}
def _get_frame_number_by_time(self, seconds: float) -> int:
if not self.stts or not self.timescale:
return max(1, int(seconds * 30.0))
target_ticks = int(seconds * self.timescale)
current_ticks = 0
current_sample = 1
for entry in self.stts:
entry_ticks = entry['count'] * entry['delta']
if current_ticks + entry_ticks > target_ticks:
ticks_into_entry = target_ticks - current_ticks
samples_into_entry = ticks_into_entry // entry['delta']
return current_sample + samples_into_entry
current_ticks += entry_ticks
current_sample += entry['count']
return current_sample - 1 if current_sample > 1 else 1
def extract_frame_by_time(self, seconds: float) -> Optional[np.ndarray]:
target_frame = self._get_frame_number_by_time(seconds)
if self.stsz and target_frame > len(self.stsz):
target_frame = len(self.stsz)
target_frame = max(1, target_frame)
return self.extract_frame(target_frame)
def extract_frame(self, frame_number: int) -> Optional[np.ndarray]:
keyframe = frame_number
if self.stss:
keyframes = [kf for kf in self.stss if kf <= frame_number]
keyframe = max(keyframes) if keyframes else self.stss[0]
sample_infos = []
min_offset = float('inf')
max_offset = 0
for f in range(keyframe, frame_number + 1):
info = self.get_sample_position(f)
if not info:
logger.warning(f"无法获取帧 {f} 的位置信息")
return None
sample_infos.append(info)
min_offset = min(min_offset, info['offset'])
max_offset = max(max_offset, info['offset'] + info['size'] - 1)
raw_data = self._download_range(min_offset, max_offset)
annexb_stream = bytearray()
for nalu in self.vps_sps_pps_nalus:
annexb_stream.extend(nalu)
for info in sample_infos:
local_offset = info['offset'] - min_offset
sample_data = raw_data[local_offset : local_offset + info['size']]
annexb_stream.extend(self._convert_sample_to_annexb(sample_data))
frames_to_step = frame_number - keyframe + 1
return self._decode_video_stream(bytes(annexb_stream), frames_to_step)
def _convert_sample_to_annexb(self, sample_data: bytes) -> bytes:
result = bytearray()
pos = 0
start_code = b'\x00\x00\x00\x01'
while pos < len(sample_data):
if pos + self.nal_length_size > len(sample_data):
break
if self.nal_length_size == 4:
nal_len = struct.unpack('>I', sample_data[pos:pos+4])[0]
elif self.nal_length_size == 2:
nal_len = struct.unpack('>H', sample_data[pos:pos+2])[0]
else:
nal_len = sample_data[pos]
pos += self.nal_length_size
if pos + nal_len > len(sample_data):
break
result.extend(start_code)
result.extend(sample_data[pos:pos+nal_len])
pos += nal_len
return bytes(result)
def _decode_video_stream(self, video_data: bytes, target_read_count: int) -> Optional[np.ndarray]:
if not video_data:
return None
ext = '.h265' if self.codec_type == 'h265' else '.h264'
temp_path = None
target_frame_img = None
try:
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
f.write(video_data)
temp_path = f.name
cap = cv2.VideoCapture(temp_path)
for i in range(target_read_count):
ret, frame = cap.read()
if not ret:
break
target_frame_img = frame
cap.release()
if target_frame_img is not None:
return cv2.cvtColor(target_frame_img, cv2.COLOR_BGR2RGB)
except Exception as e:
logger.error(f"视频流解码失败: {e}")
return None
finally:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
return None
def get_video_info(self) -> Dict:
fps = self.timescale if self.stts else 30
duration_sec = self.duration / self.timescale if self.timescale else 0
return {
'width': self.width,
'height': self.height,
'codec': self.codec_type,
'fps': fps,
'duration': duration_sec,
'total_frames': len(self.stsz) if self.stsz else 0
}
def extract_thumbnail_from_url(
video_url: str,
time_seconds: float = None,
frame_number: int = None,
save_path: str = None,
resize_width: int = None,
quality: int = 85
) -> dict:
"""
从远程视频提取封面(流式,只下载必要部分)
"""
extractor = RemoteVideoFrameExtractor(video_url, timeout=60)
if frame_number is not None:
frame = extractor.extract_frame(frame_number)
used_method = f'frame_{frame_number}'
else:
ts = time_seconds if time_seconds is not None else 0
frame = extractor.extract_frame_by_time(ts)
used_method = f'time_{ts}s'
if frame is None:
return {'status': 'error', 'message': 'Failed to extract frame'}
video_info = extractor.get_video_info()
video_info['extract_method'] = used_method
result = {
'status': 'success',
'video_info': video_info,
'shape': frame.shape
}
if save_path:
os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else '.', exist_ok=True)
if resize_width:
h, w = frame.shape[:2]
scale = resize_width / w
new_h = int(h * scale)
resized = cv2.resize(frame, (resize_width, new_h))
frame_to_save = resized
else:
frame_to_save = frame
bgr_frame = cv2.cvtColor(frame_to_save, cv2.COLOR_RGB2BGR)
cv2.imwrite(save_path, bgr_frame, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
result['saved_path'] = save_path
return result
FILE:skill.json
{
"name": "ym-mediatoolkit",
"version": "1.0.0",
"description": "视频处理工具集:1) 视频压缩 2) 封面提取 3) 音频提取(MP3/WAV)",
"author": "your_name",
"entrypoint": "python run.py --input {input_json}",
"http_port": 8080,
"actions": [
{
"name": "compress",
"description": "流式压缩视频,保持清晰度",
"input_schema": {
"type": "object",
"required": ["video_url"],
"properties": {
"video_url": {"type": "string"},
"target_ratio": {"type": "number", "default": 0.1},
"adaptive": {"type": "boolean", "default": true},
"crf": {"type": "integer", "default": 24},
"preset": {"type": "string", "default": "veryfast"}
}
}
},
{
"name": "thumbnail",
"description": "从视频任意时间点或帧号提取封面",
"input_schema": {
"type": "object",
"required": ["video_url"],
"properties": {
"video_url": {"type": "string"},
"time_seconds": {"type": "number"},
"frame_number": {"type": "integer"},
"save_path": {"type": "string"},
"resize_width": {"type": "integer"},
"quality": {"type": "integer", "default": 85}
}
}
},
{
"name": "audio",
"description": "流式提取音频,转成 MP3 或 WAV 格式",
"input_schema": {
"type": "object",
"required": ["video_url"],
"properties": {
"video_url": {"type": "string"},
"format": {"type": "string", "enum": ["mp3", "wav", "aac", "m4a"], "default": "mp3"},
"bitrate": {"type": "string", "default": "128k", "description": "比特率: 128k, 192k, 320k"},
"sample_rate": {"type": "integer", "default": 44100, "description": "采样率: 44100, 48000"},
"channels": {"type": "integer", "default": 2, "description": "声道: 1=单声道, 2=立体声"},
"start_time": {"type": "number", "description": "开始时间(秒)"},
"duration": {"type": "number", "description": "持续时间(秒)"},
"output_path": {"type": "string", "description": "输出路径"}
}
}
},
{
"name": "audio_batch",
"description": "批量提取多个视频的音频",
"input_schema": {
"type": "object",
"required": ["videos"],
"properties": {
"videos": {"type": "array", "description": "视频列表 [{'url': '...', 'name': '...'}]"},
"output_dir": {"type": "string", "default": "./audio_output"},
"format": {"type": "string", "default": "mp3"},
"bitrate": {"type": "string", "default": "128k"},
"sample_rate": {"type": "integer", "default": 44100}
}
}
},
{
"name": "audio_info",
"description": "获取视频的音频流信息",
"input_schema": {
"type": "object",
"required": ["video_url"],
"properties": {
"video_url": {"type": "string"}
}
}
},
{
"name": "info",
"description": "获取完整视频信息",
"input_schema": {
"type": "object",
"required": ["video_url"],
"properties": {
"video_url": {"type": "string"}
}
}
}
]
}Generate professional PDF documents from structured JSON data. Use when user wants to create, export, or save content as a PDF file. Supports styled titles,...
---
name: pdf-generator
description: "Generate professional PDF documents from structured JSON data. Use when user wants to create, export, or save content as a PDF file. Supports styled titles, tables, lists, highlights, images, and page breaks. Trigger phrases: Export as PDF, Generate PDF, Create PDF report, Save as PDF, PDF erstellen, PDF generieren, als PDF speichern."
---
# PDF Generator
Generate styled PDF documents from structured JSON data using ReportLab.
## Quick Start
```bash
python scripts/generate_pdf.py --output report.pdf --data '{
"title": "Monthly Report",
"subtitle": "March 2026",
"author": "PragDev",
"sections": [
{"type": "text", "text": "Introduction text here."},
{"type": "highlight", "text": "Key metric: +15%"},
{"type": "list", "items": ["Item 1", "Item 2"]}
]
}'
```
## JSON Schema Reference
See `references/schema.md` for complete schema documentation.
## Output
- PDF saved to path specified by `--output` or `data.output`
- Default: `output.pdf` in current directory
## Tips
- Use `accent_color` and `header_color` for brand colors
- Tables auto-alternate row backgrounds
- Images must exist at the specified path
- Page breaks create new pages
FILE:references/schema.md
# PDF Generator Schema Reference
## Top-Level Fields
| Field | Type | Description |
|-------|------|-------------|
| `title` | string | Document title (heading1) |
| `subtitle` | string | Subtitle below title (heading3) |
| `author` | string | Author name |
| `date` | string | Date string |
| `accent_color` | string | Accent color hex (default: #e94560) |
| `header_color` | string | Table header color (default: #1a1a2e) |
| `styles` | object | Custom style overrides |
| `sections` | array | Content sections |
| `output` | string | Output PDF path (alternative to CLI --output) |
## Section Types
### text
```json
{"type": "text", "text": "Body paragraph text."}
```
### heading
```json
{"type": "heading", "text": "Section heading"}
```
### subheading
```json
{"type": "subheading", "text": "Subheading text"}
```
### highlight
```json
{"type": "highlight", "text": "Important callout in accent color."}
```
### list
```json
{"type": "list", "items": ["First item", "Second item", "Third item"]}
```
### table
```json
{
"type": "table",
"data": [
["Column 1", "Column 2", "Column 3"],
["Value 1", "Value 2", "Value 3"],
["Value 4", "Value 5", "Value 6"]
]
}
```
- First row is treated as header (bold, white text on header_color background)
- Rows alternate white/#f5f5f5
### image
```json
{
"type": "image",
"path": "/absolute/path/to/image.png",
"width": 120,
"height": 80
}
```
- width/height in mm (default: 150x80)
- Path must be absolute or relative to script execution
### pagebreak
```json
{"type": "pagebreak"}
```
## Example: Mautic Campaign Report
```json
{
"title": "Mautic Kampagnenbericht",
"subtitle": "Q1 2026",
"author": "PragDev-Mautic",
"date": "2026-04-27",
"accent_color": "#e94560",
"sections": [
{"type": "heading", "text": "Kampagnenbersicht"},
{"type": "table", "data": [
["Kampagne", "Gesendet", "Offen", "Klicks"],
["Newsletter April", "1,234", "456", "89"],
["Product Launch", "2,500", "890", "234"]
]},
{"type": "pagebreak"},
{"type": "heading", "text": "Top Kontakte"},
{"type": "list", "items": ["Kontakt A", "Kontakt B", "Kontakt C"]}
]
}
```
FILE:scripts/generate_pdf.py
#!/usr/bin/env python3
"""Generate styled PDF documents from structured data."""
import sys
import json
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, white, black
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image, PageBreak
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
from reportlab.lib import colors
def parse_args():
"""Parse command line arguments."""
args = sys.argv[1:]
input_data = {}
output_path = "output.pdf"
json_input = None
for i, arg in enumerate(args):
if arg == "--output" and i + 1 < len(args):
output_path = args[i + 1]
elif arg == "--data" and i + 1 < len(args):
json_input = args[i + 1]
elif arg == "--help" or arg == "-h":
print_usage()
sys.exit(0)
# Handle JSON input
if json_input:
try:
input_data = json.loads(json_input)
except json.JSONDecodeError:
with open(json_input) as f:
input_data = json.load(f)
if "output" in input_data:
output_path = input_data["output"]
# Handle positional arguments (JSON files)
for arg in args:
if not arg.startswith("--") and arg not in ("--data",):
if os.path.exists(arg):
with open(arg) as f:
input_data = json.load(f)
if "output" in input_data:
output_path = input_data["output"]
break
return input_data, output_path
def print_usage():
usage = """Usage: generate_pdf.py [--output <path>] [--data '<json>'] [data.json]
JSON schema:
{
"title": "Document Title",
"subtitle": "Optional subtitle",
"author": "Author Name",
"date": "2026-04-27",
"accent_color": "#e94560",
"header_color": "#1a1a2e",
"sections": [
{"type": "heading", "text": "Section Title"},
{"type": "text", "text": "Body text here."},
{"type": "highlight", "text": "Important callout."},
{"type": "list", "items": ["Item 1", "Item 2"]},
{"type": "table", "data": [["Col1", "Col2"], ["Val1", "Val2"]]},
{"type": "image", "path": "/path/to/image.png", "width": 100, "height": 60},
{"type": "pagebreak"}
]
}"""
print(usage)
def create_styles(custom_styles=None):
"""Create named styles for the document."""
styles = getSampleStyleSheet()
defaults = {
"heading1": {"fontSize": 24, "leading": 30, "textColor": "#1a1a2e", "spaceAfter": 20, "fontName": "Helvetica-Bold"},
"heading2": {"fontSize": 18, "leading": 24, "textColor": "#16213e", "spaceAfter": 12, "fontName": "Helvetica-Bold"},
"heading3": {"fontSize": 14, "leading": 18, "textColor": "#0f3460", "spaceAfter": 8, "fontName": "Helvetica-Bold"},
"body": {"fontSize": 11, "leading": 16, "textColor": "#2d2d2d", "spaceAfter": 8, "fontName": "Helvetica"},
"caption": {"fontSize": 9, "leading": 12, "textColor": "#666666", "spaceAfter": 4, "fontName": "Helvetica-Oblique"},
"highlight": {"fontSize": 12, "leading": 16, "textColor": "#e94560", "fontName": "Helvetica-Bold"},
}
custom = custom_styles or {}
for name, props in {**defaults, **custom}.items():
styles.add(ParagraphStyle(name=name, **props))
return styles
def build_document(data, output_path, styles):
"""Build the PDF document from input data."""
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
leftMargin=20*mm,
rightMargin=20*mm,
topMargin=20*mm,
bottomMargin=20*mm,
title=data.get("title", ""),
author=data.get("author", ""),
subject=data.get("subject", ""),
)
story = []
# Header color
if "header_color" in data:
header_color = HexColor(data["header_color"])
else:
header_color = HexColor("#1a1a2e")
# Title
if "title" in data:
story.append(Paragraph(data["title"], styles["heading1"]))
story.append(Spacer(1, 5*mm))
# Subtitle
if "subtitle" in data:
story.append(Paragraph(data["subtitle"], styles["heading3"]))
story.append(Spacer(1, 10*mm))
# Author/date line
meta = []
if "author" in data:
meta.append(f"Autor: {data['author']}")
if "date" in data:
meta.append(f"Datum: {data['date']}")
if meta:
story.append(Paragraph(" | ".join(meta), styles["caption"]))
story.append(Spacer(1, 15*mm))
# Horizontal rule
if "accent_color" in data:
accent = HexColor(data["accent_color"])
else:
accent = HexColor("#e94560")
rule_table = Table([[""]], colWidths=[170*mm], rowHeights=[2*mm])
rule_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), accent),
]))
story.append(rule_table)
story.append(Spacer(1, 10*mm))
# Sections
for section in data.get("sections", []):
sec_type = section.get("type", "text")
if sec_type == "heading":
story.append(Paragraph(section["text"], styles["heading2"]))
story.append(Spacer(1, 5*mm))
elif sec_type == "subheading":
story.append(Paragraph(section["text"], styles["heading3"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "text":
story.append(Paragraph(section["text"], styles["body"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "highlight":
story.append(Paragraph(section["text"], styles["highlight"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "list":
for item in section.get("items", []):
story.append(Paragraph(f"• {item}", styles["body"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "table":
table_data = section.get("data", [])
if table_data and table_data[0]:
t = Table(table_data)
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), header_color),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [white, HexColor("#f5f5f5")]),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
]))
story.append(t)
story.append(Spacer(1, 8*mm))
elif sec_type == "image":
img_path = section.get("path", "")
if img_path and os.path.exists(img_path):
try:
w = section.get("width", 150*mm)
h = section.get("height", 80*mm)
img = Image(img_path, width=w, height=h)
story.append(img)
story.append(Spacer(1, 5*mm))
except Exception:
pass
elif sec_type == "pagebreak":
story.append(PageBreak())
doc.build(story)
print(f"PDF created: {output_path}")
return output_path
def main():
data, output_path = parse_args()
if not data:
print_usage()
sys.exit(1)
styles = create_styles(data.get("styles"))
build_document(data, output_path, styles)
if __name__ == "__main__":
main()Generate professional PDF documents from structured JSON data. Use when user wants to create, export, or save content as a PDF file. Supports styled titles,...
---
name: pdf-generator
description: "Generate professional PDF documents from structured JSON data. Use when user wants to create, export, or save content as a PDF file. Supports styled titles, tables, lists, highlights, images, and page breaks. Trigger phrases: Export as PDF, Generate PDF, Create PDF report, Save as PDF, PDF erstellen, PDF generieren, als PDF speichern."
---
# PDF Generator
Generate styled PDF documents from structured JSON data using ReportLab.
## Quick Start
```bash
python scripts/generate_pdf.py --output report.pdf --data '{
"title": "Monthly Report",
"subtitle": "March 2026",
"author": "PragDev",
"sections": [
{"type": "text", "text": "Introduction text here."},
{"type": "highlight", "text": "Key metric: +15%"},
{"type": "list", "items": ["Item 1", "Item 2"]}
]
}'
```
## JSON Schema Reference
See `references/schema.md` for complete schema documentation.
## Output
- PDF saved to path specified by `--output` or `data.output`
- Default: `output.pdf` in current directory
## Tips
- Use `accent_color` and `header_color` for brand colors
- Tables auto-alternate row backgrounds
- Images must exist at the specified path
- Page breaks create new pages
FILE:references/schema.md
# PDF Generator Schema Reference
## Top-Level Fields
| Field | Type | Description |
|-------|------|-------------|
| `title` | string | Document title (heading1) |
| `subtitle` | string | Subtitle below title (heading3) |
| `author` | string | Author name |
| `date` | string | Date string |
| `accent_color` | string | Accent color hex (default: #e94560) |
| `header_color` | string | Table header color (default: #1a1a2e) |
| `styles` | object | Custom style overrides |
| `sections` | array | Content sections |
| `output` | string | Output PDF path (alternative to CLI --output) |
## Section Types
### text
```json
{"type": "text", "text": "Body paragraph text."}
```
### heading
```json
{"type": "heading", "text": "Section heading"}
```
### subheading
```json
{"type": "subheading", "text": "Subheading text"}
```
### highlight
```json
{"type": "highlight", "text": "Important callout in accent color."}
```
### list
```json
{"type": "list", "items": ["First item", "Second item", "Third item"]}
```
### table
```json
{
"type": "table",
"data": [
["Column 1", "Column 2", "Column 3"],
["Value 1", "Value 2", "Value 3"],
["Value 4", "Value 5", "Value 6"]
]
}
```
- First row is treated as header (bold, white text on header_color background)
- Rows alternate white/#f5f5f5
### image
```json
{
"type": "image",
"path": "/absolute/path/to/image.png",
"width": 120,
"height": 80
}
```
- width/height in mm (default: 150x80)
- Path must be absolute or relative to script execution
### pagebreak
```json
{"type": "pagebreak"}
```
## Example: Mautic Campaign Report
```json
{
"title": "Mautic Kampagnenbericht",
"subtitle": "Q1 2026",
"author": "PragDev-Mautic",
"date": "2026-04-27",
"accent_color": "#e94560",
"sections": [
{"type": "heading", "text": "Kampagnenbersicht"},
{"type": "table", "data": [
["Kampagne", "Gesendet", "Offen", "Klicks"],
["Newsletter April", "1,234", "456", "89"],
["Product Launch", "2,500", "890", "234"]
]},
{"type": "pagebreak"},
{"type": "heading", "text": "Top Kontakte"},
{"type": "list", "items": ["Kontakt A", "Kontakt B", "Kontakt C"]}
]
}
```
FILE:scripts/generate_pdf.py
#!/usr/bin/env python3
"""Generate styled PDF documents from structured data."""
import sys
import json
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, white, black
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image, PageBreak
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
from reportlab.lib import colors
def parse_args():
"""Parse command line arguments."""
args = sys.argv[1:]
input_data = {}
output_path = "output.pdf"
json_input = None
for i, arg in enumerate(args):
if arg == "--output" and i + 1 < len(args):
output_path = args[i + 1]
elif arg == "--data" and i + 1 < len(args):
json_input = args[i + 1]
elif arg == "--help" or arg == "-h":
print_usage()
sys.exit(0)
# Handle JSON input
if json_input:
try:
input_data = json.loads(json_input)
except json.JSONDecodeError:
with open(json_input) as f:
input_data = json.load(f)
if "output" in input_data:
output_path = input_data["output"]
# Handle positional arguments (JSON files)
for arg in args:
if not arg.startswith("--") and arg not in ("--data",):
if os.path.exists(arg):
with open(arg) as f:
input_data = json.load(f)
if "output" in input_data:
output_path = input_data["output"]
break
return input_data, output_path
def print_usage():
usage = """Usage: generate_pdf.py [--output <path>] [--data '<json>'] [data.json]
JSON schema:
{
"title": "Document Title",
"subtitle": "Optional subtitle",
"author": "Author Name",
"date": "2026-04-27",
"accent_color": "#e94560",
"header_color": "#1a1a2e",
"sections": [
{"type": "heading", "text": "Section Title"},
{"type": "text", "text": "Body text here."},
{"type": "highlight", "text": "Important callout."},
{"type": "list", "items": ["Item 1", "Item 2"]},
{"type": "table", "data": [["Col1", "Col2"], ["Val1", "Val2"]]},
{"type": "image", "path": "/path/to/image.png", "width": 100, "height": 60},
{"type": "pagebreak"}
]
}"""
print(usage)
def create_styles(custom_styles=None):
"""Create named styles for the document."""
styles = getSampleStyleSheet()
defaults = {
"heading1": {"fontSize": 24, "leading": 30, "textColor": "#1a1a2e", "spaceAfter": 20, "fontName": "Helvetica-Bold"},
"heading2": {"fontSize": 18, "leading": 24, "textColor": "#16213e", "spaceAfter": 12, "fontName": "Helvetica-Bold"},
"heading3": {"fontSize": 14, "leading": 18, "textColor": "#0f3460", "spaceAfter": 8, "fontName": "Helvetica-Bold"},
"body": {"fontSize": 11, "leading": 16, "textColor": "#2d2d2d", "spaceAfter": 8, "fontName": "Helvetica"},
"caption": {"fontSize": 9, "leading": 12, "textColor": "#666666", "spaceAfter": 4, "fontName": "Helvetica-Oblique"},
"highlight": {"fontSize": 12, "leading": 16, "textColor": "#e94560", "fontName": "Helvetica-Bold"},
}
custom = custom_styles or {}
for name, props in {**defaults, **custom}.items():
styles.add(ParagraphStyle(name=name, **props))
return styles
def build_document(data, output_path, styles):
"""Build the PDF document from input data."""
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
leftMargin=20*mm,
rightMargin=20*mm,
topMargin=20*mm,
bottomMargin=20*mm,
title=data.get("title", ""),
author=data.get("author", ""),
subject=data.get("subject", ""),
)
story = []
# Header color
if "header_color" in data:
header_color = HexColor(data["header_color"])
else:
header_color = HexColor("#1a1a2e")
# Title
if "title" in data:
story.append(Paragraph(data["title"], styles["heading1"]))
story.append(Spacer(1, 5*mm))
# Subtitle
if "subtitle" in data:
story.append(Paragraph(data["subtitle"], styles["heading3"]))
story.append(Spacer(1, 10*mm))
# Author/date line
meta = []
if "author" in data:
meta.append(f"Autor: {data['author']}")
if "date" in data:
meta.append(f"Datum: {data['date']}")
if meta:
story.append(Paragraph(" | ".join(meta), styles["caption"]))
story.append(Spacer(1, 15*mm))
# Horizontal rule
if "accent_color" in data:
accent = HexColor(data["accent_color"])
else:
accent = HexColor("#e94560")
rule_table = Table([[""]], colWidths=[170*mm], rowHeights=[2*mm])
rule_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), accent),
]))
story.append(rule_table)
story.append(Spacer(1, 10*mm))
# Sections
for section in data.get("sections", []):
sec_type = section.get("type", "text")
if sec_type == "heading":
story.append(Paragraph(section["text"], styles["heading2"]))
story.append(Spacer(1, 5*mm))
elif sec_type == "subheading":
story.append(Paragraph(section["text"], styles["heading3"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "text":
story.append(Paragraph(section["text"], styles["body"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "highlight":
story.append(Paragraph(section["text"], styles["highlight"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "list":
for item in section.get("items", []):
story.append(Paragraph(f"• {item}", styles["body"]))
story.append(Spacer(1, 3*mm))
elif sec_type == "table":
table_data = section.get("data", [])
if table_data and table_data[0]:
t = Table(table_data)
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), header_color),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [white, HexColor("#f5f5f5")]),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
]))
story.append(t)
story.append(Spacer(1, 8*mm))
elif sec_type == "image":
img_path = section.get("path", "")
if img_path and os.path.exists(img_path):
try:
w = section.get("width", 150*mm)
h = section.get("height", 80*mm)
img = Image(img_path, width=w, height=h)
story.append(img)
story.append(Spacer(1, 5*mm))
except Exception:
pass
elif sec_type == "pagebreak":
story.append(PageBreak())
doc.build(story)
print(f"PDF created: {output_path}")
return output_path
def main():
data, output_path = parse_args()
if not data:
print_usage()
sys.exit(1)
styles = create_styles(data.get("styles"))
build_document(data, output_path, styles)
if __name__ == "__main__":
main()Short-video generation pipeline powered by MoneyPrinterTurbo v1.2.7. Configure subject, script, TTS voiceover, BGM, and subtitle styling.
---
name: dlazy-money-printer-turbo
version: 1.0.0
description: Short-video generation pipeline powered by MoneyPrinterTurbo v1.2.7. Configure subject, script, TTS voiceover, BGM, and subtitle styling.
metadata: {"clawdbot":{"emoji":"🤖","requires":{"bins":["npm","npx"]},"install":"npm install -g @dlazy/[email protected]","installAlternative":"npx @dlazy/[email protected]","homepage":"https://github.com/dlazyai/cli","source":"https://github.com/dlazyai/cli","author":"dlazyai","license":"see-repo","npm":"https://www.npmjs.com/package/@dlazy/cli","configLocation":"~/.dlazy/config.json","apiEndpoints":["api.dlazy.com","oss.dlazy.com"]},"openclaw":{"systemPrompt":"When invoking this skill, use dlazy money-printer-turbo -h for help."}}
---
# dlazy-money-printer-turbo
[English](./SKILL.md) · [中文](./SKILL-cn.md)
Short-video generation pipeline powered by MoneyPrinterTurbo v1.2.7. Configure subject, script, TTS voiceover, BGM, and subtitle styling.
## Trigger Keywords
- money-printer-turbo
## Authentication
All requests require a dLazy API key, configured through the CLI:
```bash
dlazy auth set YOUR_API_KEY
```
The CLI saves the key in your user config directory (`~/.dlazy/config.json` on macOS/Linux, `%USERPROFILE%\.dlazy\config.json` on Windows), with file permissions restricted to your OS user account. You can also supply the key per-invocation via the `DLAZY_API_KEY` environment variable.
### Getting Your API Key
1. Sign in or create an account at [dlazy.com](https://dlazy.com)
2. Go to [dlazy.com/dashboard/organization/api-key](https://dlazy.com/dashboard/organization/api-key)
3. Copy the key shown in the API Key section
Each key is scoped to your dLazy organization and can be **rotated or revoked at any time** from the same dashboard.
## About & Provenance
- **CLI source code**: [github.com/dlazyai/cli](https://github.com/dlazyai/cli)
- **Maintainer**: dlazyai
- **npm package**: `@dlazy/cli` (pinned to `1.0.6` in this skill's install spec)
- **Homepage**: [dlazy.com](https://dlazy.com)
You can install on demand without persisting a global binary by running:
```bash
npx @dlazy/[email protected] <command>
```
Or, if you prefer a global install, the skill's `metadata.clawdbot.install` field declares the exact pinned version (`npm install -g @dlazy/[email protected]`). Review the GitHub source before installing.
## How It Works
This skill is a thin client over the dLazy hosted API. When you invoke it:
- Prompts and parameters you provide are sent to the dLazy API endpoint (`api.dlazy.com`) for inference.
- Any local file paths you pass to image / video / audio fields are uploaded to dLazy's media storage (`oss.dlazy.com`) so the model can read them — the same flow as any cloud-based generation API.
- Generated output URLs returned by the API are hosted on `oss.dlazy.com`.
This is the standard SaaS pattern; the skill itself does not access network or filesystem resources beyond what the dLazy CLI already handles. See [dlazy.com](https://dlazy.com) for the full service terms.
## Usage
**CRITICAL INSTRUCTION FOR AGENT**:
Execute `dlazy money-printer-turbo` to get the result.
```bash
dlazy money-printer-turbo -h
Options:
--prompt <prompt> Prompt
--manual_script_terms <manual_script_terms>Manual script & terms input [default: false]
--video_script <video_script> Video Script [only when manual_script_terms="true"]
--video_terms <video_terms> Video Terms [only when manual_script_terms="true"]
--video_source <video_source> Video Source [default: pexels] (choices: "pexels", "local")
--local_video_files <local_video_files>Local Video Files [only when video_source="local"]
--video_concat_mode <video_concat_mode>Concat Mode [default: random] (choices: "random", "sequential")
--video_transition_mode <video_transition_mode>Transition Mode [default: None] (choices: "None", "Shuffle", "FadeIn", "FadeOut", "SlideIn", "SlideOut")
--video_aspect <video_aspect> Video Aspect [default: 9:16] (choices: "9:16", "16:9", "1:1")
--video_clip_duration <video_clip_duration>Max Clip Duration (2-10s) [default: 3] (choices: "2", "3", "4", "5", "6", "7", "8", "9", "10")
--video_count <video_count> Video Count (1-5) [default: 1] (choices: "1", "2", "3", "4", "5")
--voice_name <voice_name> Voice [default: zh-CN-XiaoxiaoNeural] (choices: "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunjianNeural", "zh-CN-YunxiNeural", "zh-CN-YunxiaNeural", "zh-CN-YunyangNeural", "zh-CN-liaoning-XiaobeiNeural", "zh-CN-shaanxi-XiaoniNeural", "zh-HK-HiuGaaiNeural", "zh-HK-HiuMaanNeural", "zh-HK-WanLungNeural", "zh-TW-HsiaoChenNeural", "zh-TW-HsiaoYuNeural", "zh-TW-YunJheNeural")
--voice_volume <voice_volume> Voice Volume (1.0 = 100%) [default: 1.0] (choices: "0.5", "0.8", "1.0", "1.2", "1.5", "2.0")
--voice_rate <voice_rate> Voice Rate (1.0 = 1x) [default: 1.0] (choices: "0.5", "0.8", "1.0", "1.2", "1.5", "2.0")
--bgm_type <bgm_type> BGM [default: random] (choices: "random", "none")
--bgm_volume <bgm_volume> BGM Volume [default: 0.2] (choices: "0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.8", "1.0")
--subtitle_enabled <subtitle_enabled>Enable Subtitles [default: true]
--font_name <font_name> Subtitle Font [default: MicrosoftYaHeiBold.ttc] (choices: "MicrosoftYaHeiBold.ttc", "MicrosoftYaHei.ttc")
--subtitle_position <subtitle_position>Subtitle Position [default: bottom] (choices: "bottom", "top", "center")
--text_fore_color <text_fore_color> Text Color [default: #FFFFFF]
--stroke_color <stroke_color> Stroke Color [default: #000000]
--font_size <font_size> Font Size [default: 60] (choices: "30", "40", "50", "60", "70", "80", "90", "100")
--stroke_width <stroke_width> Stroke Width [default: 1.5] (choices: "0.0", "0.5", "1.0", "1.5", "2.0", "3.0", "5.0", "10.0")
--promptRefs <promptRefs...> promptRefs [default: ]
--input <spec> JSON payload: inline string, @file, or - (stdin)
--dry-run Print payload + cost estimate without calling API
--no-wait Return generateId immediately for async tasks
--timeout <seconds> Max seconds to wait for async completion (default: "1800")
-h, --help display help for command
```
## Output Format
```json
{
"ok": true,
"kind": "urls",
"data": {
"urls": [
"https://oss.dlazy.com/result.mp4"
]
}
}
```
## Examples
```bash
dlazy money-printer-turbo --prompt 'prompt content'
```
## Error Handling
| Code | Error Type | Example Message |
| ---- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 401 | Unauthorized (No API Key) | `ok: false, code: "unauthorized", message: "API key is missing or invalid"` |
| 501 | Missing required parameter | `error: required option '--prompt <prompt>' not specified` |
| 502 | Local file read error | `Error: Image file/Video file not found: C:\path\to\your\file` |
| 503 | API request failed (no balance) | `ok: false, code: "insufficient_balance"` |
| 503 | API request failed (server error) | `HTTP status code error (500 server crash)` |
| 504 | Asynchronous task execution failed | `=== Generation Failed ===` / `{Specific error reason returned by backend, for example "Prompt violates safety policy"}` |
> **AGENT CRITICAL INSTRUCTION**:
> 1. If the execution result returns `code: "insufficient_balance"`, you MUST explicitly inform the user that their credits are insufficient and guide them to recharge: [https://dlazy.com/dashboard/organization/settings?tab=credits](https://dlazy.com/dashboard/organization/settings?tab=credits)
> 2. If the execution result returns `code: "unauthorized"` or indicates missing API key, you MUST inform the user to get their API key from [https://dlazy.com/dashboard/organization/api-key](https://dlazy.com/dashboard/organization/api-key) and save it using `dlazy auth set <key>` and resume the task.
## Tips
Visit https://dlazy.com for more information.
FILE:SKILL-cn.md
---
name: dlazy-money-printer-turbo
version: 1.0.0
description: Short-video generation pipeline powered by MoneyPrinterTurbo v1.2.7. Configure subject, script, TTS voiceover, BGM, and subtitle styling.
metadata: {"clawdbot":{"emoji":"🤖","requires":{"bins":["npm","npx"]},"install":"npm install -g @dlazy/[email protected]","installAlternative":"npx @dlazy/[email protected]","homepage":"https://github.com/dlazyai/cli","source":"https://github.com/dlazyai/cli","author":"dlazyai","license":"see-repo","npm":"https://www.npmjs.com/package/@dlazy/cli","configLocation":"~/.dlazy/config.json","apiEndpoints":["api.dlazy.com","oss.dlazy.com"]},"openclaw":{"systemPrompt":"当调用此技能时,可以使用 dlazy money-printer-turbo -h 查看帮助信息。"}}
---
# dlazy-money-printer-turbo
[English](./SKILL.md) · [中文](./SKILL-cn.md)
Short-video generation pipeline powered by MoneyPrinterTurbo v1.2.7. Configure subject, script, TTS voiceover, BGM, and subtitle styling.
## 触发关键词
- money-printer-turbo
## 身份验证 (Authentication)
所有请求都需要 dLazy API key,通过 CLI 配置:
```bash
dlazy auth set YOUR_API_KEY
```
CLI 会把 key 保存在你的用户配置目录(macOS/Linux 上为 `~/.dlazy/config.json`,Windows 上为 `%USERPROFILE%\.dlazy\config.json`),文件权限仅限当前操作系统用户访问。你也可以用 `DLAZY_API_KEY` 环境变量按次传入。
### 获取你的 API Key
1. 登录或在 [dlazy.com](https://dlazy.com) 创建账号
2. 访问 [dlazy.com/dashboard/organization/api-key](https://dlazy.com/dashboard/organization/api-key)
3. 复制 API Key 区域显示的密钥
每个 key 都属于你自己的 dLazy 组织,可在同一控制面板**随时轮换或吊销**。
## 关于与来源 (Provenance)
- **CLI 源代码**: [github.com/dlazyai/cli](https://github.com/dlazyai/cli)
- **维护者**: dlazyai
- **npm 包名**: `@dlazy/cli`(本技能 install 字段固定到 `1.0.6` 版本)
- **官网**: [dlazy.com](https://dlazy.com)
如果你不希望在系统上长期保留一个全局 CLI,可以按需运行:
```bash
npx @dlazy/[email protected] <command>
```
如选择全局安装,技能的 `metadata.clawdbot.install` 字段已固定到 `npm install -g @dlazy/[email protected]`。安装前建议先到 GitHub 仓库审阅源码。
## 工作原理
此技能是 dLazy 托管 API 的轻量封装。调用时:
- 你提供的提示词与参数会发送到 dLazy API(`api.dlazy.com`)进行推理。
- 传入图像 / 视频 / 音频字段的本地文件路径会被 CLI 上传到 dLazy 媒体存储(`oss.dlazy.com`),以便模型读取 —— 与任何云端生成 API 的流程一致。
- API 返回的生成结果 URL 由 `oss.dlazy.com` 托管。
这是标准的 SaaS 调用模式;技能本身不会越权访问网络或文件系统,所有动作都由 dLazy CLI 完成。完整服务条款请参见 [dlazy.com](https://dlazy.com)。
## 使用方法
**CRITICAL INSTRUCTION FOR AGENT**:
执行 `dlazy money-printer-turbo` 命令获取结果。
```bash
dlazy money-printer-turbo -h
Options:
--prompt <prompt> Prompt
--manual_script_terms <manual_script_terms>Manual script & terms input [default: false]
--video_script <video_script> Video Script [only when manual_script_terms="true"]
--video_terms <video_terms> Video Terms [only when manual_script_terms="true"]
--video_source <video_source> Video Source [default: pexels] (choices: "pexels", "local")
--local_video_files <local_video_files>Local Video Files [only when video_source="local"]
--video_concat_mode <video_concat_mode>Concat Mode [default: random] (choices: "random", "sequential")
--video_transition_mode <video_transition_mode>Transition Mode [default: None] (choices: "None", "Shuffle", "FadeIn", "FadeOut", "SlideIn", "SlideOut")
--video_aspect <video_aspect> Video Aspect [default: 9:16] (choices: "9:16", "16:9", "1:1")
--video_clip_duration <video_clip_duration>Max Clip Duration (2-10s) [default: 3] (choices: "2", "3", "4", "5", "6", "7", "8", "9", "10")
--video_count <video_count> Video Count (1-5) [default: 1] (choices: "1", "2", "3", "4", "5")
--voice_name <voice_name> Voice [default: zh-CN-XiaoxiaoNeural] (choices: "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunjianNeural", "zh-CN-YunxiNeural", "zh-CN-YunxiaNeural", "zh-CN-YunyangNeural", "zh-CN-liaoning-XiaobeiNeural", "zh-CN-shaanxi-XiaoniNeural", "zh-HK-HiuGaaiNeural", "zh-HK-HiuMaanNeural", "zh-HK-WanLungNeural", "zh-TW-HsiaoChenNeural", "zh-TW-HsiaoYuNeural", "zh-TW-YunJheNeural")
--voice_volume <voice_volume> Voice Volume (1.0 = 100%) [default: 1.0] (choices: "0.5", "0.8", "1.0", "1.2", "1.5", "2.0")
--voice_rate <voice_rate> Voice Rate (1.0 = 1x) [default: 1.0] (choices: "0.5", "0.8", "1.0", "1.2", "1.5", "2.0")
--bgm_type <bgm_type> BGM [default: random] (choices: "random", "none")
--bgm_volume <bgm_volume> BGM Volume [default: 0.2] (choices: "0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.8", "1.0")
--subtitle_enabled <subtitle_enabled>Enable Subtitles [default: true]
--font_name <font_name> Subtitle Font [default: MicrosoftYaHeiBold.ttc] (choices: "MicrosoftYaHeiBold.ttc", "MicrosoftYaHei.ttc")
--subtitle_position <subtitle_position>Subtitle Position [default: bottom] (choices: "bottom", "top", "center")
--text_fore_color <text_fore_color> Text Color [default: #FFFFFF]
--stroke_color <stroke_color> Stroke Color [default: #000000]
--font_size <font_size> Font Size [default: 60] (choices: "30", "40", "50", "60", "70", "80", "90", "100")
--stroke_width <stroke_width> Stroke Width [default: 1.5] (choices: "0.0", "0.5", "1.0", "1.5", "2.0", "3.0", "5.0", "10.0")
--promptRefs <promptRefs...> promptRefs [default: ]
--input <spec> JSON payload: inline string, @file, or - (stdin)
--dry-run Print payload + cost estimate without calling API
--no-wait Return generateId immediately for async tasks
--timeout <seconds> Max seconds to wait for async completion (default: "1800")
-h, --help display help for command
```
## 输出格式
```json
{
"ok": true,
"kind": "urls",
"data": {
"urls": [
"https://oss.dlazy.com/result.mp4"
]
}
}
```
## 命令示例
```bash
dlazy money-printer-turbo --prompt '提示词内容'
```
## 错误处理
| Code | 错误类型 | 示例信息 |
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 401 | 未授权 (API Key缺失或无效) | `ok: false, code: "unauthorized", message: "API key is missing or invalid"` |
| 501 | 缺少必填参数 | `error: required option '--prompt <prompt>' not specified` |
| 502 | 本地文件读取失败 | `Error: Image file/Video file not found: C:\path\to\your\file` |
| 503 | API 请求失败(余额不足) | `ok: false, code: "insufficient_balance"` |
| 503 | API 请求失败(服务端错误) | `HTTP status code error (500 server crash)` |
| 504 | 异步任务执行失败 | `=== Generation Failed ===` / `{后端返回的具体失败原因,比如 "Prompt violates safety policy"}` |
> **智能体关键指令**:
> 1. 如果执行结果返回 `code: "insufficient_balance"` 且提示余额不足(Insufficient balance),您必须明确告知用户当前积分不足,并引导用户点击以下链接前往充值:[https://dlazy.com/dashboard/organization/settings?tab=credits](https://dlazy.com/dashboard/organization/settings?tab=credits)
> 2. 如果执行结果返回 `code: "unauthorized"` 或提示缺少 API Key,您必须明确告知用户前往 [https://dlazy.com/dashboard/organization/api-key](https://dlazy.com/dashboard/organization/api-key) 获取 API Key 并使用 `dlazy auth set <key>` 保存,然后继续执行任务。
## Tips
Visit https://dlazy.com for more information.名字转艺术字 Skill,使用百度星河社区 ERNIE-Image API 将姓名或文字生成为艺术字图片。This skill should be used when users want to generate artistic text/name images, calligraphy art, styliz...
---
name: ernie-image-art-name
description: "名字转艺术字 Skill,使用百度星河社区 ERNIE-Image API 将姓名或文字生成为艺术字图片。This skill should be used when users want to generate artistic text/name images, calligraphy art, stylized name designs, or text-to-art-typography tasks. 触发词:艺术字、名字艺术字、书法字、艺术字体、文字设计、名字图片、姓名生成图片、把名字变成艺术字、文字转图片、ERNIE-Image 生图、文生图名字。"
version: 1.0.0
author: lizan
tags:
- image-generation
- ernie-image
- art-typography
- chinese
- baidu-aistudio
---
# 名字转艺术字 Skill
## 功能概述
使用百度星河社区 ERNIE-Image(文心图像大模型)API,将用户提供的姓名或文字生成为高质量的艺术字图片。支持中国风书法、烫金、霓虹、卡通、火焰、冰晶等多种风格。
## 前置条件
需要百度星河社区 Access Token:前往 https://aistudio.baidu.com/account/accessToken 获取。
## 执行流程
### Step 1:确认 Access Token
优先检查是否已配置,按以下顺序:
1. 用户本次提供的 Token(命令行 `--token`)
2. 环境变量 `AISTUDIO_ACCESS_TOKEN`
3. 配置文件 `config.json`(位于 skill 安装目录下)
**若 Token 未配置**,引导用户通过以下命令保存(只需一次):
```bash
python3 scripts/generate_art_name.py --set-token YOUR_TOKEN
```
Token 获取地址:https://aistudio.baidu.com/account/accessToken
### Step 2:确认输入参数
向用户确认:
- **名字/文字**:要生成的内容(必填)
- **风格**:从预设风格中选择(默认中国风)
- 中国风、烫金、霓虹、卡通、石刻、玫瑰、极简、火焰、冰晶、自定义
- **输出目录**(可选,默认 `./art_names`)
若用户描述不够具体,主动询问风格偏好。
### Step 3:执行生成
调用核心脚本(路径相对于 skill 安装目录):
```bash
python3 scripts/generate_art_name.py \
--name "用户名字" \
--style 风格名称 \
--output 输出目录
```
**常用参数:**
| 参数 | 说明 |
|---|---|
| `--name` / `-n` | 要生成的名字或文字(必填) |
| `--style` / `-s` | 风格:中国风/烫金/霓虹/卡通/石刻/玫瑰/极简/火焰/冰晶/自定义 |
| `--prompt` / `-p` | 自定义描述,配合 `--style 自定义` 使用 |
| `--output` / `-o` | 图片保存目录 |
| `--token` / `-t` | 临时指定 Access Token |
| `--model` / `-m` | 模型选择(默认 ERNIE-Image-Turbo) |
| `--set-token` | 将 Token 保存到配置文件(只需一次) |
| `--list-styles` | 查看所有可用风格 |
| `--show-config` | 查看当前配置 |
### Step 4:展示结果
脚本成功执行后,生成的图片会保存到本地。使用 `open_result_view` 展示图片,并询问是否需要调整风格重新生成。
## 错误处理
| 错误 | 解决方案 |
|---|---|
| Token 无效 / 401 | 引导用户重新获取 Token 并用 `--set-token` 保存 |
| 网络超时 | 重试,或将 timeout 延长至 180 秒 |
| 内容审核拦截 | 调整 Prompt 表达方式,避免敏感词 |
| 模型不可用 | 切换为 `ERNIE-Image` 或 `Stable-Diffusion-XL` |
## 参考文档
详见 `references/api_docs.md`:包含完整 API 参数说明、Prompt 写作技巧和代码示例。
## 配置管理
配置文件 `config.json` 位于 skill 安装目录下,格式如下:
```json
{
"access_token": "在这里填写你的 Access Token",
"model": "ERNIE-Image-Turbo",
"output_dir": "./art_names"
}
```
可直接编辑此文件,或用 `--set-token` 命令更新 Token。
FILE:README.md
# ernie-image-art-name
> 名字转艺术字 Skill —— 使用百度星河社区 ERNIE-Image API,将姓名或任意文字生成为高质量艺术字图片。
## ✨ 功能亮点
- **10 种预设风格**:中国风书法、烫金、霓虹、卡通、石刻、玫瑰、极简、火焰、冰晶,或完全自定义
- **精准中文文字渲染**:ERNIE-Image 在中文文字渲染方面处于行业领先水平
- **零依赖**:核心脚本仅用 Python 标准库,无需安装任何第三方包
- **灵活配置**:支持命令行参数、环境变量、配置文件三种 Token 传入方式
## 🚀 快速上手
### 1. 获取 Access Token
前往 [星河社区个人中心](https://aistudio.baidu.com/account/accessToken) 获取 Token,然后保存:
```bash
python3 scripts/generate_art_name.py --set-token YOUR_TOKEN
```
### 2. 生成艺术字
直接和 AI 说:
> 帮我把"张伟"生成一张中国风艺术字图片
或使用命令行:
```bash
# 默认中国风
python3 scripts/generate_art_name.py --name "张伟"
# 指定风格
python3 scripts/generate_art_name.py --name "张伟" --style 霓虹
# 完全自定义描述
python3 scripts/generate_art_name.py --name "张伟" --style 自定义 --prompt "蒸汽朋克风格,齿轮装饰,深棕色背景"
# 查看所有风格
python3 scripts/generate_art_name.py --list-styles
```
## 🎨 支持风格
| 风格 | 描述 |
|---|---|
| 中国风 | 毛笔书法,水墨金色,古典大气 |
| 烫金 | 金属质感,浮雕效果,豪华精致 |
| 霓虹 | 霓虹灯管,赛博朋克,发光效果 |
| 卡通 | 圆润字体,彩虹渐变,活泼可爱 |
| 石刻 | 篆刻浮雕,青铜质感,古朴沧桑 |
| 玫瑰 | 花卉装饰,浪漫粉色,优雅精致 |
| 极简 | 黑白几何,现代设计,高端简洁 |
| 火焰 | 燃烧效果,橙红火焰,动感强烈 |
| 冰晶 | 霜冻效果,蓝白透明,雪花装饰 |
| 自定义 | 配合 `--prompt` 完全自定义描述 |
## ⚙️ 参数说明
| 参数 | 简写 | 说明 |
|---|---|---|
| `--name` | `-n` | 要生成的名字或文字(必填) |
| `--style` | `-s` | 预设风格名称,默认:中国风 |
| `--prompt` | `-p` | 自定义风格描述(`--style 自定义` 时生效) |
| `--output` | `-o` | 图片保存目录,默认 `./art_names` |
| `--token` | `-t` | 临时指定 Access Token |
| `--model` | `-m` | 模型:ERNIE-Image / ERNIE-Image-Turbo / Stable-Diffusion-XL |
| `--set-token` | — | 将 Token 永久保存到配置文件 |
| `--list-styles` | — | 列出所有可用风格 |
| `--show-config` | — | 显示当前配置 |
## 🔑 Token 配置优先级
1. 命令行 `--token YOUR_TOKEN`
2. 环境变量 `export AISTUDIO_ACCESS_TOKEN=YOUR_TOKEN`
3. 配置文件 `config.json`(skill 目录下)
## 📋 依赖说明
- Python 3.6+(仅标准库,无需 pip install)
- 百度星河社区账号(免费,100万 Tokens 免费额度)
## 📄 License
MIT
FILE:references/api_docs.md
# 百度星河社区 ERNIE-Image API 参考文档
## 接口基础信息
| 项目 | 值 |
|---|---|
| 基础域名 | `https://aistudio.baidu.com/llm/lmapi/v3` |
| 文生图接口 | `POST /images/generations` |
| 完整 URL | `https://aistudio.baidu.com/llm/lmapi/v3/images/generations` |
| 接口格式 | 兼容 OpenAI images.generate 格式 |
## 认证方式
在 HTTP Header 中传递 Bearer Token:
```
Authorization: Bearer YOUR_ACCESS_TOKEN
```
**获取 Access Token 地址:** https://aistudio.baidu.com/account/accessToken
## 请求参数
### Request Body(JSON)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `model` | string | 是 | 模型名称,见下表 |
| `prompt` | string | 是 | 图像生成描述文本 |
| `response_format` | string | 否 | `url`(返回链接)或 `b64_json`(返回base64),默认 `url` |
| `n` | integer | 否 | 生成图片数量,默认 1 |
### 可用模型
| 模型名称 | 特点 |
|---|---|
| `ERNIE-Image` | 完整版,图像质量更高,生成较慢 |
| `ERNIE-Image-Turbo` | 快速版(推荐),仅8步推理,速度快 |
| `Stable-Diffusion-XL` | SDXL 模型,风格多样 |
## 响应格式
```json
{
"created": 1714000000,
"data": [
{
"url": "https://...", // response_format=url 时返回
"b64_json": "iVBORw0KGgo..." // response_format=b64_json 时返回
}
]
}
```
## 示例代码
### Python(纯标准库,无需第三方包)
```python
import urllib.request
import json
import base64
ACCESS_TOKEN = "your_access_token_here"
payload = json.dumps({
"model": "ERNIE-Image-Turbo",
"prompt": '将文字"张伟"设计成中国风书法艺术字,金色,红色背景',
"response_format": "b64_json",
"n": 1
}).encode("utf-8")
req = urllib.request.Request(
url="https://aistudio.baidu.com/llm/lmapi/v3/images/generations",
data=payload,
headers={
"Authorization": f"Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json"
},
method="POST"
)
with urllib.request.urlopen(req, timeout=120) as resp:
result = json.loads(resp.read())
# 保存图片
with open("output.png", "wb") as f:
f.write(base64.b64decode(result["data"][0]["b64_json"]))
```
### Python(使用 openai 包)
```python
from openai import OpenAI
client = OpenAI(
api_key="your_access_token_here",
base_url="https://aistudio.baidu.com/llm/lmapi/v3"
)
result = client.images.generate(
model="ERNIE-Image-Turbo",
prompt='将文字"张伟"设计成中国风书法艺术字,金色,红色背景',
response_format="b64_json"
)
```
## 配置文件格式
Skill 配置文件 `config.json` 位于 skill 安装目录根路径下(即 `SKILL.md` 同级目录)。
```json
{
"access_token": "your_access_token_here",
"model": "ERNIE-Image-Turbo",
"output_dir": "./art_names"
}
```
通过脚本自动写入(推荐):
```bash
python3 scripts/generate_art_name.py --set-token YOUR_TOKEN
```
## Prompt 写作技巧(艺术字)
好的艺术字 Prompt 应包含:
1. **明确标注文字内容**:用引号将名字括起来,如 `将文字"张三"设计成...`
2. **指定字体风格**:书法、印刷体、手写体等
3. **颜色描述**:主色调、渐变方向
4. **背景/氛围**:背景颜色或场景
5. **质量要求**:高分辨率、清晰可辨、精细质感
### 示例 Prompt
```
将文字"张伟"设计成中国传统书法艺术字,毛笔字体,水墨风格,金色文字,
红色背景,古典纹样装饰,大气磅礴,文字清晰可辨,高分辨率,正方形构图
```
## 注意事项
- Access Token 有效期:登录后长期有效,但 Token 泄露需立即重置
- 免费额度:每账户 100万 Tokens
- 超时设置:建议 timeout=120 秒(模型推理时间较长)
- 生成失败常见原因:Token 无效、Prompt 触发审核、网络超时
FILE:scripts/generate_art_name.py
#!/usr/bin/env python3
"""
名字转艺术字生成脚本
使用百度星河社区 ERNIE-Image API 生成艺术字图片
用法:
python3 generate_art_name.py --name "李赞" --style 中国风
python3 generate_art_name.py --name "李赞" --style 霓虹 --output ./output
python3 generate_art_name.py --name "李赞" --token YOUR_ACCESS_TOKEN
配置优先级:
1. 命令行参数 --token
2. 环境变量 AISTUDIO_ACCESS_TOKEN
3. 配置文件 ~/.workbuddy/skills/ernie-image-art-name/config.json
"""
import argparse
import base64
import json
import os
import sys
import time
from pathlib import Path
# ──────────────────────────────────────────────────────────────────────────────
# 配置
# ──────────────────────────────────────────────────────────────────────────────
CONFIG_FILE = Path(__file__).parent.parent / "config.json"
DEFAULT_CONFIG = {
"access_token": "",
"model": "ERNIE-Image-Turbo",
"output_dir": "./art_names"
}
API_BASE_URL = "https://aistudio.baidu.com/llm/lmapi/v3"
# 预设风格库
STYLE_PRESETS = {
"中国风": "中国传统书法艺术字,毛笔字体,水墨风格,金色或红色文字,古典纹样背景,大气磅礴",
"烫金": "豪华烫金艺术字,金属质感,深色背景,立体浮雕效果,精致华丽",
"霓虹": "霓虹灯管艺术字,发光效果,赛博朋克风格,深夜城市背景,色彩鲜艳",
"卡通": "可爱卡通字体,圆润边角,彩虹渐变色,白色背景,活泼有趣",
"石刻": "古代石刻篆刻艺术字,浮雕质感,古朴沧桑,青铜或石灰岩质感",
"玫瑰": "玫瑰花卉装饰艺术字,浪漫粉色,花瓣环绕,优雅精致",
"极简": "现代简约艺术字,黑白灰色调,几何字体,高端设计感",
"火焰": "火焰燃烧效果艺术字,橙红色火焰,动感强烈,深色背景",
"冰晶": "冰晶霜冻艺术字,蓝白色调,晶莹剔透,雪花冰花装饰",
"自定义": "" # 用户自行输入 prompt
}
# ──────────────────────────────────────────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────────────────────────────────────────
def load_config() -> dict:
"""加载配置文件,若不存在则创建默认配置"""
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
cfg = json.load(f)
return {**DEFAULT_CONFIG, **cfg}
except (json.JSONDecodeError, IOError):
pass
return DEFAULT_CONFIG.copy()
def save_config(cfg: dict):
"""保存配置到文件"""
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
print(f"✅ 配置已保存到 {CONFIG_FILE}")
def get_access_token(args_token: str, config: dict) -> str:
"""按优先级获取 Access Token"""
# 1. 命令行参数
if args_token:
return args_token
# 2. 环境变量
env_token = os.environ.get("AISTUDIO_ACCESS_TOKEN", "")
if env_token:
return env_token
# 3. 配置文件
cfg_token = config.get("access_token", "")
if cfg_token:
return cfg_token
return ""
def build_prompt(name: str, style: str, custom_prompt: str = "") -> str:
"""构建生成艺术字的 Prompt"""
if style == "自定义" and custom_prompt:
return f'文字"{name}",{custom_prompt}'
style_desc = STYLE_PRESETS.get(style, STYLE_PRESETS["中国风"])
prompt = (
f'将文字"{name}"设计成艺术字,{style_desc},'
f'文字清晰可辨,高分辨率,精细质感,专业设计感,正方形构图'
)
return prompt
def generate_image(prompt: str, access_token: str, model: str) -> bytes:
"""调用 ERNIE-Image API 生成图片,返回图片二进制数据"""
import urllib.request
payload = json.dumps({
"model": model,
"prompt": prompt,
"response_format": "b64_json",
"n": 1
}).encode("utf-8")
req = urllib.request.Request(
url=f"{API_BASE_URL}/images/generations",
data=payload,
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
result = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8")
raise RuntimeError(f"API 请求失败 HTTP {e.code}: {body}")
except urllib.error.URLError as e:
raise RuntimeError(f"网络连接失败: {e.reason}")
if "error" in result:
raise RuntimeError(f"API 返回错误: {result['error']}")
b64_data = result["data"][0]["b64_json"]
return base64.b64decode(b64_data)
def save_image(img_bytes: bytes, output_dir: str, name: str, style: str) -> str:
"""保存图片到指定目录,返回保存路径"""
out_path = Path(output_dir)
out_path.mkdir(parents=True, exist_ok=True)
timestamp = int(time.time())
safe_name = name.replace("/", "_").replace("\\", "_")
filename = f"{safe_name}_{style}_{timestamp}.png"
filepath = out_path / filename
with open(filepath, "wb") as f:
f.write(img_bytes)
return str(filepath)
# ──────────────────────────────────────────────────────────────────────────────
# 主入口
# ──────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="名字转艺术字 - 使用百度 ERNIE-Image API",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python3 generate_art_name.py --name "李赞" --style 中国风
python3 generate_art_name.py --name "李赞" --style 霓虹 --output ./我的艺术字
python3 generate_art_name.py --name "李赞" --style 自定义 --prompt "蒸汽朋克风格,齿轮装饰"
python3 generate_art_name.py --set-token YOUR_TOKEN # 保存 Token 到配置文件
python3 generate_art_name.py --list-styles # 查看所有风格
可用风格:
""" + " ".join(STYLE_PRESETS.keys())
)
parser.add_argument("--name", "-n", type=str, help="要转换为艺术字的名字或文字")
parser.add_argument(
"--style", "-s", type=str, default="中国风",
choices=list(STYLE_PRESETS.keys()),
help=f"艺术字风格,默认:中国风"
)
parser.add_argument("--prompt", "-p", type=str, default="",
help="自定义风格描述(--style 自定义 时生效)")
parser.add_argument("--output", "-o", type=str, default="",
help="图片保存目录,默认使用配置文件中的 output_dir")
parser.add_argument("--token", "-t", type=str, default="",
help="星河社区 Access Token(优先于配置文件)")
parser.add_argument("--model", "-m", type=str, default="",
choices=["ERNIE-Image", "ERNIE-Image-Turbo", "Stable-Diffusion-XL"],
help="使用的模型,默认:ERNIE-Image-Turbo")
parser.add_argument("--set-token", type=str, metavar="TOKEN",
help="将 Access Token 保存到配置文件")
parser.add_argument("--list-styles", action="store_true",
help="列出所有可用风格")
parser.add_argument("--show-config", action="store_true",
help="显示当前配置")
args = parser.parse_args()
config = load_config()
# ── 特殊命令 ──────────────────────────────────────────────
if args.list_styles:
print("\n📋 可用艺术字风格:\n")
for style, desc in STYLE_PRESETS.items():
if desc:
print(f" {style:6s} {desc[:40]}...")
else:
print(f" {style:6s} (用 --prompt 自定义描述)")
print()
return
if args.show_config:
print(f"\n⚙️ 当前配置({CONFIG_FILE}):\n")
display_cfg = dict(config)
if display_cfg.get("access_token"):
display_cfg["access_token"] = display_cfg["access_token"][:8] + "****"
print(json.dumps(display_cfg, ensure_ascii=False, indent=2))
print()
return
if args.set_token:
config["access_token"] = args.set_token
save_config(config)
print(f"✅ Access Token 已保存!前8位:{args.set_token[:8]}****")
return
# ── 参数校验 ──────────────────────────────────────────────
if not args.name:
parser.print_help()
print("\n❌ 请使用 --name 指定要转换的名字")
sys.exit(1)
access_token = get_access_token(args.token, config)
if not access_token:
print("❌ 未找到 Access Token!请通过以下方式之一提供:")
print(" 1. 命令行:--token YOUR_TOKEN")
print(" 2. 环境变量:export AISTUDIO_ACCESS_TOKEN=YOUR_TOKEN")
print(" 3. 配置文件:python3 generate_art_name.py --set-token YOUR_TOKEN")
print("\n 获取 Token:https://aistudio.baidu.com/account/accessToken")
sys.exit(1)
model = args.model or config.get("model", DEFAULT_CONFIG["model"])
output_dir = args.output or config.get("output_dir", DEFAULT_CONFIG["output_dir"])
# ── 生成艺术字 ───────────────────────────────────────────
prompt = build_prompt(args.name, args.style, args.prompt)
print(f"\n🎨 正在生成艺术字...")
print(f" 名字:{args.name}")
print(f" 风格:{args.style}")
print(f" 模型:{model}")
print(f" Prompt:{prompt[:80]}{'...' if len(prompt) > 80 else ''}\n")
try:
img_bytes = generate_image(prompt, access_token, model)
saved_path = save_image(img_bytes, output_dir, args.name, args.style)
print(f"✅ 艺术字生成成功!")
print(f" 保存路径:{saved_path}")
print(f" 文件大小:{len(img_bytes) / 1024:.1f} KB\n")
except RuntimeError as e:
print(f"❌ 生成失败:{e}")
sys.exit(1)
if __name__ == "__main__":
main()
JF Tech Pro AI 智搜技能。根据语义内容(如"带帽子的人"、"车"、"狗")搜索杰峰云存报警视频,获取匹配的视频片段列表。使用场景:智能视频检索、AI 事件搜索、语义化视频查找。
---
name: jf-open-pro-ai-smart-search
description: JF Tech Pro AI 智搜技能。根据语义内容(如"带帽子的人"、"车"、"狗")搜索杰峰云存报警视频,获取匹配的视频片段列表。使用场景:智能视频检索、AI 事件搜索、语义化视频查找。
# 必需凭证声明 - 平台元数据
credentials:
required:
- name: JF_UUID
type: string
description: 杰峰开放平台用户唯一标识
source: https://open.jftech.com/
- name: JF_APPKEY
type: string
description: 杰峰开放平台应用 Key
source: https://open.jftech.com/
- name: JF_APPSECRET
type: string
description: 杰峰开放平台应用密钥
source: https://open.jftech.com/
- name: JF_MOVECARD
type: integer
description: 签名算法偏移量 (0-9)
source: https://open.jftech.com/
optional:
- name: JF_SN
type: string
description: 设备序列号
- name: JF_USER
type: string
description: 用户 ID
default: admin
- name: JF_ENDPOINT
type: string
description: API 端点
default: api.jftechws.com
# 网络端点声明
endpoints:
- url: https://api.jftechws.com
description: 杰峰官方 API (国际)
- url: https://api-cn.jftech.com
description: 杰峰官方 API (中国大陆)
# 安全声明
security:
credentials_required: true
env_vars_only: true # 凭据仅通过环境变量读取
language: python # Python 脚本
network_access:
- api.jftechws.com # 杰峰官方 API (国际)
- api-cn.jftech.com # 杰峰官方 API (中国大陆)
file_access: none # 不读取本地文件
---
# JF Open Pro AI Smart Search
> **面向开发者杰峰 AI 智搜工具 (Python)**
>
> 根据语义内容搜索杰峰云存报警视频,获取匹配的视频片段列表及播放信息。
---
## 🔒 安全说明
**凭据存储:仅支持环境变量**
| 方式 | 支持 | 说明 |
|------|------|------|
| **环境变量** | ✅ 支持 | 推荐方式,避免凭据出现在进程列表或日志中 |
| **命令行参数** | ❌ 不支持 | 避免凭据泄露风险 |
| **配置文件** | ❌ 不支持 | 避免明文存储凭据 |
**网络访问:**
- ✅ 仅访问杰峰官方 API 端点 (`api.jftechws.com` / `api-cn.jftech.com`)
- ❌ 不访问第三方服务
- ❌ 不读取本地文件系统
**脚本行为:**
- ✅ 本地执行 Python 脚本(技能本身)
- ✅ 仅向指定的杰峰 API 端点发起 HTTPS 请求
- ❌ 不执行外部命令
- ❌ 不读取敏感系统文件
---
## 🚀 快速开始
### 设置环境变量
```bash
export JF_UUID="your-uuid" # 开放平台用户唯一标识
export JF_APPKEY="your-appkey" # 开放平台应用 Key
export JF_APPSECRET="your-appsecret" # 开放平台应用密钥
export JF_MOVECARD=5 # 签名算法偏移量 (0-9)
export JF_SN="your-device-sn" # 设备序列号
export JF_USER="admin" # 用户 ID(可选,默认:admin)
```
### 使用技能
```bash
# AI 智搜 - 搜索"人"相关的视频
python scripts/search_video.py --search "人"
# AI 智搜 - 搜索"车"相关的视频
python scripts/search_video.py --search "车"
# AI 智搜 - 搜索"狗"相关的视频
python scripts/search_video.py --search "狗"
# AI 智搜 - 搜索"戴帽子的人"
python scripts/search_video.py --search "戴帽子的人"
# 获取云存回放地址(指定时间)
python scripts/get_playback_url.py --start-time "2026-04-07 12:00:00" --stop-time "2026-04-07 12:45:00"
# 完整流程:AI 智搜 + 播放地址(推荐)
python scripts/ai_search_playback.py --search "人" --video-index 0
```
---
## 📋 环境变量
| 变量名 | 说明 | 必需 | 默认值 |
|--------|------|------|--------|
| `JF_UUID` | 开放平台用户唯一标识 | 是 | - |
| `JF_APPKEY` | 开放平台应用 Key | 是 | - |
| `JF_APPSECRET` | 开放平台应用密钥 | 是 | - |
| `JF_MOVECARD` | 签名算法偏移量 (0-9),用于时间戳偏移增加签名安全性 | 是 | - |
| `JF_SN` | 设备序列号 | 是 | - |
| `JF_USER` | 用户 ID | 否 | `admin` |
| `JF_ENDPOINT` | API 端点 | 否 | `api.jftechws.com` |
---
## 🛠️ 功能
### 1. AI 智搜视频
根据语义内容搜索 AI 标记的云存报警视频。
**支持的搜索类型:**
| 搜索类型 | 示例查询 | 说明 |
|----------|----------|------|
| 人物 | "人"、"戴帽子的人"、"穿红色衣服的人" | 基于人形 + 属性检测 |
| 车辆 | "车"、"白色轿车"、"卡车" | 基于车辆检测 |
| 动物 | "狗"、"猫" | 基于动物检测 |
| 行为 | "跑步的人"、"摔倒" | 基于行为分析 |
**使用示例:**
```bash
# 搜索"人"相关的视频
python scripts/search_video.py --search "人"
# 搜索"车"相关的视频
python scripts/search_video.py --search "车"
# 搜索"戴帽子的人"
python scripts/search_video.py --search "戴帽子的人"
```
**返回字段说明:**
| 字段 | 说明 | 示例 |
|------|------|------|
| `st` | 录像开始时间(秒) | 1703275200 |
| `et` | 录像结束时间(秒) | 1703275260 |
| `matchRate` | 匹配度(0-1) | 0.95 |
| `queryTags` | 检测到的标签列表 | ["person", "hat"] |
| `eventTime` | 事件触发时间 | "2024-12-23 10:00:00" |
---
### 2. 云存回放地址获取
获取云存报警视频回放/播放地址。
**使用示例:**
```bash
# 指定时间范围获取回放地址
python scripts/get_playback_url.py --start-time "2026-04-07 12:00:00" --stop-time "2026-04-07 12:45:00"
# 完整流程:AI 智搜 + 播放地址(推荐)
python scripts/ai_search_playback.py --search "人" --video-index 0
```
**工作流程:**
```
1. AI 智搜搜索视频
↓
获取云存报警信息视频列表
↓
2. 选择目标视频
↓
提取 st(开始时间)和 et(结束时间)
↓
3. 调用云存报警视频回放 API
↓
st 对应 startTime
et 对应 stopTime
↓
4. 获取播放链接
```
---
## 📖 使用场景示例
### 场景 1: 搜索特定人员的活动记录
```bash
# 搜索"人"相关的视频
python scripts/search_video.py --search "人"
# 查看返回结果,选择感兴趣的视频片段
# 使用返回的 st 和 et 获取回放地址
python scripts/get_playback_url.py --start-time "2026-04-07 12:00:00" --stop-time "2026-04-07 12:45:00"
```
### 场景 2: 搜索车辆进出记录
```bash
# 搜索"车"相关的视频
python scripts/search_video.py --search "车"
```
### 场景 3: 完整流程 - 搜索并播放
```bash
# 一步完成:搜索"人"并获取第一个视频的回放地址
python scripts/ai_search_playback.py --search "人" --video-index 0
```
---
## ⚠️ 错误处理
| 错误码 | 说明 | 解决方案 |
|--------|------|----------|
| `2000` | 成功 | - |
| `12504` | 授权失败 - 设备未开通 AI 智搜套餐 | 登录开放平台为设备绑定 AI 智搜套餐卡 |
| `10001` | 参数错误 | 检查请求参数格式 |
| `10002` | 签名失败 | 检查 appKey/appSecret 和时间戳 |
### 错误码 12504 处理
**错误信息:** `authorize failed, Please check it in the open platform`
**原因:** 设备未开通 AI 智搜服务,或未绑定套餐卡
**解决步骤:**
1. 登录杰峰开放平台:https://developer.jftech.com
2. 进入 **套餐管理** / **服务管理**
3. 找到 **AI 智搜** 或 **云存视频搜索** 套餐
4. 为设备购买并绑定套餐卡
5. 等待配置生效(通常 1-5 分钟)
6. 重新调用 API 测试
---
## ⚠️ 注意事项
1. **设备需开通云存服务** - AI 智搜需要云存套餐支持
2. **设备需开通 AI 智搜套餐** - 需在开放平台绑定套餐卡
3. **时间范围** - 只能搜索云存有效期内的视频
4. **搜索精度** - 受 AI 算法识别精度影响
---
## 📚 官方参考资料
- **杰峰开放平台**: https://open.jftech.com/
- **API 文档**: https://docs.jftech.com/
- **AI 智搜文档**: https://docs.jftech.com/docs?menusId=54582398fd8d4248962354e92ac2e47a&siderId=d2c0d9105d9c4b78bc0d2ee3851d2557
- **API 端点**: `api.jftechws.com` (国际) / `api-cn.jftech.com` (中国大陆)
---
## 📁 脚本工具
**可用脚本:**
| 脚本 | 功能 |
|------|------|
| `search_video.py` | AI 智搜 - 搜索云存报警视频 |
| `get_playback_url.py` | 获取云存回放地址(指定时间或完整流程) |
| `ai_search_playback.py` | 完整流程 - AI 智搜 + 播放地址一键获取 |
```bash
# 获取帮助
python scripts/search_video.py --help
python scripts/get_playback_url.py --help
python scripts/ai_search_playback.py --help
# AI 智搜
python scripts/search_video.py --search <搜索内容>
# 获取回放地址(指定时间)
python scripts/get_playback_url.py --start-time "YYYY-MM-DD HH:MM:SS" --stop-time "YYYY-MM-DD HH:MM:SS"
# 完整流程:AI 智搜 + 播放地址(推荐)
python scripts/ai_search_playback.py --search <搜索内容> --video-index <索引>
```
脚本路径:`scripts/search_video.py`, `scripts/get_playback_url.py`, `scripts/ai_search_playback.py`
---
**技能版本:** v1.0.0
**语言:** Python
**最后更新:** 2026-04-07
FILE:skill.yaml
# JF Open Pro AI Smart Search - Skill Registry Metadata
# This file defines the skill's requirements for ClawHub registry
name: jf-open-pro-ai-smart-search
version: 1.0.0
description: JF Tech Pro AI 智搜技能。根据语义内容(如"带帽子的人"、"车"、"狗")搜索杰峰云存报警视频,获取匹配的视频片段列表。
# Runtime requirements
runtime:
language: python
minVersion: "3.8"
# Required environment variables (credentials)
requiredEnvVars:
- name: JF_UUID
description: 杰峰开放平台用户唯一标识
source: https://open.jftech.com/
- name: JF_APPKEY
description: 杰峰开放平台应用 Key
source: https://open.jftech.com/
- name: JF_APPSECRET
description: 杰峰开放平台应用密钥
source: https://open.jftech.com/
- name: JF_MOVECARD
description: 签名算法偏移量 (0-9),用于时间戳偏移增加签名安全性
source: https://open.jftech.com/
- name: JF_SN
description: 设备序列号
source: 杰峰设备机身标签或管理后台
# Optional environment variables
optionalEnvVars:
- name: JF_USER
description: 用户 ID
default: admin
- name: JF_ENDPOINT
description: API 端点
default: api.jftechws.com
# Network endpoints (for firewall/security configuration)
endpoints:
- url: https://api.jftechws.com
description: 杰峰官方 API (国际)
- url: https://api-cn.jftech.com
description: 杰峰官方 API (中国大陆)
# Security declarations
security:
credentialsRequired: true
envVarsOnly: true
networkAccess:
- api.jftechws.com
- api-cn.jftech.com
fileAccess: none
# Entry points
scripts:
- name: search_video.py
description: AI 智搜 - 搜索云存报警视频
entryPoint: scripts/search_video.py
- name: get_playback_url.py
description: 获取云存回放地址
entryPoint: scripts/get_playback_url.py
- name: ai_search_playback.py
description: 完整流程 - AI 智搜 + 播放地址
entryPoint: scripts/ai_search_playback.py
# Tags for discovery
tags:
- jf-tech
- 杰峰
- ai-search
- video-search
- cloud-storage
- 云存搜索
FILE:scripts/ai_search_playback.py
#!/usr/bin/env python3
"""
AI 智搜 + 云存回放完整流程脚本
工作流程:
1. 调用 AI 智搜 API 获取视频列表
2. 选择指定索引的视频
3. 提取开始/结束时间
4. 调用云存回放 API 获取播放地址
用法:
export JF_UUID="your-uuid"
export JF_APPKEY="your-appkey"
export JF_APPSECRET="your-appsecret"
export JF_MOVECARD=5
export JF_SN="your-device-sn"
export JF_USER="admin"
python ai_search_playback.py --search "人" --video-index 0
"""
import argparse
import hashlib
import json
import os
import sys
import time
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
def generate_jftech_sign(appkey: str, secret: str, timestamp: int) -> str:
"""生成 JF Tech API 签名"""
sign_str = f"{appkey}{timestamp}{secret}"
return hashlib.md5(sign_str.encode()).hexdigest()
def search_jftech(sn: str, user: str, query: str, uuid: str, appkey: str,
secret: str, authorization: str = "") -> dict:
"""调用 JF Tech AI 智搜 API"""
url = "https://api.jftechws.com/aisvr/v3/gateway/api/viewsearch/searchVideo"
timestamp = int(time.time() * 1000)
sign = generate_jftech_sign(appkey, secret, timestamp)
headers = {
"Content-Type": "application/json",
"uuid": uuid,
"appkey": appkey,
"sign": sign,
"timestamp": str(timestamp),
"authorization": authorization
}
body = {
"sn": sn,
"user": user,
"searchContent": query
}
req = Request(url, data=json.dumps(body).encode(), headers=headers, method="POST")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
return result
except HTTPError as e:
return {"error": f"HTTP {e.code}", "message": e.read().decode()}
except URLError as e:
return {"error": "Network error", "message": str(e)}
def generate_timestamp(movecard=0):
"""
生成当前时间戳(毫秒),可选加上 movecard 偏移量
Args:
movecard: 签名算法偏移量 (0-9),用于增加签名安全性
"""
return str(int(time.time() * 1000) + movecard)
def generate_signature(uuid, app_key, app_secret, time_millis):
"""生成杰峰 API 签名"""
sign_str = uuid + app_key + time_millis + app_secret
return hashlib.md5(sign_str.encode('utf-8')).hexdigest()
def get_device_token(sn, uuid, app_key, app_secret, movecard=0, endpoint="api.jftechws.com"):
"""通过设备序列号生成 deviceToken"""
time_millis = generate_timestamp(movecard)
signature = generate_signature(uuid, app_key, app_secret, time_millis)
url = f"https://{endpoint}/gwp/v3/rtc/device/token"
headers = {
"uuid": uuid,
"appKey": app_key,
"timeMillis": time_millis,
"signature": signature,
"Content-Type": "application/json"
}
body = {"deviceSnList": [sn]}
req = Request(url, data=json.dumps(body).encode('utf-8'), headers=headers, method="POST")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
return result
except HTTPError as e:
return {"error": f"HTTP {e.code}", "message": e.read().decode()}
except URLError as e:
return {"error": "Network error", "message": str(e)}
def get_playback_url(sn, user, start_time, stop_time, uuid, app_key, app_secret, movecard=0,
endpoint="api.jftechws.com", stream_type="hls"):
"""获取云存回放地址"""
token_result = get_device_token(sn, uuid, app_key, app_secret, movecard, endpoint)
if token_result.get('error') or token_result.get('code') != 2000:
return {"error": f"获取 Token 失败:{token_result.get('error') or token_result.get('msg')}"}
if not token_result.get('data') or len(token_result['data']) == 0:
return {"error": "获取 Token 失败:返回数据为空"}
device_token = token_result['data'][0]['token']
url = f"https://{endpoint}/gwp/v3/rtc/device/getVideoUrl/{device_token}"
time_millis = generate_timestamp(movecard)
signature = generate_signature(uuid, app_key, app_secret, time_millis)
headers = {
"uuid": uuid,
"appKey": app_key,
"timeMillis": time_millis,
"signature": signature,
"Content-Type": "application/json"
}
body = {
"user": user,
"sn": sn,
"startTime": start_time,
"stopTime": stop_time,
"streamType": stream_type
}
req = Request(url, data=json.dumps(body).encode('utf-8'), headers=headers, method="POST")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
return result
except HTTPError as e:
return {"error": f"HTTP {e.code}", "message": e.read().decode()}
except URLError as e:
return {"error": "Network error", "message": str(e)}
def get_config_from_env():
"""从环境变量读取配置"""
required_vars = ['JF_UUID', 'JF_APPKEY', 'JF_APPSECRET', 'JF_MOVECARD', 'JF_SN']
missing_vars = [var for var in required_vars if not os.environ.get(var)]
if missing_vars:
raise Exception(f"缺少必需的环境变量:{', '.join(missing_vars)}\n"
f"请设置:export JF_UUID='...' JF_APPKEY='...' JF_APPSECRET='...' JF_MOVECARD=5 JF_SN='...'")
return {
'uuid': os.environ.get('JF_UUID'),
'appkey': os.environ.get('JF_APPKEY'),
'appsecret': os.environ.get('JF_APPSECRET'),
'movecard': int(os.environ.get('JF_MOVECARD', 5)),
'sn': os.environ.get('JF_SN'),
'user': os.environ.get('JF_USER', 'admin'),
'endpoint': os.environ.get('JF_ENDPOINT', 'api.jftechws.com')
}
def main():
parser = argparse.ArgumentParser(
description='AI 智搜 + 云存回放完整流程',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
环境变量:
JF_UUID 开放平台用户唯一标识 (必需)
JF_APPKEY 开放平台应用 Key (必需)
JF_APPSECRET 开放平台应用密钥 (必需)
JF_MOVECARD 签名算法偏移量,通常设为 5 (必需)
JF_SN 设备序列号 (必需)
JF_USER 用户 ID,默认 admin (可选)
JF_ENDPOINT API 端点,默认 api.jftechws.com (可选)
示例:
# 设置环境变量
export JF_UUID="your-uuid"
export JF_APPKEY="your-appkey"
export JF_APPSECRET="your-appsecret"
export JF_MOVECARD=5
export JF_SN="your-device-sn"
export JF_USER="admin"
# 搜索"人"并获取第一个视频的回放地址
python ai_search_playback.py --search "人" --video-index 0
# 搜索"车"并获取第二个视频的回放地址
python ai_search_playback.py --search "车" --video-index 1
''')
parser.add_argument('--search', required=True, help='搜索内容(如"人"、"车"、"狗")')
parser.add_argument('--video-index', type=int, default=0, help='选择第几个视频(从 0 开始,默认 0)')
args = parser.parse_args()
# 从环境变量读取配置
try:
config = get_config_from_env()
except Exception as e:
print(f'❌ 配置错误:{e}')
sys.exit(1)
print('========================================')
print('AI 智搜 + 云存回放完整流程')
print('========================================')
print(f"设备 SN: {config['sn']}")
print(f"搜索内容:{args.search}")
print(f"视频索引:{args.video_index}")
print()
# 步骤 1: AI 智搜
print('>>> 步骤 1/3: AI 智搜搜索视频...')
search_result = search_jftech(
sn=config['sn'],
user=config['user'],
query=args.search,
uuid=config['uuid'],
appkey=config['appkey'],
secret=config['appsecret']
)
if search_result.get('error'):
print(f"❌ AI 智搜失败:{search_result['error']}")
if search_result.get('message'):
print(f" 详情:{search_result['message']}")
sys.exit(1)
if search_result.get('code') != 2000:
print(f"❌ API 错误码:{search_result.get('code')}")
print(f" 详情:{search_result.get('msg', 'Unknown error')}")
sys.exit(1)
data = search_result.get('data', {})
videos = data.get('videos', [])
if not videos:
print('❌ 未找到匹配的视频')
sys.exit(1)
print(f"✅ 找到 {len(videos)} 个匹配的视频")
if args.video_index >= len(videos):
print(f"❌ 视频索引 {args.video_index} 超出范围 (0-{len(videos)-1})")
sys.exit(1)
video = videos[args.video_index]
print(f" 选择:片段 {args.video_index + 1}")
print(f" 时间:{video.get('eventTime', 'N/A')}")
print(f" 匹配度:{video.get('matchRate', 0):.0%}")
print()
# 步骤 2: 提取时间
start_time = video.get('st')
stop_time = video.get('et')
if not start_time or not stop_time:
print('❌ 无法提取视频时间信息')
sys.exit(1)
# 转换时间戳为可读格式
from datetime import datetime
start_dt = datetime.fromtimestamp(start_time)
stop_dt = datetime.fromtimestamp(stop_time)
print('>>> 步骤 2/3: 提取视频时间...')
print(f" 开始:{start_dt.strftime('%Y-%m-%d %H:%M:%S')} ({start_time})")
print(f" 结束:{stop_dt.strftime('%Y-%m-%d %H:%M:%S')} ({stop_time})")
print()
# 步骤 3: 获取回放地址
print('>>> 步骤 3/3: 获取云存回放地址...')
playback_result = get_playback_url(
sn=config['sn'],
user=config['user'],
start_time=start_time,
stop_time=stop_time,
uuid=config['uuid'],
app_key=config['appkey'],
app_secret=config['appsecret'],
movecard=config['movecard'],
endpoint=config['endpoint']
)
if playback_result.get('error'):
print(f"❌ 获取回放地址失败:{playback_result['error']}")
sys.exit(1)
if playback_result.get('code') != 2000:
print(f"❌ API 错误码:{playback_result.get('code')}")
print(f" 详情:{playback_result.get('msg', 'Unknown error')}")
sys.exit(1)
playback_data = playback_result.get('data', {})
playback_url = playback_data.get('url') or playback_data.get('playUrl')
if not playback_url:
print('❌ 未找到播放 URL')
print(json.dumps(playback_result, indent=2, ensure_ascii=False))
sys.exit(1)
print('✅ 回放地址获取成功')
print()
print('========================================')
print('播放信息')
print('========================================')
print(f"设备 SN: {config['sn']}")
print(f"时间范围:{start_dt.strftime('%Y-%m-%d %H:%M:%S')} - {stop_dt.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"播放地址:{playback_url}")
print()
print("使用方式:")
print(f" - VLC 播放:vlc \"{playback_url}\"")
print(f" - 网页播放:在浏览器中打开 URL")
print(f" - 下载:curl -o video.mp4 \"{playback_url}\"")
print('========================================')
if __name__ == "__main__":
main()
FILE:scripts/get_playback_url.py
#!/usr/bin/env python3
"""
云存报警视频回放地址获取脚本
工作流程:
1. 先通过 AI 智搜获取云存报警信息视频列表
2. 提取录像开始时间(st 对应 startTime)和录像结束时间(et 对应 stopTime)
3. 通过设备序列号生成 deviceToken
4. 通过获取云存报警视频回放或下载地址接口,获取播放链接
官方文档:
https://docs.jftech.com/docs?menusId=54582398fd8d4248962354e92ac2e47a&siderId=2e08468f46564602d01ae8a244661672
API 端点:
1. 获取设备 Token: POST https://api.jftechws.com/gwp/v3/rtc/device/token
2. 云存回放:POST https://api.jftechws.com/gwp/v3/rtc/device/getVideoUrl/{deviceToken}
用法:
# 设置环境变量(必需)
export JF_UUID="your-uuid"
export JF_APPKEY="your-appkey"
export JF_APPSECRET="your-appsecret"
export JF_MOVECARD=5 # 签名算法偏移量 (0-9)
export JF_SN="your-device-sn"
export JF_USER="admin" # 可选,默认 admin
export JF_ENDPOINT="api.jftechws.com" # 可选
# 完整流程:AI 智搜 + 播放地址
python get_playback_url.py --search "人" --video-index 0
# 直接获取指定时间的播放地址
python get_playback_url.py --start-time "2026-03-28 15:23:26" --stop-time "2026-03-28 15:23:36"
"""
import argparse
import hashlib
import json
import os
import time
import sys
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
def generate_timestamp(movecard=0):
"""
生成当前时间戳(毫秒),可选加上 movecard 偏移量
Args:
movecard: 签名算法偏移量 (0-9),用于增加签名安全性
"""
return str(int(time.time() * 1000) + movecard)
def generate_signature(uuid, app_key, app_secret, time_millis):
"""
生成杰峰 API 签名
签名算法:MD5(uuid + appKey + timeMillis + secret)
Args:
uuid: 开放平台用户 uuid
app_key: 应用 appKey
app_secret: 应用密钥
time_millis: 时间戳(毫秒),已包含 movecard 偏移
"""
sign_str = uuid + app_key + time_millis + app_secret
return hashlib.md5(sign_str.encode('utf-8')).hexdigest()
def get_device_token(sn, uuid, app_key, app_secret, movecard=0, endpoint="api.jftechws.com"):
"""
通过设备序列号生成 deviceToken
API: POST https://api.jftechws.com/gwp/v3/rtc/device/token
Args:
sn: 设备序列号
uuid: 开放平台用户 uuid
app_key: 应用 appKey
app_secret: 应用密钥
movecard: 签名算法偏移量 (0-9)
endpoint: API 端点
Returns:
dict: API 响应,包含 deviceToken
"""
time_millis = generate_timestamp(movecard)
signature = generate_signature(uuid, app_key, app_secret, time_millis)
url = f"https://{endpoint}/gwp/v3/rtc/device/token"
headers = {
"uuid": uuid,
"appKey": app_key,
"timeMillis": time_millis,
"signature": signature,
"Content-Type": "application/json"
}
body = {"deviceSnList": [sn]}
req = Request(url, data=json.dumps(body).encode('utf-8'), headers=headers, method="POST")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
return result
except HTTPError as e:
return {"error": f"HTTP Error {e.code}: {e.reason}", "status": e.code}
except URLError as e:
return {"error": f"URL Error: {e.reason}"}
except Exception as e:
return {"error": str(e)}
def get_cloud_playback_url(device_token, sn, user, start_time, stop_time,
uuid, app_key, app_secret, movecard=0,
channel=0, stream_type=1, endpoint="api.jftechws.com"):
"""
获取云存报警视频回放或下载地址
根据录像开始时间(st 对应 startTime)和录像结束时间(et 对应 stopTime)
获取对应云存报警视频播放链接
API: POST https://api.jftechws.com/gwp/v3/rtc/device/getVideoUrl/{deviceToken}
官方文档:
https://docs.jftech.com/docs?menusId=54582398fd8d4248962354e92ac2e47a&siderId=2e08468f46564602d01ae8a244661672
Args:
device_token: 设备 Token(从 get_device_token 获取)
sn: 设备序列号
user: 用户 ID
start_time: 录像开始时间(格式:YYYY-MM-DD HH:MM:SS,对应 AI 智搜的 st 字段)
stop_time: 录像结束时间(格式:YYYY-MM-DD HH:MM:SS,对应 AI 智搜的 et 字段)
uuid: 开放平台用户 uuid
app_key: 应用 appKey
app_secret: 应用密钥
movecard: 签名算法偏移量 (0-9)
channel: 通道号(默认 0)
stream_type: 码流类型(1=辅码流,2=主码流,默认 1)
endpoint: API 端点
Returns:
dict: API 响应,包含播放地址
"""
time_millis = generate_timestamp(movecard)
signature = generate_signature(uuid, app_key, app_secret, time_millis)
# 云存回放 API 端点
url = f"https://{endpoint}/gwp/v3/rtc/device/getVideoUrl/{device_token}"
headers = {
"uuid": uuid,
"appKey": app_key,
"timeMillis": time_millis,
"signature": signature,
"Content-Type": "application/json"
}
# 请求体:startTime 对应 st,stopTime 对应 et
body = {
"sn": sn,
"user": user,
"startTime": start_time,
"stopTime": stop_time,
"channel": channel,
"streamType": stream_type
}
req = Request(url, data=json.dumps(body).encode('utf-8'), headers=headers, method="POST")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
return result
except HTTPError as e:
return {"error": f"HTTP Error {e.code}: {e.reason}", "status": e.code}
except URLError as e:
return {"error": f"URL Error: {e.reason}"}
except Exception as e:
return {"error": str(e)}
def ai_search(sn, user, search_content, uuid, app_key, app_secret, movecard=0, endpoint="api.jftechws.com"):
"""
AI 智搜 - 搜索云存报警视频
API: POST https://api.jftechws.com/aisvr/v3/gateway/api/viewsearch/searchVideo
Args:
sn: 设备序列号
user: 用户 ID
search_content: 搜索内容
uuid: 开放平台用户 uuid
app_key: 应用 appKey
app_secret: 应用密钥
movecard: 签名算法偏移量 (0-9)
endpoint: API 端点
Returns:
dict: API 响应
"""
time_millis = generate_timestamp(movecard)
signature = generate_signature(uuid, app_key, app_secret, time_millis)
url = f"https://{endpoint}/aisvr/v3/gateway/api/viewsearch/searchVideo"
headers = {
"uuid": uuid,
"appKey": app_key,
"timeMillis": time_millis,
"signature": signature,
"Content-Type": "application/json"
}
body = {
"sn": sn,
"user": user,
"searchContent": search_content
}
req = Request(url, data=json.dumps(body).encode('utf-8'), headers=headers, method="POST")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
return result
except HTTPError as e:
return {"error": f"HTTP Error {e.code}: {e.reason}"}
except URLError as e:
return {"error": f"URL Error: {e.reason}"}
except Exception as e:
return {"error": str(e)}
def ai_search_and_playback(sn, user, search_content, uuid, app_key, app_secret, movecard=0,
video_index=0, endpoint="api.jftechws.com"):
"""
完整流程:AI 智搜 + 云存报警视频回放地址获取
1. 通过搜索视频获取云存报警信息视频列表
2. 提取录像开始时间(st 对应 startTime)和录像结束时间(et 对应 stopTime)
3. 通过设备序列号生成 deviceToken
4. 通过获取云存报警视频回放或下载地址接口,获取播放链接
Args:
sn: 设备序列号
user: 用户 ID
search_content: 搜索内容
uuid: 开放平台用户 uuid
app_key: 应用 appKey
app_secret: 应用密钥
movecard: 签名算法偏移量 (0-9)
video_index: 选择第几个视频(从 0 开始)
endpoint: API 端点
Returns:
dict: 包含搜索结果和播放地址
"""
print("=" * 70)
print("🎬 AI 智搜 + 云存报警视频回放地址获取")
print("=" * 70)
print()
print(f"设备 SN: {sn}")
print(f"用户:{user}")
print(f"搜索内容:{search_content}")
print(f"选择视频索引:{video_index}")
print()
# 步骤 1: AI 智搜 - 获取云存报警信息视频列表
print(">>> 步骤 1: 搜索视频获取云存报警信息视频列表...")
search_result = ai_search(sn, user, search_content, uuid, app_key, app_secret, movecard, endpoint)
if search_result.get('error'):
print(f"❌ AI 智搜失败:{search_result['error']}")
return {"error": search_result['error']}
if search_result.get('code') != 2000:
print(f"❌ AI 智搜失败:{search_result.get('msg', 'Unknown error')}")
return {"error": search_result.get('msg', 'Unknown error')}
videos = search_result.get('data', [])
if not videos:
print("❌ 未找到匹配的视频")
return {"error": "No videos found"}
print(f"✅ AI 智搜成功,找到 {len(videos)} 条视频")
print()
# 选择指定索引的视频
if video_index >= len(videos):
print(f"❌ 视频索引 {video_index} 超出范围(0-{len(videos)-1})")
return {"error": f"Video index {video_index} out of range"}
video = videos[video_index]
print(f"📹 选择第 {video_index + 1} 个视频:")
print(f" 录像开始时间(st):{video['st']}")
print(f" 录像结束时间(et):{video['et']}")
print(f" 匹配度:{video['matchRate']:.1%}")
print(f" 文件名:{video['indx']}")
print()
# 步骤 2: 通过设备序列号生成 deviceToken
print(">>> 步骤 2: 通过设备序列号生成 deviceToken...")
token_result = get_device_token(sn, uuid, app_key, app_secret, movecard, endpoint)
if token_result.get('error'):
print(f"❌ 获取 deviceToken 失败:{token_result['error']}")
return {"error": token_result['error'], "search_result": search_result}
if token_result.get('code') != 2000:
print(f"❌ 获取 deviceToken 失败:{token_result.get('msg', 'Unknown error')}")
return {"error": token_result.get('msg', 'Unknown error'), "search_result": search_result}
device_token = token_result['data'][0]['deviceToken']
print(f"✅ deviceToken 获取成功:{device_token[:30]}...")
print()
# 步骤 3: 获取云存报警视频回放地址
print(">>> 步骤 3: 获取云存报警视频回放地址...")
print(f" API 端点:POST /gwp/v3/rtc/device/getVideoUrl/{device_token[:30]}...")
print(f" startTime: {video['st']} (对应 st)")
print(f" stopTime: {video['et']} (对应 et)")
print()
playback_result = get_cloud_playback_url(
device_token=device_token,
sn=sn,
user=user,
start_time=video['st'], # st 对应 startTime
stop_time=video['et'], # et 对应 stopTime
uuid=uuid,
app_key=app_key,
app_secret=app_secret,
endpoint=endpoint
)
if playback_result.get('error'):
print(f"❌ 获取播放地址失败:{playback_result['error']}")
return {"error": playback_result['error'], "search_result": search_result}
if playback_result.get('code') != 2000:
print(f"❌ 获取播放地址失败:{playback_result.get('msg', 'Unknown error')}")
return {"error": playback_result.get('msg', 'Unknown error'), "search_result": search_result}
# 成功获取播放地址
play_url = playback_result['data'].get('url')
print("✅ 云存报警视频播放地址获取成功!")
print()
print("=" * 70)
print("🎬 播放地址")
print("=" * 70)
print()
print(f"📹 视频信息:")
print(f" 时间:{video['st']} - {video['et']}")
print(f" 时长:10 秒")
print(f" 匹配度:{video['matchRate']:.1%}")
print(f" 文件名:{video['indx']}")
print()
print(f"🔗 播放地址:")
print(f" {play_url}")
print()
print("=" * 70)
print("🎯 播放方式:")
print("=" * 70)
print()
print("1. VLC 播放器:")
print(f' vlc "{play_url}"')
print()
print("2. 网页播放(HLS.js):")
print(f' <video src="{play_url}" controls></video>')
print()
print("3. FFmpeg 下载:")
print(f' ffmpeg -i "{play_url}" -c copy video.mp4')
print()
return {
"success": True,
"search_result": search_result,
"playback_result": playback_result,
"video_info": video,
"play_url": play_url
}
def get_config_from_env():
"""从环境变量读取配置"""
required_vars = ['JF_UUID', 'JF_APPKEY', 'JF_APPSECRET', 'JF_MOVECARD', 'JF_SN']
missing_vars = [var for var in required_vars if not os.environ.get(var)]
if missing_vars:
raise Exception(f"缺少必需的环境变量:{', '.join(missing_vars)}\n"
f"请设置:export JF_UUID='...' JF_APPKEY='...' JF_APPSECRET='...' JF_MOVECARD=5 JF_SN='...'")
return {
'uuid': os.environ.get('JF_UUID'),
'appkey': os.environ.get('JF_APPKEY'),
'appsecret': os.environ.get('JF_APPSECRET'),
'movecard': int(os.environ.get('JF_MOVECARD', 5)),
'sn': os.environ.get('JF_SN'),
'user': os.environ.get('JF_USER', 'admin'),
'endpoint': os.environ.get('JF_ENDPOINT', 'api.jftechws.com')
}
def main():
parser = argparse.ArgumentParser(
description='AI 智搜 + 云存报警视频回放地址获取',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
环境变量:
JF_UUID 开放平台用户唯一标识 (必需)
JF_APPKEY 开放平台应用 Key (必需)
JF_APPSECRET 开放平台应用密钥 (必需)
JF_MOVECARD 签名算法偏移量,通常设为 5 (必需)
JF_SN 设备序列号 (必需)
JF_USER 用户 ID,默认 admin (可选)
JF_ENDPOINT API 端点,默认 api.jftechws.com (可选)
使用流程:
1. 通过搜索视频获取云存报警信息视频列表
2. 提取录像开始时间(st 对应 startTime)和录像结束时间(et 对应 stopTime)
3. 通过设备序列号生成 deviceToken
4. 通过获取云存报警视频回放或下载地址接口,获取播放链接
官方文档:
https://docs.jftech.com/docs?menusId=54582398fd8d4248962354e92ac2e47a&siderId=2e08468f46564602d01ae8a244661672
示例:
# 设置环境变量
export JF_UUID="your-uuid"
export JF_APPKEY="your-appkey"
export JF_APPSECRET="your-appsecret"
export JF_MOVECARD=5
export JF_SN="your-device-sn"
export JF_USER="admin"
# 完整流程:AI 智搜 + 播放地址
python get_playback_url.py --search "人" --video-index 0
# 直接获取指定时间的播放地址
python get_playback_url.py --start-time "2026-04-07 12:00:00" --stop-time "2026-04-07 12:45:00"
'''
)
parser.add_argument('--search', help='搜索内容(如"人"、"车"、"狗")')
parser.add_argument('--video-index', type=int, default=0, help='选择第几个视频(从 0 开始,默认 0)')
parser.add_argument('--start-time', help='录像开始时间(格式:YYYY-MM-DD HH:MM:SS)')
parser.add_argument('--stop-time', help='录像结束时间(格式:YYYY-MM-DD HH:MM:SS)')
args = parser.parse_args()
# 从环境变量读取配置
try:
config = get_config_from_env()
except Exception as e:
print(f'❌ 配置错误:{e}')
sys.exit(1)
# 如果有 search 参数,执行完整流程
if args.search:
result = ai_search_and_playback(
sn=config['sn'],
user=config['user'],
search_content=args.search,
uuid=config['uuid'],
app_key=config['appkey'],
app_secret=config['appsecret'],
movecard=config['movecard'],
video_index=args.video_index,
endpoint=config['endpoint']
)
# 如果有 start_time 和 stop_time 参数,直接获取播放地址
elif args.start_time and args.stop_time:
print(">>> 通过设备序列号生成 deviceToken...")
token_result = get_device_token(config['sn'], config['uuid'], config['appkey'], config['appsecret'], config['movecard'], config['endpoint'])
if token_result.get('error') or token_result.get('code') != 2000:
print(f"❌ 获取 deviceToken 失败:{token_result.get('error') or token_result.get('msg')}")
sys.exit(1)
device_token = token_result['data'][0]['deviceToken']
print(f"✅ deviceToken 获取成功")
print(">>> 获取云存报警视频回放地址...")
playback_result = get_cloud_playback_url(
device_token=device_token,
sn=config['sn'],
user=config['user'],
start_time=args.start_time,
stop_time=args.stop_time,
uuid=config['uuid'],
app_key=config['appkey'],
app_secret=config['appsecret'],
movecard=config['movecard'],
endpoint=config['endpoint']
)
if playback_result.get('error') or playback_result.get('code') != 2000:
print(f"❌ 获取播放地址失败:{playback_result.get('error') or playback_result.get('msg')}")
sys.exit(1)
play_url = playback_result['data'].get('url')
print(f"✅ 播放地址:{play_url}")
result = {"success": True, "play_url": play_url}
else:
parser.print_help()
sys.exit(1)
# 输出 JSON 结果
print()
print("=" * 70)
print("📋 JSON 结果")
print("=" * 70)
print()
print(json.dumps(result, indent=2, ensure_ascii=False))
sys.exit(0 if result.get('success') else 1)
if __name__ == '__main__':
main()
FILE:scripts/search_video.py
#!/usr/bin/env python3
"""
AI 智搜脚本 - 搜索云存报警视频
仅支持环境变量配置凭据,避免命令行泄露风险。
支持平台:JF Tech(杰峰)
用法:
export JF_UUID="your-uuid"
export JF_APPKEY="your-appkey"
export JF_APPSECRET="your-appsecret"
export JF_MOVECARD=5
export JF_SN="your-device-sn"
export JF_USER="admin"
python search_video.py --search "人"
python search_video.py --search "车"
python search_video.py --search "戴帽子的人"
"""
import argparse
import hashlib
import json
import os
import time
import sys
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
def generate_jftech_sign(appkey: str, secret: str, timestamp: int, movecard: int = 0) -> str:
"""
生成 JF Tech API 签名
Args:
appkey: 应用 appKey
secret: 应用密钥
timestamp: 时间戳(毫秒)
movecard: 签名算法偏移量 (0-9),用于增加签名安全性
"""
# 时间戳加上 movecard 偏移量
adjusted_timestamp = timestamp + movecard
sign_str = f"{appkey}{adjusted_timestamp}{secret}"
return hashlib.md5(sign_str.encode()).hexdigest()
def search_jftech(sn: str, user: str, query: str, uuid: str, appkey: str,
secret: str, authorization: str, movecard: int = 0) -> dict:
"""
调用 JF Tech AI 智搜 API
Args:
sn: 设备序列号
user: 用户 ID
query: 搜索内容(语义描述)
uuid: 开放平台用户 uuid
appkey: 应用 appKey
secret: 应用密钥
authorization: 用户 token
movecard: 签名算法偏移量 (0-9)
Returns:
API 响应字典
"""
url = "https://api.jftechws.com/aisvr/v3/gateway/api/viewsearch/searchVideo"
timestamp = int(time.time() * 1000)
sign = generate_jftech_sign(appkey, secret, timestamp, movecard)
headers = {
"Content-Type": "application/json",
"uuid": uuid,
"appkey": appkey,
"sign": sign,
"timestamp": str(timestamp),
"authorization": authorization
}
body = {
"sn": sn,
"user": user,
"searchContent": query
}
req = Request(url, data=json.dumps(body).encode(), headers=headers, method="POST")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
return result
except HTTPError as e:
return {"error": f"HTTP {e.code}", "message": e.read().decode()}
except URLError as e:
return {"error": "Network error", "message": str(e)}
def format_results(results: dict) -> str:
"""格式化搜索结果输出"""
if "error" in results:
return f"❌ 错误:{results.get('error', 'Unknown')}\n{results.get('message', '')}"
if results.get("code") != 2000:
return f"❌ API 错误码:{results.get('code')}\n{results.get('msg', '')}"
data = results.get("data", {})
videos = data.get("videos", [])
if not videos:
return "📭 未找到匹配的视频"
output = []
output.append(f"✅ 找到 {len(videos)} 个匹配的视频片段\n")
for i, video in enumerate(videos, 1):
output.append(f"📹 片段 {i}:")
output.append(f" 时间:{video.get('eventTime', 'N/A')}")
output.append(f" 匹配度:{video.get('matchRate', 0):.0%}")
output.append(f" 标签:{', '.join(video.get('queryTags', []))}")
output.append(f" 大小:{video.get('vidsz', 0) / 1024:.1f} KB")
if video.get('picfg') == 1:
output.append(f" 缩略图:有")
output.append("")
return "\n".join(output)
def get_config_from_env():
"""从环境变量读取配置"""
required_vars = ['JF_UUID', 'JF_APPKEY', 'JF_APPSECRET', 'JF_MOVECARD', 'JF_SN']
missing_vars = [var for var in required_vars if not os.environ.get(var)]
if missing_vars:
raise Exception(f"缺少必需的环境变量:{', '.join(missing_vars)}\n"
f"请设置:export JF_UUID='...' JF_APPKEY='...' JF_APPSECRET='...' JF_MOVECARD=5 JF_SN='...'")
return {
'uuid': os.environ.get('JF_UUID'),
'appkey': os.environ.get('JF_APPKEY'),
'secret': os.environ.get('JF_APPSECRET'),
'movecard': int(os.environ.get('JF_MOVECARD', 5)),
'sn': os.environ.get('JF_SN'),
'user': os.environ.get('JF_USER', 'admin'),
'endpoint': os.environ.get('JF_ENDPOINT', 'api.jftechws.com')
}
def main():
parser = argparse.ArgumentParser(
description="AI 智搜 - 搜索云存报警视频",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
环境变量:
JF_UUID 开放平台用户唯一标识 (必需)
JF_APPKEY 开放平台应用 Key (必需)
JF_APPSECRET 开放平台应用密钥 (必需)
JF_MOVECARD 签名算法偏移量,通常设为 5 (必需)
JF_SN 设备序列号 (必需)
JF_USER 用户 ID,默认 admin (可选)
JF_ENDPOINT API 端点,默认 api.jftechws.com (可选)
示例:
# 设置环境变量
export JF_UUID="your-uuid"
export JF_APPKEY="your-appkey"
export JF_APPSECRET="your-appsecret"
export JF_MOVECARD=5
export JF_SN="your-device-sn"
export JF_USER="admin"
# 搜索"人"相关的视频
python search_video.py --search "人"
# 搜索"车"相关的视频
python search_video.py --search "车"
# 搜索"戴帽子的人"
python search_video.py --search "戴帽子的人"
''')
parser.add_argument("--search", dest="query", required=True, help="搜索内容(语义描述)")
parser.add_argument("--json", action="store_true", help="输出 JSON 格式")
args = parser.parse_args()
try:
config = get_config_from_env()
except Exception as e:
print(f'❌ 配置错误:{e}')
sys.exit(1)
result = search_jftech(
sn=config['sn'],
user=config['user'],
query=args.query,
uuid=config['uuid'],
appkey=config['appkey'],
secret=config['secret'],
authorization='', # 如需 authorization 可从环境变量添加
movecard=config['movecard']
)
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(format_results(result))
if __name__ == "__main__":
main()
FILE:.clawhub/origin.json
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "jf-open-pro-ai-smart-search",
"installedVersion": "1.0.3",
"installedAt": 1775547600000
}
AI面试模拟与辅导。基于用户简历和JD生成定制化面试题库,逐题模拟面试并提供评分与示范回答。 触发词:面试模拟、面试准备、简历面试、interview prep、面试练习、 面试辅导、帮我准备面试、出面试题、面试题生成、interview practice、 mock interview、面试官模拟。
---
name: interview-prep
description: >
AI面试模拟与辅导。基于用户简历和JD生成定制化面试题库,逐题模拟面试并提供评分与示范回答。
触发词:面试模拟、面试准备、简历面试、interview prep、面试练习、
面试辅导、帮我准备面试、出面试题、面试题生成、interview practice、
mock interview、面试官模拟。
---
# Interview Prep — AI 面试模拟与辅导
## 核心原则(面试出题的专家思维)
## 面试场景决策树
```
用户类型?
├─ 校招/应届生 → 侧重:学习能力、基础扎实度、实习经历深挖、潜力信号
│ 题型配比:基础概念(40%) + 项目深挖(30%) + 行为题(20%) + 开放题(10%)
├─ 社招(3-8年) → 侧重:实战方法论、技术选型判断、跨团队协作、量化成果
│ 题型配比:项目深挖(35%) + 技术深度(25%) + 行为题(20%) + 情景题(15%) + 压力题(5%)
├─ 资深/管理岗 → 侧重:战略思维、团队建设、优先级判断、向上管理、失败复盘
│ 题型配比:战略判断(30%) + 管理场景(25%) + 深度项目复盘(25%) + 行为题(20%)
└─ 不明确 → 询问用户:目标岗位、工作年限、面试轮次(一面/二面/HR面)
```
## 追问深度控制
- 每道核心题最多追问3层,在第3层触底后切换到下一题
- 用户回答质量高(有细节、有数据、有反思)→ 可以跳过第1层直接进第2层
- 用户回答空泛("我们团队一起做的""就是按流程来的")→ 停在第1层追问细节,不升级
## NEVER List(面试出题的反模式)
- **NEVER** 给空洞的鼓励式点评("很棒!继续保持")——每条反馈必须指向具体可改进的点
- **NEVER** 忽略追问就急着出下一题——一道好题的价值在追问中体现
- **NEVER** 评分时只看"答案正确性"——面试考察表达力、结构化思维、抗压能力,不仅是对错
每道题回答完后,指出**最该改进的一个点**
## 单题快答模式
用户没有简历/JD,也没有要走完整流程,而是直接丢过来一道面试题时(例如"面试官问'你最大的缺点是什么'怎么回答"),跳过所有 Phase,直接给出满分示范回答。
**回答要求**:
- 以优秀面试者的第一人称视角写回答,不是以导师口吻讲道理
- 使用 STAR 结构(情境→任务→行动→结果),语言自然口语化
- 如果用户提到了具体岗位/行业,结合该岗位特性定制;否则给出通用但可改编的回答
- 回答末尾附加一两句"这道题面试官真正在考察什么"的简短分析
- 如果用户提供了自己的答案,先点评再给示范,格式同 Phase 3
## 工作流程
```
单题快答(用户直接问一道题)→ 直接示范回答
完整流程(用户有简历/JD)→ Phase 1 → Phase 2 → Phase 3 → Phase 4
```
### Phase 1: 输入解析
1. **确认目标岗位**(如用户未提供):
- 主动询问用户目标岗位名称(如"Java后端开发"、"产品经理"、"数据分析师"等)
- 询问工作年限和面试类型(校招/社招/内推)
- 如果用户提供了JD,从中提取岗位信息
2. 根据文件类型解析:
- PDF → `pdf` 工具
- DOCX → `exec` 用 python-docx/pandoc 转文本
- 图片 → `autoglm-image-recognition`
- URL → `web_fetch`
- 纯文本 → 直接使用
3. 提取结构化摘要(内存中):技能栈、项目经历、工作经历、年限、教育背景、量化成果 / 岗位职责、技能要求、团队信息
4. 展示摘要,用户确认后进入 Phase 2
### Phase 2: 生成题库
**MANDATORY 步骤 A — 面经搜索**:进入此阶段前,使用 `autoglm-websearch` 技能搜索真实面经:
- 搜索关键词:`"{目标岗位}" 面试经验 面经 2024 2025`(结合技能关键词)
- 如果用户提供了目标公司,先加上公司名搜索,再去掉公司名搜索
- 搜索 2-3 轮,尽量覆盖:高频考点、真题风格、面试官侧重点、候选人踩坑点
- 将搜索到的面经要点整理为参考素材(内存中),用于出题
**MANDATORY 步骤 B — 加载出题指令**:读取 [`references/generate-prompt.md`] 完整内容。**Do NOT Load** 其他 references 文件。
**出题时**:结合面经素材 + 用户简历 + JD要求,让题目更贴近真实面试风格。如果有高频考点在面经中反复出现,优先覆盖。
生成后一次性展示完整题库列表(题号、分类、考察点、难度),让用户了解全貌。
### Phase 3: 逐题模拟
每道题:出题 → 用户回答 → 点评(具体改进点 + 示范回答)→ 下一题
**"不会"处理**:用户表示不会/不懂时,立即切换为"完美面试者"视角,给出结合其简历背景的满分示范(STAR结构),温和引导继续。
随时支持:跳过、重答、换类似题、暂停/恢复、查看进度。
### Phase 4: 复盘报告
所有题目完成后,输出:
- 总体评分 + 各维度雷达
- 最强项/薄弱项(各举1-2个具体例子)
- 每题得分一览
- 具体可执行的提升建议(不是泛泛的"加强练习")
FILE:references/generate-prompt.md
# 题库生成指令
在 Phase 2 中,基于 Phase 1 的解析结果生成题库。
## 核心出题原则
**角度差异化**:同类题目从不同维度切入,避免重复考察同一点
## 题目分类与配额(参考,根据面试场景决策树调整)
| 分类 | 数量 | 出题策略 |
|------|------|----------|
| 破冰 | 1-2题 | "2分钟自我介绍,重点突出与这个岗位最相关的经历" |
| 项目深挖 | 5-7题 | 选简历中2-3个最相关项目,每项目2-3道追问链(概述→选型→困境) |
| 技术/专业 | 6-20题 | 基于JD必备技能,优先考察简历声明掌握但深度存疑的技能 |
| 行为/软技能 | 3-4题 | 团队协作、冲突处理、优先级管理——必须用STAR引导回答 |
| 情景假设 | 2-3题 | 构造该岗位日常工作的真实业务场景 |
| 压力/反向 | 2-3题 | 失败经历、缺点、离职原因 + 基于简历定制的压力追问 |
## 各分类出题要点
**项目深挖追问链模板**:
- 第1层:"你在XX项目中的角色是什么?核心贡献在哪?"
- 第2层:"当时为什么选A方案?和B方案比优劣是什么?"
- 第3层:"如果当时时间/资源减半,你会怎么调整?"
**技术/专业能力**:
- 概念理解 + 实际应用双角度——"你简历上写了熟悉XX,讲讲你在实际项目中怎么用XX解决XX问题的?"
- 对于JD强调但简历中体现不足的技能,重点出题考察真实掌握度
**行为/软技能**:
- 必须结合岗位特性出题——技术岗考"与产品经理意见不合",管理岗考"团队成员能力参差不齐"
- 引导用户用STAR回答,但不要在题目中直接说"请用STAR"(面试官不会这么提醒)
**情景假设**:
- 场景越具体越好——"入职第一周,接手的项目deadline还有两周但代码质量很差且没有文档,你怎么做?"
**压力/反向**:
- 基于简历定制——"你XX项目的成果,团队其他人贡献了多少?""你上一份工作为什么离开?"
## 题目格式
```
#N [分类] | 考察点:XXX | 难度:⭐~⭐⭐⭐⭐⭐
题目正文(具体、有场景感)
```
## 输出模板
```
📚 题库已生成(共N题)
━━━ 破冰 ━━━
#1 [破冰] ⭐⭐ | 考察点:自我介绍与岗位匹配
...
━━━ 项目深挖 ━━━
#2 [项目深挖] ⭐⭐ | 考察点:XX项目核心贡献
...
━━━ 技术/专业 ━━━
...
━━━ 行为/软技能 ━━━
...
━━━ 情景假设 ━━━
...
━━━ 压力/反向 ━━━
...
准备好了吗?回复"开始"进入模拟面试 🎯
```
FFmpeg 把 lipsync 视频按顺序 concat + 叠 BGM + 烧字幕 + 0.3s 淡入淡出,输出 final.mp4。触发词:视频拼接、成片合成、FFmpeg 拼接。
---
name: huo15-comic-edit
displayName: 火15 漫剧-成片拼接
description: FFmpeg 把 lipsync 视频按顺序 concat + 叠 BGM + 烧字幕 + 0.3s 淡入淡出,输出 final.mp4。触发词:视频拼接、成片合成、FFmpeg 拼接。
version: 0.1.0
---
# 火15 漫剧-成片拼接 Skill
> 所有片段 → 一条 final.mp4。纯本地 FFmpeg,无 API 成本。
---
## 输入 / 输出
```bash
python scripts/edit.py --project-dir output/demo
```
读取:
- `lipsync/S*.mp4`(或 fallback 到 `videos/S*.mp4`)
- `audio/S*_*.wav`(对白,与视频混入)
- `bgm.mp3`(整片 BGM)
- `script.json`(取对白文本+时间戳生成字幕)
输出:`final.mp4`
## 工作流
1. **拼接视频**:ffmpeg concat demuxer,按 scene id 顺序
2. **生成字幕**:从 script.json 计算每条对白的起止时间(按镜头 5s 均摊)→ `subtitle.srt`
3. **混音**:对白 + BGM(-20dB) + 原视频音轨(-6dB)
4. **烧字幕**:ffmpeg `subtitles` filter,国风样式(宋体/描边)
5. **转场**:相邻镜头 0.3s crossfade(可选)
## 字幕样式(subtitle.ass)
```
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, Bold, Outline, Alignment, MarginV
Style: Default,Source Han Serif SC,48,&H00FFFFFF,&H00000000,1,3,2,120
```
## 依赖
- 系统装 `ffmpeg` ≥ 5.0
- 字体:`Source Han Serif SC`(思源宋体)
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-comic-edit",
"version": "0.1.0"
}
FILE:scripts/_shared/__init__.py
"""huo15-ai-comics 共享库。
所有子 skill 通过 sys.path 注入此模块(dual-path fallback):
HERE = pathlib.Path(__file__).resolve()
# 优先 bundled(clawhub 独立安装场景:scripts/_shared/)
# fallback monorepo 根(本仓库 dev 场景:../../../_shared/)
for _cand in (HERE.parent / "_shared", HERE.parents[2] / "_shared"):
if (_cand / "config.py").exists():
sys.path.insert(0, str(_cand))
break
from cost_guard import CostGuard
from checkpoint import Checkpoint
from ark_api import ArkClient
from config import DEFAULTS, MODELS
publish.sh 会在发布前把本目录 cp 到每个 `<skill>/scripts/_shared/`,
让独立安装的 skill 仍能解析 import。
"""
FILE:scripts/_shared/ark_api.py
"""火山方舟 API 薄封装:Seedream 4.0 图 / Seedance 2.0 视频 / 豆包大模型 TTS.
2026-04 核对。文档:
- Seedance: https://www.volcengine.com/docs/82379/1520757
- Seedream: doubao-seedream-4-0-250828
- TTS: https://www.volcengine.com/docs/6561/97465
"""
from __future__ import annotations
import base64
import os
import pathlib
import time
import requests
try:
from config import ENDPOINTS, MODELS
except ImportError: # 兼容直接运行
ENDPOINTS = {
"ark_base": "https://ark.cn-beijing.volces.com/api/v3",
}
MODELS = {
"image": "doubao-seedream-4-0-250828",
"video": "doubao-seedance-2-0-260128",
"video_fast": "doubao-seedance-2-0-fast-260128",
"tts": "doubao-tts-bigtts",
}
class ArkClient:
def __init__(self, api_key: str | None = None):
self.api_key = api_key or os.environ.get("ARK_API_KEY", "")
if not self.api_key:
raise RuntimeError("缺少 ARK_API_KEY 环境变量")
self.base_url = ENDPOINTS["ark_base"]
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# ------------ 图像(Seedream 4.0)------------
def generate_image(
self,
prompt: str,
reference_images: list[str] | None = None,
size: str = "1024x1792",
model: str | None = None,
) -> str:
"""文生图 / 图生图。返回第一张图片 URL.
reference_images 传文件路径或 URL,会自动转 data URI.
"""
content = [{"type": "text", "text": prompt}]
for img in reference_images or []:
content.append({
"type": "image_url",
"image_url": {"url": self._image_to_data_uri(img)},
})
body = {
"model": model or MODELS["image"],
"content": content,
"size": size,
"watermark": False,
}
resp = requests.post(
f"{self.base_url}/images/generations",
headers=self.headers,
json=body,
timeout=120,
)
resp.raise_for_status()
data = resp.json()
# 兼容两种响应结构
if "data" in data and data["data"]:
return data["data"][0].get("url", "")
if "url" in data:
return data["url"]
raise RuntimeError(f"image response no url: {data}")
# ------------ 视频(Seedance 2.0)------------
def submit_video(
self,
prompt: str,
first_frame: str | None = None,
last_frame: str | None = None,
reference_image: str | None = None,
reference_video: str | None = None,
reference_audio: str | None = None,
duration: int = 5,
ratio: str = "9:16",
resolution: str = "720p",
generate_audio: bool = False,
return_last_frame: bool = False,
fast: bool = False,
seed: int | None = None,
) -> str:
"""提交视频任务,返回 task_id.
支持多种输入模态:first_frame / last_frame(首尾帧模式)/ reference_image /
reference_video / reference_audio。同一请求最多 9 图、3 视频、3 音频。
"""
content: list[dict] = [{"type": "text", "text": prompt}]
def _add_img(path: str, role: str):
content.append({
"type": "image_url",
"image_url": {"url": self._image_to_data_uri(path)},
"role": role,
})
if first_frame:
_add_img(first_frame, "first_frame")
if last_frame:
_add_img(last_frame, "last_frame")
if reference_image:
_add_img(reference_image, "reference_image")
if reference_video:
content.append({
"type": "video_url",
"video_url": {"url": reference_video if reference_video.startswith("http") else reference_video},
"role": "reference_video",
})
if reference_audio:
content.append({
"type": "audio_url",
"audio_url": {"url": reference_audio},
"role": "reference_audio",
})
body: dict = {
"model": MODELS["video_fast" if fast else "video"],
"content": content,
"ratio": ratio,
"resolution": resolution,
"duration": duration,
"watermark": False,
"return_last_frame": return_last_frame,
"generate_audio": generate_audio,
}
if seed is not None:
body["seed"] = seed
resp = requests.post(
f"{self.base_url}/contents/generations/tasks",
headers=self.headers,
json=body,
timeout=60,
)
resp.raise_for_status()
return resp.json()["id"]
def poll_video(self, task_id: str, timeout_s: int = 900, interval_s: int = 10) -> dict:
"""轮询视频任务,完成返回完整 dict(含 content.video_url / usage.total_tokens)."""
deadline = time.time() + timeout_s
while time.time() < deadline:
time.sleep(interval_s)
resp = requests.get(
f"{self.base_url}/contents/generations/tasks/{task_id}",
headers=self.headers,
timeout=30,
)
data = resp.json()
status = data.get("status")
if status == "succeeded":
return data
if status == "failed":
raise RuntimeError(f"视频任务失败: {data}")
raise TimeoutError(f"视频任务 {task_id} 超时({timeout_s}s)")
def download_video(self, url: str, out_path: pathlib.Path) -> pathlib.Path:
out_path = pathlib.Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
r = requests.get(url, stream=True, timeout=300)
with open(out_path, "wb") as f:
for chunk in r.iter_content(8192):
f.write(chunk)
return out_path
# ------------ 语音(豆包大模型 TTS)------------
def tts(
self,
text: str,
voice: str = "zh_female_sinong_conversation_wvae_bigtts",
out_path: pathlib.Path | str = "out.wav",
speed: float = 1.0,
emotion: str | None = None,
) -> pathlib.Path:
"""豆包 TTS 合成,写入 out_path.
voice 是音色 ID,格式 zh_{gender}_{name}_conversation_wvae_bigtts.
完整音色见 https://www.volcengine.com/docs/6561/97465.
注意:TTS 可能需要单独的 appid/cluster 凭证(走 openspeech.bytedance.com),
此处用 ark OpenAI 兼容接口作为简化,实际项目请核对授权方式。
"""
body = {
"model": MODELS["tts"],
"input": text,
"voice": voice,
"speed": speed,
"response_format": "wav",
}
if emotion:
body["emotion"] = emotion
resp = requests.post(
f"{self.base_url}/audio/speech",
headers=self.headers,
json=body,
timeout=60,
)
resp.raise_for_status()
out = pathlib.Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(resp.content)
return out
# ------------ 工具 ------------
@staticmethod
def _image_to_data_uri(path_or_url: str) -> str:
if path_or_url.startswith(("http://", "https://", "data:")):
return path_or_url
p = pathlib.Path(path_or_url)
ext = p.suffix.lower().lstrip(".") or "jpeg"
if ext == "jpg":
ext = "jpeg"
b64 = base64.b64encode(p.read_bytes()).decode()
return f"data:image/{ext};base64,{b64}"
def cost_from_video_response(data: dict, resolution: str = "720p") -> float:
"""从视频响应中精确计算成本(按 token)."""
from config import PRICING
tokens = data.get("usage", {}).get("total_tokens", 0)
return round(tokens * PRICING["video_per_mtoken"] / 1_000_000, 3)
FILE:scripts/_shared/checkpoint.py
"""阶段 checkpoint,支持失败续跑."""
from __future__ import annotations
import json
import pathlib
from dataclasses import dataclass, field, asdict
STEPS = [
"script",
"characters",
"storyboard",
"videos",
"dubs",
"lipsync",
"bgm",
"subtitle",
"edit",
]
@dataclass
class Checkpoint:
project_dir: pathlib.Path
status: dict = field(default_factory=dict)
def __post_init__(self):
self.project_dir = pathlib.Path(self.project_dir)
self._load()
def _path(self) -> pathlib.Path:
return self.project_dir / ".checkpoint.json"
def _load(self) -> None:
if self._path().exists():
self.status = json.loads(self._path().read_text())
else:
self.status = {s: "pending" for s in STEPS}
def save(self) -> None:
self.project_dir.mkdir(parents=True, exist_ok=True)
self._path().write_text(json.dumps(self.status, ensure_ascii=False, indent=2))
def is_done(self, step: str) -> bool:
return self.status.get(step) == "done"
def mark_done(self, step: str) -> None:
self.status[step] = "done"
self.save()
def mark_running(self, step: str) -> None:
self.status[step] = "running"
self.save()
def mark_failed(self, step: str, reason: str = "") -> None:
self.status[step] = f"failed: {reason}" if reason else "failed"
self.save()
def next_pending(self) -> str | None:
for s in STEPS:
v = self.status.get(s, "pending")
if v != "done":
return s
return None
def sub_mark(self, step: str, sub_id: str, value: str = "done") -> None:
"""用于镜头级别的细粒度 checkpoint,如 videos.S01=done."""
key = f"{step}.{sub_id}"
self.status[key] = value
self.save()
def sub_done(self, step: str, sub_id: str) -> bool:
return self.status.get(f"{step}.{sub_id}") == "done"
FILE:scripts/_shared/config.py
"""家族共享默认参数与模型 ID.
基于 2026-04 实测火山方舟/Kling/Suno 文档核对。
"""
DEFAULTS = {
"duration_total": 240, # 秒,3-5 分钟区间默认 4 分钟
"scene_duration": 5, # 单镜头秒数(Seedance 2.0 支持 4-15)
"ratio": "9:16", # 竖屏;Seedance 2.0 支持 9:16/16:9/4:3/3:4/21:9/1:1/adaptive
"resolution": "720p", # 默认 720p 省钱;可选 480p/720p/1080p/2K
"style": "三渲二国风",
"genre": "仙侠",
"cost_cap": 600.0,
"cost_warn_ratio": 0.7,
"cost_hard_ratio": 1.0,
"watermark": False,
"concurrency": 3,
"scene_retry": 2,
"fast_mode": False, # True 用 seedance-fast(便宜但质量低)
}
MODELS = {
"script": "claude-opus-4-7",
"image": "doubao-seedream-4-0-250828", # 方舟 Seedream 4.0
"video": "doubao-seedance-2-0-260128", # 方舟 Seedance 2.0 标准版
"video_fast": "doubao-seedance-2-0-fast-260128", # 方舟 Seedance 2.0 fast
"tts": "doubao-tts-bigtts", # 方舟豆包大模型 TTS
"lipsync": "kling-v2.6", # Kling 2.6 原生对口型
"music": "suno-v5.5", # Suno v5.5(via sunoapi.org)
}
# API 端点(2026-04 核对)
ENDPOINTS = {
"ark_base": "https://ark.cn-beijing.volces.com/api/v3",
"kling_base": "https://api.klingai.com/v1", # 官方;第三方 piapi.ai/fal.ai 亦可
"suno_base": "https://api.sunoapi.org", # 第三方(Suno 无公开官方 API)
}
# 单位价(元),2026-04 核对
# Seedance 2.0: token-based 46¥/M tokens(1080p 文生/图生视频),28¥/M tokens(视频参考)
# 按秒估算的便利值:token = duration × width × height × fps / 1024,用 token × 46 / 1e6 得元价
PRICING = {
"image_per_pic": 0.08, # Seedream 4.0 每张
"video_480p_per_sec": 0.50, # 480p 9:16 ≈ 5s ¥2.5
"video_720p_per_sec": 0.994, # 720p 9:16 ≈ 5s ¥5
"video_1080p_per_sec": 2.26, # 1080p 9:16 ≈ 5s ¥11.3
"video_fast_discount": 0.5, # fast 模型约 5 折
"video_per_mtoken": 46.0, # 官方 token 单价(M tokens)
"tts_per_char": 0.0008, # 豆包大模型 TTS
"lipsync_per_5s": 0.72, # Kling 官方 $0.1/5s ≈ ¥0.72
"bgm_per_track": 3.0, # sunoapi.org 第三方均价
}
def video_unit_price(resolution: str = "720p", fast: bool = False) -> float:
"""返回每秒元价,用于预估."""
base = {
"480p": PRICING["video_480p_per_sec"],
"720p": PRICING["video_720p_per_sec"],
"1080p": PRICING["video_1080p_per_sec"],
"2K": PRICING["video_1080p_per_sec"] * 1.5,
}.get(resolution, PRICING["video_720p_per_sec"])
if fast:
base *= PRICING["video_fast_discount"]
return base
# 国风专用提示词片段
STYLE_PRESETS = {
"三渲二国风": {
"prefix": "三渲二国风动画风格,工笔线条,中国传统审美",
"lighting": "柔和自然光,水墨晕染",
"palette": "青绿山水色调,朱砂点缀",
},
"水墨": {
"prefix": "中国水墨画风格,留白意境",
"lighting": "墨色浓淡",
"palette": "黑白灰为主,偶尔赭石",
},
"古风赛璐璐": {
"prefix": "日式赛璐璐+中国古风融合,清新明亮",
"lighting": "柔光晴天",
"palette": "淡雅国风配色",
},
"工笔": {
"prefix": "中国工笔画风格,细腻线条,重彩渲染",
"lighting": "平光",
"palette": "矿物颜料,金线勾勒",
},
}
GENRE_PRESETS = {
"仙侠": "门派纷争 / 修仙问道 / 御剑飞行 / 法宝秘术",
"宫斗": "宫廷权谋 / 妃嫔博弈 / 皇家礼仪 / 雕梁画栋",
"江湖": "快意恩仇 / 武林纷争 / 客栈酒肆 / 刀光剑影",
"志怪": "山海异兽 / 妖怪传说 / 古籍志异 / 诡谲氛围",
}
# 豆包大模型 TTS 音色(2026-04 核对,_conversation_wvae_bigtts 后缀=豆包大模型版)
# 实际音色 ID 较多,这里列常用。完整列表见 https://www.volcengine.com/docs/6561/97465
VOICE_PRESETS = {
# 男声
"male_young": "zh_male_ahu_conversation_wvae_bigtts", # 温暖阿虎(青年温润)
"male_mature": "zh_male_M392_conversation_wvae_bigtts", # 京腔侃爷(成熟磁性)
"male_elder": "zh_male_yanqing_conversation_wvae_bigtts", # 沉稳长者
# 女声
"female_young": "zh_female_sinong_conversation_wvae_bigtts", # 爽快思思(清新少女)
"female_mature": "zh_female_xiaohe_conversation_wvae_bigtts", # 湾湾小何(温柔熟女)
"female_elder": "zh_female_guniang_conversation_wvae_bigtts", # 端庄长辈
}
FILE:scripts/_shared/cost_guard.py
"""三级成本熔断.
- 硬熔断(hard):开工前估算超 cap 立即阻止
- 软预警(warn):运行时累计超 warn_ratio × cap 打印告警
- 降级建议(suggest):超 cap 时给出具体降级方案
"""
from __future__ import annotations
import json
import pathlib
import time
from dataclasses import dataclass, field, asdict
class BudgetExceeded(RuntimeError):
"""熔断触发异常;上层 catch 后走降级逻辑."""
@dataclass
class CostRecord:
step: str
item: str
cost: float
ts: float = field(default_factory=time.time)
@dataclass
class CostGuard:
cap: float = 600.0
warn_ratio: float = 0.7
project_dir: pathlib.Path | None = None
spent: float = 0.0
records: list[CostRecord] = field(default_factory=list)
_warned: bool = False
def preflight(self, estimated_total: float) -> None:
"""开工前硬熔断.
Raises:
BudgetExceeded: 估算超 cap 时抛出,附带降级建议字符串。
"""
if estimated_total > self.cap:
suggestions = self.downgrade_suggestions(estimated_total)
raise BudgetExceeded(
f"预估成本 ¥{estimated_total:.2f} 超过熔断上限 ¥{self.cap:.2f}。\n"
f"降级建议:\n{suggestions}\n"
f"如需继续,请提升 cost_cap 或采纳上述任一降级方案。"
)
def charge(self, step: str, item: str, cost: float) -> None:
"""扣费,触发软预警/硬熔断."""
self.spent += cost
self.records.append(CostRecord(step=step, item=item, cost=cost))
ratio = self.spent / self.cap if self.cap else 0
if ratio >= 1.0:
self._persist()
raise BudgetExceeded(
f"累计成本 ¥{self.spent:.2f} 达到熔断上限 ¥{self.cap:.2f}({step}/{item})。\n"
f"已完成步骤可从 checkpoint 续跑。"
)
if ratio >= self.warn_ratio and not self._warned:
self._warned = True
print(
f"⚠️ 成本预警:已花费 ¥{self.spent:.2f}({ratio:.0%} of ¥{self.cap:.2f}),"
f"即将触发熔断。"
)
self._persist()
def downgrade_suggestions(self, estimated_total: float) -> str:
"""生成降级方案文本."""
over = estimated_total - self.cap
lines = [
f" 1. 缩短总时长:每减 1 分钟约省 ¥{estimated_total / 5:.0f}",
f" 2. 减少镜头数:每删 1 镜约省 ¥{estimated_total / 48:.1f}",
f" 3. 关闭对口型:整片省 ¥{estimated_total * 0.37:.0f}(约 37%)",
f" 4. 视频降到 4 秒/镜:整片省 ¥{estimated_total * 0.2:.0f}(约 20%)",
f" 5. 提升 cost_cap 至 ¥{estimated_total * 1.1:.0f}(当前 ¥{self.cap:.0f},需多付 ¥{over:.2f})",
]
return "\n".join(lines)
def report(self) -> dict:
"""返回成本报告(可序列化)."""
by_step: dict[str, float] = {}
for r in self.records:
by_step[r.step] = by_step.get(r.step, 0.0) + r.cost
return {
"spent": round(self.spent, 2),
"cap": self.cap,
"ratio": round(self.spent / self.cap, 3) if self.cap else 0,
"by_step": {k: round(v, 2) for k, v in by_step.items()},
"record_count": len(self.records),
}
def _persist(self) -> None:
if not self.project_dir:
return
self.project_dir.mkdir(parents=True, exist_ok=True)
path = self.project_dir / ".cost.json"
data = {
"cap": self.cap,
"warn_ratio": self.warn_ratio,
"spent": self.spent,
"records": [asdict(r) for r in self.records],
}
path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
@classmethod
def load(cls, project_dir: pathlib.Path, cap: float = 600.0) -> "CostGuard":
"""从 project_dir/.cost.json 恢复."""
path = project_dir / ".cost.json"
guard = cls(cap=cap, project_dir=project_dir)
if path.exists():
data = json.loads(path.read_text())
guard.cap = data.get("cap", cap)
guard.warn_ratio = data.get("warn_ratio", 0.7)
guard.spent = data.get("spent", 0.0)
guard.records = [CostRecord(**r) for r in data.get("records", [])]
return guard
def estimate_total(
n_scenes: int,
n_characters: int,
total_chars: int,
scene_duration: int = 5,
resolution: str = "720p",
fast: bool = False,
enable_lipsync: bool = True,
enable_bgm: bool = True,
) -> dict:
"""估算全流程成本,返回明细 dict.
video 按 resolution 和 fast 标志换算每秒元价;lipsync 按 scene_duration/5s 换算。
"""
from config import PRICING, video_unit_price
image_cost = (n_scenes + n_characters * 3) * PRICING["image_per_pic"]
video_cost = n_scenes * scene_duration * video_unit_price(resolution, fast)
tts_cost = total_chars * PRICING["tts_per_char"]
lipsync_cost = (
n_scenes * (scene_duration / 5.0) * PRICING["lipsync_per_5s"]
if enable_lipsync else 0
)
bgm_cost = PRICING["bgm_per_track"] if enable_bgm else 0
total = image_cost + video_cost + tts_cost + lipsync_cost + bgm_cost
return {
"image": round(image_cost, 2),
"video": round(video_cost, 2),
"tts": round(tts_cost, 2),
"lipsync": round(lipsync_cost, 2),
"bgm": round(bgm_cost, 2),
"total": round(total, 2),
"resolution": resolution,
"fast": fast,
}
FILE:scripts/edit.py
"""成片拼接:concat + 混音 + 烧字幕."""
from __future__ import annotations
import argparse
import json
import pathlib
import subprocess
import sys
def pick_video_dir(project_dir: pathlib.Path) -> pathlib.Path:
"""优先用 lipsync/,fallback 到 videos/."""
lipsync = project_dir / "lipsync"
videos = project_dir / "videos"
if lipsync.exists() and any(lipsync.glob("S*.mp4")):
return lipsync
return videos
def write_concat_list(videos: list[pathlib.Path], out: pathlib.Path) -> None:
lines = [f"file '{v.resolve()}'" for v in videos]
out.write_text("\n".join(lines))
def build_srt(script: dict, out: pathlib.Path) -> None:
"""按镜头 5s 均摊,每条对白占该镜时段."""
entries = []
idx = 1
t = 0.0
scene_dur = script.get("scene_duration", 5)
for scene in script.get("scenes", []):
dialogues = scene.get("dialogue", [])
n = max(1, len(dialogues))
slot = scene_dur / n
for i, d in enumerate(dialogues):
start = t + i * slot
end = start + slot - 0.1
entries.append(
f"{idx}\n{fmt_ts(start)} --> {fmt_ts(end)}\n{d.get('text', '')}\n"
)
idx += 1
t += scene_dur
out.write_text("\n".join(entries))
def fmt_ts(s: float) -> str:
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = s % 60
return f"{h:02d}:{m:02d}:{sec:06.3f}".replace(".", ",")
def run(cmd: list[str]) -> None:
print(f" $ {' '.join(cmd[:6])} ...")
subprocess.run(cmd, check=True)
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--project-dir", required=True)
args = p.parse_args()
project_dir = pathlib.Path(args.project_dir)
script = json.loads((project_dir / "script.json").read_text())
video_dir = pick_video_dir(project_dir)
videos = sorted(video_dir.glob("S*.mp4"))
if not videos:
print(f"❌ 找不到视频片段: {video_dir}")
return 1
print(f"[edit] 从 {video_dir.name}/ 拼接 {len(videos)} 个镜头")
# 1. concat
concat_list = project_dir / "concat.txt"
write_concat_list(videos, concat_list)
concat_out = project_dir / "concat.mp4"
run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
"-i", str(concat_list),
"-c", "copy", str(concat_out),
])
# 2. 字幕
srt = project_dir / "subtitle.srt"
build_srt(script, srt)
# 3. 混 BGM + 烧字幕
bgm = project_dir / "bgm.mp3"
final = project_dir / "final.mp4"
vf = f"subtitles={srt}:force_style='FontName=Source Han Serif SC,FontSize=48,PrimaryColour=&H00FFFFFF&,OutlineColour=&H00000000&,Outline=3,MarginV=120'"
if bgm.exists():
# 混入 BGM(压到 -20dB)
run([
"ffmpeg", "-y",
"-i", str(concat_out),
"-i", str(bgm),
"-filter_complex",
f"[0:a]volume=1.0[a0];[1:a]volume=0.1[a1];[a0][a1]amix=inputs=2:duration=first[aout]",
"-map", "0:v", "-map", "[aout]",
"-vf", vf,
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-c:a", "aac", "-b:a", "192k",
str(final),
])
else:
run([
"ffmpeg", "-y",
"-i", str(concat_out),
"-vf", vf,
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-c:a", "copy",
str(final),
])
size_mb = final.stat().st_size / 1024 / 1024
print(f"✅ {final} ({size_mb:.1f} MB)")
return 0
if __name__ == "__main__":
sys.exit(main())