Skill: hunanjsd/obsidian-inbox-pipeline (version 1.0.1)

--- name: obsidian-inbox-pipeline description: > 将任意来源（AI 资讯、经济雷达、旅游日报、RSS、文章）自动采集、 结构化写入 Obsidian inbox 的完整流水线。支持 Telegram / 飞书推送 和 cron 定时执行。一套配置，永久自动沉淀知识。 --- # Obsidian Inbox Pipeline 打通外部信息源与 Obsidian 知识库的自动化流水线。安装后配置好环境变量，即可持续将资讯日报、文章、帖子等结构化沉淀到 Obsidian，无需手动整理。 ## 核心脚本 | 脚本 | 功能 | 关键参数 | |------|------|---------| | `capture.py` | 写入单条笔记到 inbox | `--type`, `--title`, `--source`, `--tags`, `--content` | | `query.py` | 搜索知识库（index + 正文） | `--query`, `--type`, `--tags`, `--limit` | | `review.py` | 生成 inbox 整理报告 | `--dry-run`（预览） | | `scripts/daily_pipeline.sh` | 三合一流水线参考 | radar 类型、名称、分类、来源 | ## 快速开始 ### 1. 安装依赖 ```bash # 克隆 skill 到本地 clawhub install obsidian-inbox-pipeline # 复制环境变量模板 cp references/.env.example .env # 编辑 .env，填入真实路径和凭证 ``` ### 2. 配置环境变量 ```bash # .env 文件示例 OBSIDIAN_VAULT_PATH=/Users/you/obsidian/MyKnowledge OBSIDIAN_INBOX_DIR=inbox TELEGRAM_BOT_TOKEN=123456:ABC-xxx TELEGRAM_CHAT_ID=987654321 FEISHU_APP_ID=cli_xxx FEISHU_APP_SECRET=xxx ``` > ⚠️ **安全提醒**：`.env` 文件请加入 `.gitignore`，不要提交到公开仓库。脚本从不硬编码任何凭证。 ### 3. 写入一条笔记（测试） ```bash export OBSIDIAN_VAULT_PATH=/path/to/vault python3 capture.py \ --type capture \ --title "测试笔记" \ --source "手动测试" \ --tags "[测试]" \ --content "这是正文内容，可以是任何文本..." ``` ### 4. 搜索知识库 ```bash export OBSIDIAN_VAULT_PATH=/path/to/vault # 搜索标题和摘要 python3 query.py --query "OpenClaw" --limit 5 # 只看 knowledge 类型 python3 query.py --query "AI Agent" --type knowledge --limit 5 # 搜索正文内容（需要 obsidian-cli） python3 query.py --query "OpenClaw" --include-content ``` ### 5. 生成 Inbox 整理报告 ```bash export OBSIDIAN_VAULT_PATH=/path/to/vault # 预览（不写文件） python3 review.py --dry-run # 正式生成（写入 notes/_review/inbox-review-YYYY-MM-DD.md） python3 review.py ``` ### 6. 定时自动运行（cron） ```bash # 每天 7:00 自动跑（来源和 cron 触发自行配置） 0 7 * * * cd /path/to/skill && \ OBSIDIAN_VAULT_PATH=/path/to/vault \ TELEGRAM_BOT_TOKEN=xxx \ TELEGRAM_CHAT_ID=xxx \ bash scripts/daily_pipeline.sh "ai-radar" "AI 资讯雷达" "AI" "🤖" "AI 资讯" \ >> /var/log/obsidian_pipeline.log 2>&1 ``` ## 流水线架构 ``` 外部来源（RSS / API / 爬虫 / 手动） │ ▼ ┌─────────────────────┐ │ 生成原始 Markdown │ │ (日报/文章/帖子) │ └────────┬────────────┘ │ ▼ ┌─────────────────────┐ │ capture.py │◄── 写入 Obsidian inbox │ (结构化模板写入) │ （type / tags / source / TL;DR） └────────┬────────────┘ │ ▼ ┌─────────────────────┐ │ rebuild_index.mjs │◄── 重建 .ai/index.json │ (Obsidian 官方脚本) │ （支持 query.py 搜索） └────────┬────────────┘ │ ▼ ┌─────────────────────┐ │ query.py / review │◄── 知识库检索 / inbox 整理 │ │ └─────────────────────┘ 可选通知渠道（均走环境变量，不硬编码）： ├── Telegram Bot（需 TELEGRAM_BOT_TOKEN） └── 飞书自建应用（需 FEISHU_APP_ID / FEISHU_APP_SECRET） ``` ## frontmatter 标准格式 写入的每条笔记使用统一 frontmatter，便于后续检索和分类： ```yaml --- type: capture # capture | daily-report | knowledge created: 2026-03-21 # 自动时间戳 tags: [AI, 日报] # 自定义标签 source: AI 资讯雷达 # 来源 status: inbox # inbox → knowledge → _archive --- ``` ## 配合 AI 雷达使用 配合 `ai-radar`、`economy-radar`、`travel-radar` 等 skill 使用时： ```bash # 三合一每日自动运行（配合 cron） 0 7 * * * \ source /path/to/.env && \ bash /path/to/obsidian-inbox-pipeline/scripts/daily_pipeline.sh \ "ai-radar" "AI 资讯雷达" "AI" "🤖" "AI 资讯" && \ bash /path/to/obsidian-inbox-pipeline/scripts/daily_pipeline.sh \ "economy-radar" "经济雷达" "经济" "📊" "经济资讯" && \ bash /path/to/obsidian-inbox-pipeline/scripts/daily_pipeline.sh \ "travel-radar" "旅游雷达" "旅游" "✈️" "旅游资讯" ``` ## 故障排查 | 问题 | 解决方案 | |------|---------| | `OBSIDIAN_VAULT_PATH` 报错 | 确保环境变量已设置，或安装 `obsidian-cli` | | inbox 写入了但 query 搜不到 | 运行 `node /path/to/vault/scripts/rebuild_index.mjs` 重建索引 | | Telegram 推送失败 | 确认 Bot Token 和 Chat ID 正确，Bot 已被用户发起过对话 | | 飞书推送失败 | 检查 app_id / app_secret 权限（需要发消息权限） | ## 依赖 - Python ≥ 3.8 - `obsidian-cli`（可选，vault 路径自动发现） - `node`（可选，索引重建脚本） - Telegram Bot Token（可选，通知推送） - 飞书自建应用凭证（可选，通知推送）

path: capture.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Obsidian Capture — 写入 Obsidian inbox
用法：
  python3 capture.py --type daily-report --title "标题" --source "来源" --tags "[AI,日报]" --category ai --content "正文"

环境变量：
  OBSIDIAN_VAULT_PATH   Obsidian Vault 根目录（必填）
  OBSIDIAN_INBOX_DIR    inbox 子目录（默认: inbox）
"""
import os
import sys
import argparse
from datetime import datetime

# 注入 skill 目录到 path，方便同目录 import
_SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
if _SKILL_DIR not in sys.path:
    sys.path.insert(0, _SKILL_DIR)

try:
    from config import get_vault, slugify
except ImportError:
    # 降级：定义最小版本（允许单独运行）
    def get_vault():
        path = os.environ.get("OBSIDIAN_VAULT_PATH", "").strip()
        if path:
            return path
        import subprocess
        try:
            r = subprocess.run(["obsidian-cli", "print-default", "--path-only"],
                              capture_output=True, text=True, timeout=5)
            if r.returncode == 0:
                return r.stdout.strip()
        except Exception:
            pass
        raise RuntimeError("OBSIDIAN_VAULT_PATH 未设置")
    def slugify(s):
        import re
        s = re.sub(r'[^\w\s-]', '', s)
        return re.sub(r'[-\s]+', '-', s).strip('-')[:40]

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


def build_capture_md(args) -> str:
    """构建 capture 格式 Markdown"""
    lines = [
        "---",
        f"type: {args.type}",
        f"created: {DATETIME}",
        f"tags: {args.tags or '[]'}",
        f"source: {args.source or ''}",
        "status: inbox",
        "---",
        "",
        f"# {args.title}",
        "",
        "## TL;DR",
        "",
        (args.tldr or "").strip(),
        "",
        "## 内容摘要",
        "",
        args.content.strip()[:3000] if args.content else "",
        "",
        "## 链接索引",
        "",
    ]

    import re
    if args.content:
        urls = re.findall(r'https?://[^\s\)）\]\n]+', args.content)
        for url in urls[:10]:
            fname = url.split('/')[-1][:50] or "链接"
            lines.append(f"- [{fname}]({url})")
        lines.append("")

    lines.extend([
        "## 下一步",
        "- [ ] ",
        "",
        f"<!-- source: {args.source or 'manual'} -->",
        f"<!-- category: {args.category or 'general'} -->",
    ])
    return '\n'.join(lines)


def write_note(path: str, content: str) -> str:
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, 'w', encoding='utf-8') as f:
        f.write(content)
    return path


def main():
    parser = argparse.ArgumentParser(description="写入 Obsidian inbox")
    parser.add_argument('--type', default='capture',
                        help='笔记类型: capture/daily-report/knowledge')
    parser.add_argument('--title', required=True, help='笔记标题')
    parser.add_argument('--source', default='', help='来源')
    parser.add_argument('--tags', default='[]', help='标签，如 [AI,日报]')
    parser.add_argument('--content', default='', help='正文内容')
    parser.add_argument('--tldr', default='', help='TL;DR 一句话摘要')
    parser.add_argument('--category', default='general',
                        help='分类: ai/economy/travel/investment/general')
    parser.add_argument('--dry-run', action='store_true', help='仅预览，不写入')
    args = parser.parse_args()

    vault = get_vault()
    inbox_dir = os.environ.get("OBSIDIAN_INBOX_DIR", "inbox").strip().lstrip("/")
    inbox_path = os.path.join(vault, inbox_dir)
    os.makedirs(inbox_path, exist_ok=True)

    content = build_capture_md(args)
    filename = f"{DATE}-{slugify(args.title)}.md"
    filepath = os.path.join(inbox_path, filename)

    if args.dry_run:
        print(f"[DRY RUN] 文件: {filename}")
        print(content)
        return

    write_note(filepath, content)
    print(f"✅ 已写入: {filepath}")
    print(f"📝 类型: {args.type} | 标签: {args.tags} | 来源: {args.source}")


if __name__ == "__main__":
    main()


path: config.py
content: #!/usr/bin/env python3
"""统一配置读取模块 — 所有脚本通过 get_config() 获取配置"""
import os
import re

# ── Env Var ────────────────────────────────────────────
def get_vault() -> str:
    """Obsidian Vault 根目录"""
    path = os.environ.get("OBSIDIAN_VAULT_PATH", "").strip()
    if path:
        return path
    # fallback: 尝试 obsidian-cli
    import subprocess
    try:
        result = subprocess.run(
            ["obsidian-cli", "print-default", "--path-only"],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except Exception:
        pass
    raise RuntimeError(
        "OBSIDIAN_VAULT_PATH 未设置，且 obsidian-cli 不可用。"
        "请设置环境变量或安装 obsidian-cli。"
    )


def get_telegram_config() -> dict:
    """Telegram Bot 配置（可选）"""
    [REDACTED_SECRET]"TELEGRAM_BOT_TOKEN", "").strip()
    chat_id = os.environ.get("TELEGRAM_CHAT_ID", "").strip()
    if not token or not chat_id:
        return {"enabled": False}
    return {"enabled": True, "token": token, "chat_id": chat_id}


def get_feishu_config() -> dict:
    """飞书 Bot 配置（可选）"""
    app_id = os.environ.get("FEISHU_APP_ID", "").strip()
    app_secret = os.environ.get("FEISHU_APP_SECRET", "").strip()
    receive_id = os.environ.get("FEISHU_RECEIVE_ID", "").strip()
    if not app_id or not app_secret:
        return {"enabled": False}
    return {"enabled": True, "app_id": app_id, "app_secret": app_secret, "receive_id": receive_id}


# ── 工具函数 ────────────────────────────────────────────
def sanitize_filename(title: str) -> str:
    """标题 → 安全文件名"""
    name = re.sub(r'[<>:"/\\|?*]', '', title)
    name = name[:60].strip()
    return name or "untitled"


def slugify(s: str) -> str:
    """字符串 → URL-safe slug"""
    s = re.sub(r'[^\w\s-]', '', s)
    s = re.sub(r'[-\s]+', '-', s).strip('-')
    return s[:40]


path: query.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Obsidian Query — 搜索 Obsidian 知识库
用法：
  python3 query.py --query "AI Agent" --type knowledge --limit 5
  python3 query.py --query "OpenClaw" --tags "AI-Agent,安全"

环境变量：
  OBSIDIAN_VAULT_PATH   Obsidian Vault 根目录（必填）
"""
import os
import sys
import re
import json
import argparse
import subprocess
from datetime import datetime
from typing import List, Dict

_SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
if _SKILL_DIR not in sys.path:
    sys.path.insert(0, _SKILL_DIR)

try:
    from config import get_vault
except ImportError:
    def get_vault():
        path = os.environ.get("OBSIDIAN_VAULT_PATH", "").strip()
        if path:
            return path
        import subprocess
        try:
            r = subprocess.run(["obsidian-cli", "print-default", "--path-only"],
                              capture_output=True, text=True, timeout=5)
            if r.returncode == 0:
                return r.stdout.strip()
        except Exception:
            pass
        raise RuntimeError("OBSIDIAN_VAULT_PATH 未设置")


INDEX_FILENAME = ".ai"  # folder containing index.json


def load_index(vault: str) -> Dict:
    """加载 .ai/index.json"""
    index_dir = os.path.join(vault, INDEX_FILENAME)
    index_path = os.path.join(index_dir, "index.json")
    if os.path.exists(index_path):
        with open(index_path) as f:
            return json.load(f)
    return {"items": []}


def search_index(query: str, index: Dict,
                 type_filter: str = None,
                 tags: List[str] = None,
                 limit: int = 10) -> List[Dict]:
    """在 index.json 中搜索"""
    query_lower = query.lower()
    results = []
    seen = set()

    for item in index.get("items", []):
        path = item.get("path", "")
        title = item.get("title", "").lower()
        excerpt = item.get("excerpt", "").lower()
        item_type = item.get("type", "")
        item_tags = item.get("tags", [])

        if "/_review/" in path or path.startswith("_"):
            continue
        if query_lower not in title and query_lower not in excerpt:
            continue
        if type_filter and item_type != type_filter:
            continue
        if tags and not any(t in item_tags for t in tags):
            continue
        if path in seen:
            continue
        seen.add(path)

        results.append(item)
        if len(results) >= limit:
            break
    return results


def search_content(query: str, vault: str, limit: int = 10) -> List[Dict]:
    """用 obsidian-cli 搜索正文内容"""
    try:
        result = subprocess.run(
            ["obsidian-cli", "search-content", query, "--path", vault],
            capture_output=True, text=True, timeout=15
        )
        lines = result.stdout.split('\n')
        results = []
        current = {}
        for line in lines:
            line = line.strip()
            if '::' in line and not line.startswith('#'):
                key, val = line.split('::', 1)
                current[key.strip()] = val.strip()
            elif line.startswith('==='):
                if current:
                    results.append(current)
                    current = {}
        return results[:limit]
    except Exception as e:
        print(f"obsidian-cli search-content error: {e}", file=sys.stderr)
        return []


def build_report(query: str, index_results: List[Dict],
                 content_results: List[Dict], vault: str) -> str:
    """构建输出报告"""
    lines = [
        f"## 📚 Obsidian 知识库搜索：「{query}」",
        "",
        f"找到 **{len(index_results)}** 条索引笔记 + **{len(content_results)}** 条内容匹配",
        "",
    ]

    if index_results:
        lines.append("### 📋 结构化笔记")
        for i, item in enumerate(index_results, 1):
            path = item.get('path', '')
            title = item.get('title', '无标题')
            item_type = item.get('type', '—')
            item_tags = ', '.join(item.get('tags', []) or [])
            excerpt = item.get('excerpt', '')[:150]
            updated = item.get('updated', 0)
            date = datetime.fromtimestamp(updated).strftime('%Y-%m-%d') if updated else '未知'
            lines.extend([
                f"**{i}. {title}**",
                f"- 类型：`{item_type}` | 标签：{item_tags or '无'} | 更新：{date}",
                f"- 路径：`{path}`",
                f"- 摘要：{excerpt}...",
                "",
            ])

    if content_results:
        lines.append("### 📄 内容全文匹配")
        for i, result in enumerate(content_results[:5], 1):
            match = result.get('match', '')
            path = result.get('path', result.get('file', ''))
            if len(match) > 300:
                match = match[:300] + '...'
            lines.extend([
                f"**{i}. {path}**",
                f"```\n{match}\n```",
                "",
            ])

    if not index_results and not content_results:
        lines.append("⚠️ 未找到相关内容。")

    lines.extend([
        "",
        "---",
        f"*知识库索引更新时间：{datetime.now().strftime('%Y-%m-%d %H:%M')}*",
        f"*Vault：{vault}*",
    ])
    return '\n'.join(lines)


def main():
    parser = argparse.ArgumentParser(description="搜索 Obsidian 知识库")
    parser.add_argument('--query', '-q', required=True, help='搜索关键词')
    parser.add_argument('--type', '-t', default=None,
                        help='按 type 过滤: knowledge/capture/daily-report')
    parser.add_argument('--tags', default=None, help='按标签过滤，逗号分隔')
    parser.add_argument('--limit', '-l', type=int, default=10, help='最大结果数')
    parser.add_argument('--include-content', action='store_true',
                       help='读取笔记正文作为上下文（更多 tokens）')
    parser.add_argument('--dry-run', action='store_true', help='仅预览')
    args = parser.parse_args()

    vault = get_vault()
    index = load_index(vault)
    tags = [t.strip() for t in args.tags.split(',')] if args.tags else None

    index_results = search_index(args.query, index,
                                  type_filter=args.type, tags=tags,
                                  limit=args.limit)
    content_results = []
    if args.include_content:
        content_results = search_content(args.query, vault, limit=args.limit)

    report = build_report(args.query, index_results, content_results, vault)
    print(report)


if __name__ == "__main__":
    main()


path: review.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Obsidian Review — 读取 inbox 内容，生成整理报告
用法：
  python3 review.py
  python3 review.py --dry-run

环境变量：
  OBSIDIAN_VAULT_PATH   Obsidian Vault 根目录（必填）
  OBSIDIAN_INBOX_DIR    inbox 子目录（默认: inbox）
"""
import os
import sys
import glob
from datetime import datetime

_SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
if _SKILL_DIR not in sys.path:
    sys.path.insert(0, _SKILL_DIR)

try:
    from config import get_vault
except ImportError:
    def get_vault():
        path = os.environ.get("OBSIDIAN_VAULT_PATH", "").strip()
        if path:
            return path
        import subprocess
        try:
            r = subprocess.run(["obsidian-cli", "print-default", "--path-only"],
                              capture_output=True, text=True, timeout=5)
            if r.returncode == 0:
                return r.stdout.strip()
        except Exception:
            pass
        raise RuntimeError("OBSIDIAN_VAULT_PATH 未设置")

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


def parse_frontmatter(text: str) -> tuple:
    """返回 (frontmatter_dict, body)"""
    if not text.startswith('---'):
        return {}, text
    end = text.find('\n---\n', 4)
    if end == -1:
        return {}, text
    fm_text = text[4:end]
    body = text[end+5:]
    fm = {}
    for line in fm_text.split('\n'):
        if ':' in line:
            key, val = line.split(':', 1)
            fm[key.strip()] = val.strip()
    return fm, body


def extract_title(body: str) -> str:
    for line in body.split('\n'):
        line = line.strip()
        if line.startswith('# ') and len(line) > 2:
            return line[2:].strip()
    return "无标题"


def read_inbox_items(vault: str, inbox_dir: str) -> list:
    inbox_path = os.path.join(vault, inbox_dir)
    pattern = os.path.join(inbox_path, "*.md")
    files = glob.glob(pattern)
    items = []
    for path in sorted(files, key=os.path.getmtime, reverse=True):
        try:
            with open(path, encoding='utf-8') as f:
                content = f.read()
            fm, body = parse_frontmatter(content)
            items.append({
                'path': os.path.basename(path),
                'title': extract_title(body),
                'type': fm.get('type', 'unknown'),
                'source': fm.get('source', 'unknown'),
                'tags': fm.get('tags', ''),
                'status': fm.get('status', ''),
                'size': len(content),
                'mtime': os.path.getmtime(path),
                'excerpt': body[:200].strip(),
            })
        except Exception as e:
            print(f"Error reading {path}: {e}", file=sys.stderr)
    return items


def build_review_report(items: list, vault: str) -> str:
    lines = [
        f"## 🗂️ Inbox Review — {DATE}",
        "",
        f"**Inbox 共 {len(items)} 条待整理内容**",
        "",
        "---",
        "",
    ]

    by_type = {}
    for item in items:
        t = item['type'] or 'unknown'
        by_type.setdefault(t, []).append(item)

    for note_type, notes in by_type.items():
        lines.append(f"### 📁 {note_type}（{len(notes)} 条）")
        for item in notes:
            path = item['path'].replace('.md', '')
            title = item['title']
            source = item['source']
            tags = item['tags']
            excerpt = item['excerpt'].replace('\n', ' ')[:100].strip()
            lines.append(
                f"**[[inbox/{path}|{title}]]**\n"
                f"- 来源：{source or '未知'} | 标签：{tags or '无'} | "
                f"大小：{item['size']} bytes\n"
                f"- 摘要：{excerpt}..."
            )
        lines.append("")

    lines.extend([
        "## ✍️ 整理建议",
        "",
        f"**{DATETIME}** 当前 inbox 共 **{len(items)}** 条内容待处理：",
        "",
    ])

    capture_items = by_type.get('capture', [])
    daily_items = [i for i in items
                   if '日报' in i['title'] or 'radar' in i['title'].lower()]

    if capture_items:
        lines.append(f"- **{len(capture_items)} 条 capture** 内容，建议阅读 TL;DR 后选择性归档")
    if daily_items:
        lines.append(f"- **{len(daily_items)} 条日报**，可选择性存入归档目录")

    lines.extend([
        "",
        "## 📋 下一步行动",
        "",
        "- [ ] 阅读每条 inbox 的 TL;DR",
        "- [ ] 将需要保留的内容打上 `type: knowledge` 标签",
        "- [ ] 将已整理内容移出 inbox（删除或归档）",
        "",
        f"*Vault：{vault}*",
    ])
    return '\n'.join(lines)


def main():
    import argparse
    parser = argparse.ArgumentParser(description="Inbox 整理")
    parser.add_argument('--dry-run', action='store_true', help='预览报告，不输出文件')
    args = parser.parse_args()

    vault = get_vault()
    inbox_dir = os.environ.get("OBSIDIAN_INBOX_DIR", "inbox").strip().lstrip("/")
    items = read_inbox_items(vault, inbox_dir)

    if not items:
        print("📭 Inbox 为空，无需整理。")
        return

    report = build_review_report(items, vault)
    print(report)

    if not args.dry_run:
        review_dir = os.path.join(vault, "notes", "_review")
        os.makedirs(review_dir, exist_ok=True)
        out_path = os.path.join(review_dir, f"inbox-review-{DATE}.md")
        with open(out_path, 'w', encoding='utf-8') as f:
            f.write(report)
        print(f"\n✅ Review 已写入：{out_path}")


if __name__ == "__main__":
    main()


path: scripts/daily_pipeline.sh
content: #!/usr/bin/env bash
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Obsidian Inbox Pipeline — 每日自动流水线参考实现
#
# 用法：
#   OBSIDIAN_VAULT_PATH=/path/to/vault \
#   TELEGRAM_BOT_TOKEN=xxx TELEGRAM_CHAT_ID=xxx \
#   ./daily_pipeline.sh "ai-radar" "AI 资讯雷达" "AI" "🤖" "AI 日报"
#
# 前置依赖：
#   - python3
#   - obsidian-cli（可选，用于 print-default）
#   - 目标 radar 技能的 main.py（如 ~/.openclaw/workspace/skills/ai-radar/main.py）
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
set -eo

# ── 校验环境变量 ──────────────────────────────────────
if [[ -z "${OBSIDIAN_VAULT_PATH:-}" ]]; then
  echo "❌ 缺少 OBSIDIAN_VAULT_PATH"
  exit 1
fi

SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
RADAR_TYPE="${1:?用法: $0 <radar_type> <name> <category> <emoji> <source>}"
RADAR_NAME="${2:?}"
CATEGORY="${3:?}"
EMOJI="${4:?}"
SOURCE="${5:?}"

TODAY="$(date '+%Y-%m-%d')"
TMP="/tmp/radar_${RADAR_TYPE}.md"

# ── Step 1：生成日报 ──────────────────────────────────
RADAR_MAIN="$SKILL_DIR/../${RADAR_TYPE}/main.py"
if [[ -f "$RADAR_MAIN" ]]; then
  python3 "$RADAR_MAIN" > "$TMP"
else
  echo "⚠️  未找到 $RADAR_MAIN，跳过生成步骤"
  exit 0
fi

# ── Step 2：写入 Obsidian inbox ──────────────────────
# 模板：用 capture.py 写入（结构化版本）
# 如 radar 提供了独立格式脚本，优先用其写入结构化 Obsidian 文件
FORMAT_SCRIPT="$SKILL_DIR/../${RADAR_TYPE}/format_push.py"
if [[ -f "$FORMAT_SCRIPT" ]]; then
  python3 "$FORMAT_SCRIPT" \
    "$RADAR_TYPE" "$RADAR_NAME" "$SOURCE" "$CATEGORY" "$EMOJI" "$TODAY" \
    > /dev/null 2>&1
  OBSIDIAN_PATH="$(python3 -c "
import sys
for line in open('$TMP'):
    if line.startswith('OBSIDIAN|'):
        print(line.split('|',1)[1].strip())
")"
else
  # fallback：用 capture.py 写入
  CONTENT="$(cat "$TMP")"
  python3 "$SKILL_DIR/capture.py" \
    --type daily-report \
    --title "$EMOJI $RADAR_NAME · $TODAY" \
    --source "$SOURCE" \
    --tags "[$CATEGORY, 日报, 知识沉淀]" \
    --category "${CATEGORY,,}" \
    --content "$CONTENT"
fi

# ── Step 3：（可选）推送 Telegram ─────────────────────
if [[ -n "${TELEGRAM_BOT_TOKEN:-}" && -n "${TELEGRAM_CHAT_ID:-}" ]]; then
  PUSH_FILE="/tmp/radar_push_${RADAR_TYPE}.txt"
  python3 - "$PUSH_FILE" << 'PYEOF'
import sys, json, urllib.request, urllib.parse

push_file = sys.argv[1]
with open(push_file) as f:
    text = f.read()

[REDACTED_SECRET]"TELEGRAM_BOT_TOKEN")
chat_id = os.environ.get("TELEGRAM_CHAT_ID")

# MarkdownV2 escape
for ch in ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']:
    text = text.replace(ch, '\\' + ch)

data = json.dumps({
    "chat_id": chat_id,
    "text": text,
    "parse_mode": "MarkdownV2"
}).encode()

req = urllib.request.Request(
    f"https://api.telegram.org/bot{token}/sendMessage",
    data=data,
    headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req, timeout=15)
print("✅ Telegram 推送成功")
PYEOF
fi

# ── Step 4：（可选）推送飞书 ──────────────────────────
if [[ -n "${FEISHU_APP_ID:-}" && -n "${FEISHU_APP_SECRET:-}" ]]; then
  python3 - "$RADAR_TYPE" << 'PYEOF'
import sys, json, urllib.request, ssl

radar_type = sys.argv[1]
ctx = ssl.create_default_context()
app_id = os.environ.get("FEISHU_APP_ID")
app_secret = os.environ.get("FEISHU_APP_SECRET")
receive_id = os.environ.get("FEISHU_RECEIVE_ID", "")

# get token
data = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode()
req = urllib.request.Request(
    "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
    data=data, headers={"Content-Type": "application/json"}
)
resp = json.loads(urllib.request.urlopen(req, timeout=15, context=ctx).read())
[REDACTED_SECRET]"tenant_access_token", "")

# read push file
push_file = f"/tmp/radar_push_{radar_type}.txt"
with open(push_file) as f:
    content = f.read()

# send
payload = json.dumps({
    "receive_id": receive_id or os.environ.get("FEISHU_RECEIVE_ID", ""),
    "receive_id_type": "open_id",
    "msg_type": "text",
    "content": json.dumps({"text": content})
}).encode()
req2 = urllib.request.Request(
    "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
    data=payload,
    headers={"Content-Type": "application/json", "Authorization": "Bearer " + token}
)
resp2 = json.loads(urllib.request.urlopen(req2, timeout=15, context=ctx).read())
print("✅ 飞书推送成功" if resp2.get("code") == 0 else f"❌ 飞书错误: {resp2.get('msg')}")
PYEOF
fi

# ── Step 5：（可选）重建索引 ──────────────────────────
if command -v node &> /dev/null; then
  REBUILD_SCRIPT="${OBSIDIAN_VAULT_PATH}/scripts/rebuild_index.mjs"
  if [[ -f "$REBUILD_SCRIPT" ]]; then
    node "$REBUILD_SCRIPT" 2>/dev/null && echo "✅ 索引已重建"
  fi
fi

echo "✅ $RADAR_NAME 流水线完成"
rm -f "$TMP"


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