Skill: glin23/creator-feed-watch (version 0.2.0)

--- name: creator-feed-watch version: 0.2.0 description: Track YouTube creators and analyze video content. Paste a YouTube link to get an AI-powered breakdown (summary, key points, why it matters), then optionally follow the creator for new upload notifications. categories: - media - youtube - monitoring - analysis --- # Creator Feed Watch ## Role: YouTube 视频分析机器人 ## 核心准则 1. 视觉优先：使用 Emoji 和加粗标题构建清晰的视觉锚点，方便移动端快速扫视。 2. 响应式分析： - 优先提取字幕，基于字幕做深度分析。 - 若无字幕，基于标题与描述生成分析，并在首行醒目标注："⚠️ *本分析基于标题/描述生成，未获取到字幕*" 3. 交互闭环：分析结束后必须停留，等待用户指令再触发 `add_follow`。 4. 语言自适应：根据用户发送消息的语言回复。用户用中文就用中文，用英文就用英文，用日语就用日语。模板结构和 Emoji 保持不变，仅翻译文字内容。 ## When to Use Use this skill when the user: - pastes a YouTube video link and wants to understand the content quickly - pastes a YouTube channel link, `@handle`, video URL, or channel ID - wants to analyze a video (summary, key points, why it matters) - wants to start following a creator for new upload notifications - wants a concise update card generated for a new YouTube upload ## 工作流执行手册 ### 场景 A：用户发送 YouTube 链接（分析 + 可选关注） Step 1. [数据抓取]：检测到 YouTube 链接后，立即调用 `analyze_video` 获取视频素材（标题、描述、频道名、字幕）。 Step 2. [精炼输出]：基于返回的素材，按照下方【机器人专用模板】生成内容分析。 Step 3. [触发转换]：在报告最末尾，以独立段落发送订阅询问。 Step 4. [逻辑判断]： - 用户回复"是/关注/好的/订阅"等肯定词 → 调用 `add_follow` 并回复"✅ 已关注「{频道名}」，新视频将自动推送给你。" - 用户回复其他内容或不回复 → 静默结束，不执行任何调用。 ### 场景 B：用户明确要求关注频道 直接调用 `add_follow`，无需先分析。 ### 场景 C：检查新视频更新 调用 `check_watchlist_updates` 或 `notify_watchlist_updates`。 ## 分析规则 - 核心要点必须具体，禁止空泛表述（如"内容很丰富""值得学习"） - 每个要点以动词开头，包含具体事实、数据或结论 - "为什么值得看"要指出视频的独特价值，而非泛泛推荐 - 如果有字幕，基于字幕全文做深度分析 - 如果没有字幕，基于标题和描述做分析 ## 机器人专用模板 📺 **{视频标题}** 👤 {频道名} 💡 **一句话总结** > {一句话概括，控制在25字以内} 🎯 **核心要点** • {要点1：动词开头，具体且精炼} • {要点2：具体的事实或数据} • {要点3：关键结论} • {要点4：补充细节} • {要点5：核心启发} 🔍 **为什么值得看** {用2句话说明视频的独特价值，如：避坑指南/效率神器/深度洞察} 🔗 [点击查看原视频]({视频链接}) --- 💡 **要关注「{频道名}」吗？** 以后有新视频会自动推送给你。（回复"是"开启关注） ## Examples - "帮我分析这个视频：https://www.youtube.com/watch?v=abc123def45" - "这个视频讲了什么？https://youtu.be/xyz789" - "Follow this creator: https://www.youtube.com/@OpenAI" - "Check for new uploads from my followed creators" ## Current Scope - Analyzes YouTube videos using transcript extraction and AI-powered content breakdown. - Normalizes YouTube inputs into structured metadata. - Resolves supported YouTube inputs into stable channel targets with the YouTube Data API. - Falls back to public YouTube pages and feeds when no API key is configured. - Fetches the latest uploads from a channel uploads playlist. - Persists a local watchlist of followed creators. - Checks the watchlist for newly published uploads and prepares update cards. - Attempts runtime delivery of update notifications when the host provides a send adapter. ## Limitations - Transcript extraction relies on YouTube's caption system; videos without captions will get a lighter analysis based on title and description only. - The free, no-key path relies on public YouTube pages and feeds, so it is more fragile than the API-backed path. - Custom `/c/...` URLs are still ambiguous and not resolved yet. - Watchlist persistence is currently a local JSON file, not a database. - Actual message delivery depends on the host runtime exposing a compatible send interface.

path: README.md
content: # Content Creator Skill

`Content Creator Skill` is a GitHub-ready OpenClaw skill scaffold for tracking YouTube creators from pasted links, normalizing follow targets, and preparing concise update cards for new uploads.

The published skill slug in this repo is `creator-feed-watch`. I kept the repo name broad and the skill name specific so the project can later grow beyond YouTube without confusing users about what the skill does today.

This repo is licensed under `MIT-0`, which matches ClawHub's publish-time license requirement.

## Install in OpenClaw

For local OpenClaw testing, install from the project path:

```bash
openclaw skill install --path ./content-creator-skill
```

OpenClaw's skill lifecycle and local install flow are documented here:
- https://openclawdoc.com/docs/skills/installation
- https://openclawdoc.com/docs/skills/creating-skills/

After you publish to GitHub or ClawHub, OpenClaw can install the skill from a repository or registry entry depending on the user's setup.

## Current MVP

- Normalize pasted YouTube inputs such as channel URLs, `@handles`, video URLs, and channel IDs
- Work out of the box without a YouTube API key by using public YouTube pages and feeds
- Resolve supported YouTube inputs into a stable channel target with the YouTube Data API
- Fetch the latest uploads from the channel's uploads playlist
- Persist a local JSON watchlist of followed creators
- Check the watchlist for newly published uploads
- Prepare and dispatch notification payloads when the runtime exposes a delivery adapter
- Prepare a channel-friendly update card from video metadata
- Provide a minimal, testable Node runtime entry point that matches the OpenClaw skill layout

## Not Implemented Yet

- Polling or webhook-based new video detection
- Transcript-aware summarization

## Suggested Roadmap

1. Add scheduled checks so OpenClaw can run this without a manual trigger.
2. Add a delivery adapter that posts updates into OpenClaw-connected channels.
3. Add transcript retrieval and better summaries when captions are available.
4. Add dedupe and retry behavior for production notification flows.

## Project Layout

```text
content-creator-skill/
  SKILL.md
  manifest.yaml
  config.example.yaml
  package.json
  src/
  test/
```

## Local Development

```bash
cd content-creator-skill
npm run check:release
npm test
```

## Configuration

This skill now has two modes:

- Zero-config mode: no API key required, using public YouTube pages and feeds
- Enhanced mode: optional `youtube_api_key` for stronger and more reliable resolution

Optional configuration:

```yaml
youtube_api_key: "YOUR_YOUTUBE_DATA_API_KEY"
summary_style: "brief"
watchlist_path: "./data/watchlist.json"
delivery_target: "telegram:my-channel"
```

If you do provide a key, it can be supplied through OpenClaw skill config or `YOUTUBE_API_KEY`.

## Watchlist Actions

- `add_follow`: resolve a YouTube input and persist it in the local watchlist
- `list_follows`: return all persisted follow targets
- `remove_follow`: remove a creator by channel ID, URL, handle, username, or original source input
- `check_watchlist_updates`: fetch the newest upload for each followed creator and return only the new ones
- `notify_watchlist_updates`: build notification messages for new uploads and deliver them through a runtime adapter when available

## Example Inputs

Add a creator:

```json
{
  "action": "add_follow",
  "source": "https://www.youtube.com/@OpenAI"
}
```

Check for new uploads without sending:

```json
{
  "action": "check_watchlist_updates"
}
```

Notify new uploads through the runtime adapter:

```json
{
  "action": "notify_watchlist_updates",
  "delivery_target": "telegram:my-channel"
}
```

## Delivery Behavior

`notify_watchlist_updates` tries the following runtime interfaces in order:

- `context.deliver(notification)`
- `context.notify(notification)`
- `context.sendMessage(text, notification)`
- `context.channel.send(text, notification)`
- `context.channels.send(target, notification)`

If none of those exist, the skill returns the prepared notifications without marking them as seen.

## Publish to ClawHub

When you are ready to publish:

```bash
npm run release:dry
openclaw clawhub login
openclaw skill publish .
```

ClawHub will validate the manifest and skill metadata, run tests, bundle the skill, and publish a new version. Publishing behavior and semver rules are documented here:
- https://openclawdoc.com/docs/skills/clawhub/
- https://openclawdoc.com/docs/skills/creating-skills/


path: package.json
content: {
  "name": "content-creator-skill",
  "version": "0.2.0",
  "description": "OpenClaw skill scaffold for tracking YouTube creators and preparing update summaries.",
  "license": "MIT-0",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "check:release": "node scripts/check-release.js",
    "release:dry": "npm run check:release && npm test",
    "test": "node --test"
  }
}


path: manifest.yaml
content: name: creator-feed-watch
version: 0.2.0
author: lee
license: MIT-0
runtime: node
entry: src/index.js
min_openclaw_version: "2026.1.0"
description: Track YouTube creators from pasted links, normalize watch targets, and prepare concise update summaries for new uploads.

categories:
  - media
  - youtube
  - monitoring

config:
  - key: youtube_api_key
    type: string
    required: false
    description: Optional YouTube Data API key for richer and more reliable creator resolution and upload checks.
  - key: summary_style
    type: string
    required: false
    default: brief
    description: Preferred summary mode for prepared update cards.
  - key: watchlist_path
    type: string
    required: false
    description: Optional path for the persisted watchlist JSON file.
  - key: delivery_target
    type: string
    required: false
    description: Optional default destination identifier used by the runtime delivery adapter.

input:
  type: object
  properties:
    action:
      type: string
      enum:
        - normalize_target
        - fetch_latest_uploads
        - add_follow
        - list_follows
        - remove_follow
        - check_watchlist_updates
        - notify_watchlist_updates
        - prepare_update
        - analyze_video
        - healthcheck
      description: Skill operation to run.
    source:
      type: string
      description: Raw YouTube input such as a channel URL, handle, video URL, or channel ID.
    limit:
      type: number
      description: Number of recent uploads to fetch, capped at 10.
    seed_latest:
      type: boolean
      description: When adding a follow, seed the latest video as already seen to avoid an immediate historical alert.
    mark_seen:
      type: boolean
      description: When checking the watchlist, persist the newest video as seen after reporting updates.
    delivery_target:
      type: string
      description: Optional destination override for the notification action.
    channel_name:
      type: string
      description: Display name of the creator channel when preparing an update card.
    video_title:
      type: string
      description: Title of the new upload.
    video_description:
      type: string
      description: Description text for the new upload.
    video_url:
      type: string
      description: Public video URL.
    published_at:
      type: string
      description: ISO timestamp for when the video was published.
  required:
    - action

output:
  type: object
  properties:
    ok:
      type: boolean
      description: Indicates whether the action completed successfully.
    action:
      type: string
      description: The action that was executed.
    normalized:
      type: object
      description: Structured information about a normalized YouTube target.
    target:
      type: object
      description: Resolved YouTube channel details for the tracked creator.
    follow:
      type: object
      description: A persisted watchlist follow record.
    follows:
      type: array
      description: Persisted watchlist follow records.
    uploads:
      type: array
      description: Recent uploads returned from the channel uploads playlist.
    latest_upload:
      type: object
      description: The newest upload returned by the fetch action.
    latest_update:
      type: object
      description: A prepared update-card payload for the newest upload.
    checked:
      type: array
      description: Per-follow check results returned by watchlist update scans.
    updates:
      type: array
      description: Follow targets with newly detected uploads.
    notifications:
      type: array
      description: Notification payloads prepared for runtime delivery.
    deliveries:
      type: array
      description: Per-notification runtime delivery results.
    count:
      type: number
      description: Number of follow targets affected by the action.
    update_count:
      type: number
      description: Number of follow targets with new uploads.
    delivered_count:
      type: number
      description: Number of notifications delivered successfully.
    failed_count:
      type: number
      description: Number of notifications that failed delivery.
    adapter:
      type: string
      description: Name of the runtime delivery adapter used for notifications.
    storage_path:
      type: string
      description: Path to the persisted watchlist JSON file.
    update:
      type: object
      description: Prepared update-card payload for a new upload.
    video_id:
      type: string
      description: The YouTube video ID of the analyzed video.
    video_url:
      type: string
      description: Public URL of the analyzed video.
    title:
      type: string
      description: Title of the analyzed video.
    description:
      type: string
      description: Description text of the analyzed video.
    channel_id:
      type: string
      description: YouTube channel ID of the video creator.
    channel_name:
      type: string
      description: Display name of the video creator channel.
    has_transcript:
      type: boolean
      description: Whether a transcript was successfully extracted.
    transcript_language:
      type: string
      description: Language code of the extracted transcript.
    transcript_text:
      type: string
      description: Full transcript text extracted from video captions.
    message:
      type: string
      description: Human-readable status or error message.


path: config.example.yaml
content: youtube_api_key: "YOUR_YOUTUBE_DATA_API_KEY"
summary_style: "brief"
watchlist_path: "./data/watchlist.json"
delivery_target: "telegram:my-channel"


path: src/delivery.js
content: function buildNotification(update, options = {}) {
  const target = update.follow || {};
  const latestUpload = update.latest_upload || {};
  const latestUpdate = update.latest_update || {};

  return {
    kind: "creator_update",
    delivery_target: options.delivery_target || null,
    channel_id: target.channel_id || null,
    title: latestUpdate.headline || latestUpload.title || "New creator update",
    text: latestUpdate.summary || latestUpload.title || "New upload detected",
    markdown: latestUpdate.markdown || latestUpload.title || "New upload detected",
    video_url: latestUpload.video_url || null,
    published_at: latestUpload.published_at || null,
    metadata: {
      creator_title: target.title || latestUpload.channel_title || null,
      video_id: latestUpload.video_id || null,
      source_input: target.source_input || null,
    },
  };
}

function getDeliveryAdapter(context = {}) {
  if (typeof context.deliver === "function") {
    return {
      kind: "context.deliver",
      send: (notification) => context.deliver(notification),
    };
  }

  if (typeof context.notify === "function") {
    return {
      kind: "context.notify",
      send: (notification) => context.notify(notification),
    };
  }

  if (typeof context.sendMessage === "function") {
    return {
      kind: "context.sendMessage",
      send: (notification) =>
        context.sendMessage(notification.markdown || notification.text, notification),
    };
  }

  if (context.channel && typeof context.channel.send === "function") {
    return {
      kind: "context.channel.send",
      send: (notification) =>
        context.channel.send(notification.markdown || notification.text, notification),
    };
  }

  if (context.channels && typeof context.channels.send === "function") {
    return {
      kind: "context.channels.send",
      send: (notification) =>
        context.channels.send(notification.delivery_target, notification),
    };
  }

  return null;
}

async function dispatchNotifications(notifications, options = {}, context = {}) {
  const adapter = getDeliveryAdapter(context);

  if (!adapter) {
    return {
      ok: false,
      adapter: null,
      delivered_count: 0,
      failed_count: 0,
      deliveries: [],
      message: "No runtime delivery adapter is available in the current skill context.",
    };
  }

  const deliveries = [];

  for (const notification of notifications) {
    try {
      const result = await adapter.send({
        ...notification,
        delivery_target: options.delivery_target || notification.delivery_target || null,
      });

      deliveries.push({
        ok: true,
        adapter: adapter.kind,
        notification,
        result: result ?? null,
      });
    } catch (error) {
      deliveries.push({
        ok: false,
        adapter: adapter.kind,
        notification,
        error: error.message || "Delivery failed.",
      });
    }
  }

  const deliveredCount = deliveries.filter((item) => item.ok).length;
  const failedCount = deliveries.length - deliveredCount;

  return {
    ok: failedCount === 0,
    adapter: adapter.kind,
    delivered_count: deliveredCount,
    failed_count: failedCount,
    deliveries,
    message:
      failedCount === 0
        ? `Delivered ${deliveredCount} notification(s).`
        : `Delivered ${deliveredCount} notification(s), ${failedCount} failed.`,
  };
}

module.exports = {
  buildNotification,
  dispatchNotifications,
  getDeliveryAdapter,
};


path: src/watchlist-store.js
content: const fs = require("node:fs/promises");
const path = require("node:path");

const STORE_VERSION = 1;

function getWatchlistPath(context = {}) {
  const configured = String(context.config?.watchlist_path || "").trim();
  if (configured) {
    return path.resolve(configured);
  }

  return path.resolve(process.cwd(), "data", "watchlist.json");
}

async function ensureParentDir(filePath) {
  await fs.mkdir(path.dirname(filePath), { recursive: true });
}

function normalizeStore(data) {
  if (!data || typeof data !== "object") {
    return {
      version: STORE_VERSION,
      follows: [],
    };
  }

  return {
    version: Number(data.version) || STORE_VERSION,
    follows: Array.isArray(data.follows) ? data.follows : [],
  };
}

async function loadStore(context = {}) {
  const filePath = getWatchlistPath(context);

  try {
    const raw = await fs.readFile(filePath, "utf8");
    return {
      path: filePath,
      data: normalizeStore(JSON.parse(raw)),
    };
  } catch (error) {
    if (error.code === "ENOENT") {
      return {
        path: filePath,
        data: normalizeStore(null),
      };
    }
    throw error;
  }
}

async function saveStore(context = {}, data) {
  const filePath = getWatchlistPath(context);
  await ensureParentDir(filePath);
  await fs.writeFile(filePath, JSON.stringify(normalizeStore(data), null, 2) + "\n", "utf8");
  return filePath;
}

function toFollowRecord(target, options = {}) {
  const now = new Date().toISOString();
  return {
    channel_id: target.channel_id,
    title: target.title || "",
    description: target.description || "",
    canonical_url: target.canonical_url || "",
    custom_url: target.custom_url || "",
    handle: target.handle || "",
    username: target.username || "",
    uploads_playlist_id: target.uploads_playlist_id || "",
    source_input: options.source_input || "",
    source_kind: options.source_kind || "",
    followed_at: options.followed_at || now,
    updated_at: now,
    last_checked_at: options.last_checked_at || null,
    last_seen_video_id: options.last_seen_video_id || null,
    last_seen_published_at: options.last_seen_published_at || null,
    last_seen_title: options.last_seen_title || null,
  };
}

function canonicalMatchValue(value) {
  return String(value || "").trim().toLowerCase();
}

function matchFollowBySource(follow, source) {
  const needle = canonicalMatchValue(source);
  if (!needle) {
    return false;
  }

  return [
    follow.channel_id,
    follow.canonical_url,
    follow.custom_url,
    follow.handle,
    follow.username,
    follow.source_input,
  ]
    .map(canonicalMatchValue)
    .some((value) => value && value === needle);
}

async function listFollows(context = {}) {
  const store = await loadStore(context);
  return {
    path: store.path,
    follows: store.data.follows,
  };
}

async function upsertFollow(target, options = {}, context = {}) {
  const store = await loadStore(context);
  const follows = [...store.data.follows];
  const index = follows.findIndex((follow) => follow.channel_id === target.channel_id);
  const existing = index >= 0 ? follows[index] : null;

  const nextRecord = toFollowRecord(target, {
    source_input: options.source_input,
    source_kind: options.source_kind,
    followed_at: existing?.followed_at,
    last_checked_at: options.last_checked_at ?? existing?.last_checked_at,
    last_seen_video_id: options.last_seen_video_id ?? existing?.last_seen_video_id,
    last_seen_published_at:
      options.last_seen_published_at ?? existing?.last_seen_published_at,
    last_seen_title: options.last_seen_title ?? existing?.last_seen_title,
  });

  if (index >= 0) {
    follows[index] = nextRecord;
  } else {
    follows.push(nextRecord);
  }

  await saveStore(context, {
    version: STORE_VERSION,
    follows,
  });

  return {
    created: index === -1,
    follow: nextRecord,
    count: follows.length,
  };
}

async function removeFollow(source, context = {}) {
  const store = await loadStore(context);
  const follows = [...store.data.follows];
  const index = follows.findIndex((follow) => matchFollowBySource(follow, source));

  if (index === -1) {
    return {
      removed: false,
      count: follows.length,
      follow: null,
    };
  }

  const [removedFollow] = follows.splice(index, 1);
  await saveStore(context, {
    version: STORE_VERSION,
    follows,
  });

  return {
    removed: true,
    count: follows.length,
    follow: removedFollow,
  };
}

module.exports = {
  getWatchlistPath,
  listFollows,
  loadStore,
  matchFollowBySource,
  removeFollow,
  saveStore,
  upsertFollow,
};


path: src/index.js
content: const { normalizeYouTubeInput } = require("./youtube");
const { fetchLatestUploads, resolveYouTubeTarget } = require("./youtube-api");
const { buildNotification, dispatchNotifications } = require("./delivery");
const { summarizeVideoMetadata } = require("./summary");
const { listFollows, removeFollow, upsertFollow } = require("./watchlist-store");
const { analyzeVideo } = require("./analyze");
const { fetchTranscript } = require("./transcript");

function buildLatestUpdate(latestUpload, target) {
  if (!latestUpload) {
    return null;
  }

  return summarizeVideoMetadata({
    channel_name: latestUpload.channel_title || target?.title,
    video_title: latestUpload.title,
    video_description: latestUpload.description,
    video_url: latestUpload.video_url,
    published_at: latestUpload.published_at,
  });
}

async function scanWatchlistUpdates(context = {}, options = {}) {
  const watchlist = await listFollows(context);
  const markSeen = options.mark_seen !== false;
  const updates = [];
  const checked = [];

  for (const follow of watchlist.follows) {
    try {
      const latest = await fetchLatestUploads(follow.channel_id, { limit: 1 }, context);
      const latestUpload = latest.latest_upload;
      const hasUpdate = Boolean(
        latestUpload &&
          latestUpload.video_id &&
          latestUpload.video_id !== follow.last_seen_video_id,
      );

      let storedFollow = follow;
      if (latestUpload) {
        const stored = await upsertFollow(
          latest.target,
          {
            source_input: follow.source_input || follow.canonical_url || follow.channel_id,
            source_kind: follow.source_kind || "channel",
            last_checked_at: new Date().toISOString(),
            last_seen_video_id:
              hasUpdate && !markSeen ? follow.last_seen_video_id : latestUpload.video_id,
            last_seen_published_at:
              hasUpdate && !markSeen
                ? follow.last_seen_published_at
                : latestUpload.published_at,
            last_seen_title:
              hasUpdate && !markSeen ? follow.last_seen_title : latestUpload.title,
          },
          context,
        );
        storedFollow = stored.follow;
      }

      checked.push({
        follow: storedFollow,
        latest_upload: latestUpload,
        has_update: hasUpdate,
      });

      if (hasUpdate) {
        updates.push({
          follow: storedFollow,
          latest_upload: latestUpload,
          latest_update: buildLatestUpdate(latestUpload, latest.target),
        });
      }
    } catch (error) {
      checked.push({
        follow,
        has_update: false,
        error: error.message || "Failed to check follow target.",
      });
    }
  }

  return {
    count: watchlist.follows.length,
    update_count: updates.length,
    checked,
    updates,
  };
}

async function markUpdatesSeen(updates, context = {}) {
  for (const update of updates) {
    const follow = update.follow || {};
    const latestUpload = update.latest_upload || {};
    await upsertFollow(
      {
        channel_id: follow.channel_id,
        title: follow.title,
        description: follow.description,
        canonical_url: follow.canonical_url,
        custom_url: follow.custom_url,
        handle: follow.handle,
        username: follow.username,
        uploads_playlist_id: follow.uploads_playlist_id,
      },
      {
        source_input: follow.source_input || follow.canonical_url || follow.channel_id,
        source_kind: follow.source_kind || "channel",
        last_checked_at: new Date().toISOString(),
        last_seen_video_id: latestUpload.video_id || follow.last_seen_video_id,
        last_seen_published_at: latestUpload.published_at || follow.last_seen_published_at,
        last_seen_title: latestUpload.title || follow.last_seen_title,
      },
      context,
    );
  }
}

async function handler(input = {}, context = {}) {
  const action = input.action;

  if (!action) {
    return {
      ok: false,
      message: "Missing required input: action.",
    };
  }

  if (action === "healthcheck") {
    return {
      ok: true,
      action,
      message: "creator-feed-watch is ready.",
      config_present: Object.keys(context.config || {}).length > 0,
    };
  }

  if (action === "normalize_target") {
    if (!input.source) {
      return {
        ok: false,
        action,
        message: "Missing required input: source.",
      };
    }

    const normalized = normalizeYouTubeInput(input.source);
    return {
      ok: normalized.ok,
      action,
      normalized,
      message: normalized.ok
        ? "YouTube input normalized."
        : normalized.message || "Unable to normalize input.",
    };
  }

  if (action === "prepare_update") {
    const update = summarizeVideoMetadata(input);
    return {
      ok: true,
      action,
      update,
      message: "Prepared update card from video metadata.",
    };
  }

  if (action === "fetch_latest_uploads") {
    if (!input.source) {
      return {
        ok: false,
        action,
        message: "Missing required input: source.",
      };
    }

    try {
      const latest = await fetchLatestUploads(
        input.source,
        { limit: input.limit },
        context,
      );

      return {
        ok: true,
        action,
        target: latest.target,
        normalized: latest.normalized,
        uploads: latest.uploads,
        latest_upload: latest.latest_upload,
        latest_update: buildLatestUpdate(latest.latest_upload, latest.target),
        message: latest.uploads.length
          ? `Fetched ${latest.uploads.length} recent upload(s).`
          : "No uploads were returned for this channel.",
      };
    } catch (error) {
      return {
        ok: false,
        action,
        message: error.message || "Failed to fetch latest YouTube uploads.",
      };
    }
  }

  if (action === "list_follows") {
    const watchlist = await listFollows(context);
    return {
      ok: true,
      action,
      follows: watchlist.follows,
      count: watchlist.follows.length,
      storage_path: watchlist.path,
      message: watchlist.follows.length
        ? `Loaded ${watchlist.follows.length} follow target(s).`
        : "Watchlist is empty.",
    };
  }

  if (action === "add_follow") {
    if (!input.source) {
      return {
        ok: false,
        action,
        message: "Missing required input: source.",
      };
    }

    try {
      const resolved = await resolveYouTubeTarget(input.source, context);
      let latest = null;

      if (input.seed_latest !== false) {
        latest = await fetchLatestUploads(input.source, { limit: 1 }, context);
      }

      const stored = await upsertFollow(
        resolved.target,
        {
          source_input: input.source,
          source_kind: resolved.normalized.kind,
          last_checked_at: latest ? new Date().toISOString() : null,
          last_seen_video_id: latest?.latest_upload?.video_id || null,
          last_seen_published_at: latest?.latest_upload?.published_at || null,
          last_seen_title: latest?.latest_upload?.title || null,
        },
        context,
      );

      return {
        ok: true,
        action,
        created: stored.created,
        follow: stored.follow,
        latest_upload: latest?.latest_upload || null,
        latest_update: buildLatestUpdate(latest?.latest_upload || null, resolved.target),
        count: stored.count,
        message: stored.created
          ? "Added creator to watchlist."
          : "Updated existing creator in watchlist.",
      };
    } catch (error) {
      return {
        ok: false,
        action,
        message: error.message || "Failed to add follow target.",
      };
    }
  }

  if (action === "remove_follow") {
    if (!input.source) {
      return {
        ok: false,
        action,
        message: "Missing required input: source.",
      };
    }

    const removed = await removeFollow(input.source, context);
    return {
      ok: removed.removed,
      action,
      follow: removed.follow,
      count: removed.count,
      message: removed.removed
        ? "Removed creator from watchlist."
        : "No matching follow target was found.",
    };
  }

  if (action === "analyze_video") {
    if (!input.source) {
      return {
        ok: false,
        action,
        message: "Missing required input: source.",
      };
    }

    try {
      const analysis = await analyzeVideo(input.source, context);
      return {
        ok: true,
        action,
        video_id: analysis.video_id,
        video_url: analysis.video_url,
        title: analysis.title,
        description: analysis.description,
        published_at: analysis.published_at,
        channel_id: analysis.channel_id,
        channel_name: analysis.channel_name,
        has_transcript: analysis.has_transcript,
        transcript_language: analysis.transcript_language,
        transcript_text: analysis.transcript_text,
        message: analysis.has_transcript
          ? "Video materials collected with transcript."
          : "Video materials collected without transcript (captions unavailable).",
      };
    } catch (error) {
      return {
        ok: false,
        action,
        message: error.message || "Failed to analyze video.",
      };
    }
  }

  if (action === "check_watchlist_updates") {
    const scanned = await scanWatchlistUpdates(context, {
      mark_seen: input.mark_seen,
    });

    return {
      ok: true,
      action,
      count: scanned.count,
      update_count: scanned.update_count,
      checked: scanned.checked,
      updates: scanned.updates,
      message: scanned.update_count
        ? `Found ${scanned.update_count} follow target(s) with new uploads.`
        : "No new uploads found across the watchlist.",
    };
  }

  if (action === "notify_watchlist_updates") {
    const scanned = await scanWatchlistUpdates(context, {
      mark_seen: false,
    });

    if (scanned.update_count === 0) {
      return {
        ok: true,
        action,
        count: scanned.count,
        update_count: 0,
        notifications: [],
        deliveries: [],
        message: "No new uploads found across the watchlist.",
      };
    }

    const notifications = scanned.updates.map((update) =>
      buildNotification(update, {
        delivery_target: input.delivery_target || context.config?.delivery_target || null,
      }),
    );

    const delivery = await dispatchNotifications(
      notifications,
      {
        delivery_target: input.delivery_target || context.config?.delivery_target || null,
      },
      context,
    );

    if (delivery.ok && input.mark_seen !== false) {
      await markUpdatesSeen(scanned.updates, context);
    }

    return {
      ok: delivery.ok,
      action,
      count: scanned.count,
      update_count: scanned.update_count,
      notifications,
      deliveries: delivery.deliveries,
      delivered_count: delivery.delivered_count,
      failed_count: delivery.failed_count,
      adapter: delivery.adapter,
      message: delivery.message,
    };
  }

  return {
    ok: false,
    action,
    message: `Unsupported action: ${action}`,
  };
}

module.exports = handler;
module.exports._internal = {
  analyzeVideo,
  buildNotification,
  dispatchNotifications,
  fetchLatestUploads,
  fetchTranscript,
  listFollows,
  markUpdatesSeen,
  normalizeYouTubeInput,
  removeFollow,
  resolveYouTubeTarget,
  scanWatchlistUpdates,
  summarizeVideoMetadata,
  upsertFollow,
};


path: src/transcript.js
content: function getFetchImpl(context = {}) {
  if (typeof context.fetch === "function") {
    return context.fetch;
  }

  if (typeof fetch === "function") {
    return fetch.bind(globalThis);
  }

  throw new Error("No fetch implementation is available in the current runtime.");
}

function extractCaptionTracks(html) {
  const match = String(html || "").match(/"captionTracks"\s*:\s*(\[[\s\S]*?\])/);
  if (!match) {
    return [];
  }

  try {
    return JSON.parse(match[1]);
  } catch {
    return [];
  }
}

function pickBestTrack(tracks) {
  if (!Array.isArray(tracks) || tracks.length === 0) {
    return null;
  }

  const zhTrack = tracks.find(
    (t) => t.languageCode && /^zh/i.test(t.languageCode),
  );
  if (zhTrack) {
    return zhTrack;
  }

  const enTrack = tracks.find(
    (t) => t.languageCode && /^en/i.test(t.languageCode),
  );
  if (enTrack) {
    return enTrack;
  }

  return tracks[0];
}

function parseTranscriptXml(xml) {
  const texts = [];
  const regex = /<text\b[^>]*>([\s\S]*?)<\/text>/gi;
  let match;

  while ((match = regex.exec(xml)) !== null) {
    const decoded = match[1]
      .replace(/&amp;/g, "&")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
      .replace(/&#x27;/g, "'")
      .replace(/\n/g, " ")
      .trim();

    if (decoded) {
      texts.push(decoded);
    }
  }

  return texts.join(" ");
}

async function fetchTranscript(videoUrl, context = {}) {
  const fetchImpl = getFetchImpl(context);

  const videoIdMatch = String(videoUrl || "").match(
    /(?:v=|youtu\.be\/)([A-Za-z0-9_-]{11})/,
  );
  if (!videoIdMatch) {
    return { ok: false, reason: "invalid_video_url" };
  }

  const watchUrl = `https://www.youtube.com/watch?v=${videoIdMatch[1]}`;

  let html;
  try {
    const response = await fetchImpl(watchUrl, {
      headers: {
        "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
        accept: "text/html",
      },
    });
    html = await response.text();
  } catch {
    return { ok: false, reason: "fetch_page_failed" };
  }

  const tracks = extractCaptionTracks(html);
  if (tracks.length === 0) {
    return { ok: false, reason: "no_captions_available" };
  }

  const track = pickBestTrack(tracks);
  if (!track || !track.baseUrl) {
    return { ok: false, reason: "no_suitable_caption_track" };
  }

  let xml;
  try {
    const response = await fetchImpl(track.baseUrl, {
      headers: { accept: "application/xml, text/xml" },
    });
    xml = await response.text();
  } catch {
    return { ok: false, reason: "fetch_captions_failed" };
  }

  const text = parseTranscriptXml(xml);
  if (!text) {
    return { ok: false, reason: "empty_captions" };
  }

  return {
    ok: true,
    text,
    language: track.languageCode || null,
    source: "caption_track",
  };
}

module.exports = {
  fetchTranscript,
  extractCaptionTracks,
  pickBestTrack,
  parseTranscriptXml,
};


path: src/youtube.js
content: const CHANNEL_ID_RE = /^UC[A-Za-z0-9_-]{22}$/;
const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;

function maybeUrl(raw) {
  if (!raw) {
    return null;
  }

  const trimmed = String(raw).trim();
  const withProtocol =
    trimmed.startsWith("http://") ||
    trimmed.startsWith("https://") ||
    trimmed.startsWith("@") ||
    CHANNEL_ID_RE.test(trimmed)
      ? trimmed
      : trimmed.startsWith("youtube.com/") ||
          trimmed.startsWith("www.youtube.com/") ||
          trimmed.startsWith("m.youtube.com/") ||
          trimmed.startsWith("youtu.be/")
        ? `https://${trimmed}`
        : trimmed;

  try {
    return new URL(withProtocol);
  } catch {
    return null;
  }
}

function normalizeYouTubeInput(source) {
  const raw = String(source || "").trim();
  if (!raw) {
    return {
      ok: false,
      message: "Missing YouTube input.",
    };
  }

  if (CHANNEL_ID_RE.test(raw)) {
    return {
      ok: true,
      kind: "channel",
      raw,
      channel_id: raw,
      canonical_url: `https://www.youtube.com/channel/${raw}`,
      unresolved: false,
      follow_ready: true,
    };
  }

  if (raw.startsWith("@")) {
    return {
      ok: true,
      kind: "handle",
      raw,
      handle: raw,
      canonical_url: `https://www.youtube.com/${raw}`,
      unresolved: true,
      follow_ready: false,
      recommended_next_step: "Resolve this handle to a stable channel ID before persisting it.",
    };
  }

  const url = maybeUrl(raw);
  if (!url) {
    return {
      ok: false,
      raw,
      message: "Input is not a recognized YouTube URL, handle, or channel ID.",
    };
  }

  const host = url.hostname.replace(/^www\./, "").replace(/^m\./, "");

  if (host === "youtu.be") {
    const videoId = url.pathname.split("/").filter(Boolean)[0];
    return {
      ok: Boolean(videoId && VIDEO_ID_RE.test(videoId)),
      kind: "video",
      raw,
      video_id: videoId,
      canonical_url: `https://www.youtube.com/watch?v=${videoId}`,
      unresolved: true,
      follow_ready: false,
      recommended_next_step: "Resolve this video to its parent channel before adding it to a watchlist.",
    };
  }

  if (host !== "youtube.com") {
    return {
      ok: false,
      raw,
      message: "Only YouTube URLs are supported.",
    };
  }

  const parts = url.pathname.split("/").filter(Boolean);
  const first = parts[0] || "";
  const second = parts[1] || "";

  if (first === "channel" && CHANNEL_ID_RE.test(second)) {
    return {
      ok: true,
      kind: "channel",
      raw,
      channel_id: second,
      canonical_url: `https://www.youtube.com/channel/${second}`,
      unresolved: false,
      follow_ready: true,
    };
  }

  if (first.startsWith("@")) {
    return {
      ok: true,
      kind: "handle",
      raw,
      handle: first,
      canonical_url: `https://www.youtube.com/${first}`,
      unresolved: true,
      follow_ready: false,
      recommended_next_step: "Resolve this handle to a stable channel ID before persisting it.",
    };
  }

  if (first === "watch") {
    const videoId = url.searchParams.get("v");
    return {
      ok: Boolean(videoId && VIDEO_ID_RE.test(videoId)),
      kind: "video",
      raw,
      video_id: videoId,
      canonical_url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined,
      unresolved: true,
      follow_ready: false,
      recommended_next_step: "Resolve this video to its parent channel before adding it to a watchlist.",
    };
  }

  if (first === "shorts" || first === "live") {
    return {
      ok: Boolean(second && VIDEO_ID_RE.test(second)),
      kind: "video",
      raw,
      video_id: second,
      canonical_url: second ? `https://www.youtube.com/watch?v=${second}` : undefined,
      unresolved: true,
      follow_ready: false,
      recommended_next_step: "Resolve this video to its parent channel before adding it to a watchlist.",
    };
  }

  if (first === "playlist") {
    const playlistId = url.searchParams.get("list");
    return {
      ok: Boolean(playlistId),
      kind: "playlist",
      raw,
      playlist_id: playlistId,
      canonical_url: playlistId
        ? `https://www.youtube.com/playlist?list=${playlistId}`
        : undefined,
      unresolved: true,
      follow_ready: false,
      recommended_next_step: "Playlists are not stable creator watch targets yet.",
    };
  }

  if (first === "user") {
    return {
      ok: true,
      kind: "legacy_username",
      raw,
      username: second,
      canonical_url: `https://www.youtube.com/user/${second}`,
      unresolved: true,
      follow_ready: false,
      recommended_next_step: "Resolve this legacy username to a stable channel ID before persisting it.",
    };
  }

  if (first === "c") {
    return {
      ok: true,
      kind: "custom_channel_url",
      raw,
      custom_path: `c/${second}`.replace(/\/$/, ""),
      canonical_url: `https://www.youtube.com/${first}/${second}`,
      unresolved: true,
      follow_ready: false,
      recommended_next_step: "Resolve this custom channel URL to a stable channel ID before persisting it.",
    };
  }

  return {
    ok: false,
    raw,
    message: "YouTube input format is recognized, but this path is not handled yet.",
  };
}

module.exports = {
  CHANNEL_ID_RE,
  VIDEO_ID_RE,
  normalizeYouTubeInput,
};


path: src/analyze.js
content: const { normalizeYouTubeInput } = require("./youtube");
const { fetchLatestUploads, resolveYouTubeTarget } = require("./youtube-api");
const { fetchTranscript } = require("./transcript");

async function analyzeVideo(source, context = {}) {
  const normalized = normalizeYouTubeInput(source);

  if (!normalized.ok) {
    throw new Error(normalized.message || "Unable to normalize YouTube input.");
  }

  let videoId = null;
  let videoTitle = null;
  let videoDescription = null;
  let videoUrl = null;
  let publishedAt = null;
  let channelName = null;
  let channelId = null;

  if (normalized.kind === "video") {
    videoId = normalized.video_id;
    videoUrl = normalized.canonical_url;

    const resolved = await resolveYouTubeTarget(source, context);
    channelId = resolved.target.channel_id;
    channelName = resolved.target.title;

    const uploads = await fetchLatestUploads(resolved.target.channel_id, { limit: 10 }, context);
    const match = uploads.uploads.find((u) => u.video_id === videoId);

    if (match) {
      videoTitle = match.title;
      videoDescription = match.description;
      publishedAt = match.published_at;
      channelName = channelName || match.channel_title;
    }
  } else {
    const resolved = await resolveYouTubeTarget(source, context);
    channelId = resolved.target.channel_id;
    channelName = resolved.target.title;

    const uploads = await fetchLatestUploads(source, { limit: 1 }, context);
    const latest = uploads.latest_upload;

    if (latest) {
      videoId = latest.video_id;
      videoTitle = latest.title;
      videoDescription = latest.description;
      videoUrl = latest.video_url;
      publishedAt = latest.published_at;
      channelName = channelName || latest.channel_title;
    }
  }

  if (!videoId) {
    throw new Error("Could not find a video to analyze for this input.");
  }

  const transcriptUrl = videoUrl || `https://www.youtube.com/watch?v=${videoId}`;
  const transcript = await fetchTranscript(transcriptUrl, context);

  return {
    video_id: videoId,
    video_url: videoUrl || `https://www.youtube.com/watch?v=${videoId}`,
    title: videoTitle || "Untitled video",
    description: videoDescription || "",
    published_at: publishedAt || null,
    channel_id: channelId,
    channel_name: channelName || "Unknown",
    has_transcript: transcript.ok,
    transcript_language: transcript.ok ? transcript.language : null,
    transcript_text: transcript.ok ? transcript.text : null,
  };
}

module.exports = {
  analyzeVideo,
};


path: src/youtube-api.js
content: const { CHANNEL_ID_RE, normalizeYouTubeInput } = require("./youtube");

const YOUTUBE_API_BASE = "https://www.googleapis.com/youtube/v3";
const YOUTUBE_FEED_BASE = "https://www.youtube.com/feeds/videos.xml";

function getFetchImpl(context = {}) {
  if (typeof context.fetch === "function") {
    return context.fetch;
  }

  if (typeof fetch === "function") {
    return fetch.bind(globalThis);
  }

  throw new Error("No fetch implementation is available in the current runtime.");
}

function getApiKey(context = {}) {
  const configuredKey = String(
    context.config?.youtube_api_key || process.env.YOUTUBE_API_KEY || "",
  ).trim();

  if (!configuredKey) {
    throw new Error(
      "Missing YouTube API key. Set config.youtube_api_key or the YOUTUBE_API_KEY environment variable.",
    );
  }

  return configuredKey;
}

function hasApiKey(context = {}) {
  return Boolean(
    String(context.config?.youtube_api_key || process.env.YOUTUBE_API_KEY || "").trim(),
  );
}

function buildUrl(path, params) {
  const url = new URL(`${YOUTUBE_API_BASE}/${path}`);

  Object.entries(params).forEach(([key, value]) => {
    if (value === undefined || value === null || value === "") {
      return;
    }
    url.searchParams.set(key, String(value));
  });

  return url;
}

async function fetchYouTubeJson(path, params, context = {}) {
  const fetchImpl = getFetchImpl(context);
  const [REDACTED_SECRET]);
  const url = buildUrl(path, {
    ...params,
    key: apiKey,
  });

  const response = await fetchImpl(url.toString(), {
    headers: {
      accept: "application/json",
    },
  });

  const rawBody = await response.text();
  let body;

  try {
    body = rawBody ? JSON.parse(rawBody) : {};
  } catch {
    body = { raw: rawBody };
  }

  if (!response.ok) {
    const apiMessage =
      body?.error?.message || body?.message || `YouTube API request failed with status ${response.status}.`;
    throw new Error(apiMessage);
  }

  return body;
}

async function fetchText(url, context = {}) {
  const fetchImpl = getFetchImpl(context);
  const response = await fetchImpl(url, {
    headers: {
      accept: "application/xml, text/xml, text/html, application/json;q=0.9, */*;q=0.8",
    },
  });

  const body = await response.text();
  if (!response.ok) {
    throw new Error(`Request failed for ${url} with status ${response.status}.`);
  }

  return body;
}

function ensureSingleItem(payload, errorMessage) {
  if (!Array.isArray(payload?.items) || payload.items.length === 0) {
    throw new Error(errorMessage);
  }

  return payload.items[0];
}

function mapChannelResource(channel) {
  return {
    kind: "channel",
    channel_id: channel.id,
    title: channel.snippet?.title,
    description: channel.snippet?.description || "",
    custom_url: channel.snippet?.customUrl,
    published_at: channel.snippet?.publishedAt,
    thumbnails: channel.snippet?.thumbnails || {},
    uploads_playlist_id: channel.contentDetails?.relatedPlaylists?.uploads,
    canonical_url: channel.id ? `https://www.youtube.com/channel/${channel.id}` : undefined,
    follow_ready: Boolean(channel.id && channel.contentDetails?.relatedPlaylists?.uploads),
    unresolved: false,
  };
}

function mapUploadItem(item) {
  const videoId = item.contentDetails?.videoId || item.snippet?.resourceId?.videoId;
  const publishedAt = item.contentDetails?.videoPublishedAt || item.snippet?.publishedAt;

  return {
    video_id: videoId,
    video_url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined,
    title: item.snippet?.title || "Untitled video",
    description: item.snippet?.description || "",
    published_at: publishedAt,
    channel_id: item.snippet?.channelId,
    channel_title: item.snippet?.channelTitle,
    thumbnails: item.snippet?.thumbnails || {},
    playlist_item_id: item.id,
    position: item.snippet?.position,
  };
}

async function getChannelById(channelId, context = {}) {
  const payload = await fetchYouTubeJson(
    "channels",
    {
      part: "snippet,contentDetails",
      id: channelId,
      maxResults: 1,
    },
    context,
  );

  return mapChannelResource(
    ensureSingleItem(payload, `Could not find a YouTube channel for channel ID ${channelId}.`),
  );
}

async function getChannelByHandle(handle, context = {}) {
  const normalizedHandle = String(handle || "").replace(/^@/, "");
  const payload = await fetchYouTubeJson(
    "channels",
    {
      part: "snippet,contentDetails",
      forHandle: normalizedHandle,
      maxResults: 1,
    },
    context,
  );

  const channel = mapChannelResource(
    ensureSingleItem(payload, `Could not find a YouTube channel for handle @${normalizedHandle}.`),
  );

  channel.handle = `@${normalizedHandle}`;
  return channel;
}

async function getChannelByUsername(username, context = {}) {
  const payload = await fetchYouTubeJson(
    "channels",
    {
      part: "snippet,contentDetails",
      forUsername: username,
      maxResults: 1,
    },
    context,
  );

  const channel = mapChannelResource(
    ensureSingleItem(payload, `Could not find a YouTube channel for username ${username}.`),
  );

  channel.username = username;
  return channel;
}

async function resolveChannelFromVideoId(videoId, context = {}) {
  const payload = await fetchYouTubeJson(
    "videos",
    {
      part: "snippet",
      id: videoId,
      maxResults: 1,
    },
    context,
  );

  const video = ensureSingleItem(payload, `Could not find a YouTube video for ID ${videoId}.`);
  const channelId = video.snippet?.channelId;

  if (!channelId) {
    throw new Error(`The video ${videoId} does not expose a parent channel ID.`);
  }

  const channel = await getChannelById(channelId, context);
  channel.resolved_from_video_id = videoId;
  return channel;
}

async function resolveYouTubeTarget(source, context = {}) {
  if (hasApiKey(context)) {
    return resolveYouTubeTargetWithApi(source, context);
  }

  return resolveYouTubeTargetFromPublicSource(source, context);
}

async function resolveYouTubeTargetWithApi(source, context = {}) {
  const normalized = normalizeYouTubeInput(source);

  if (!normalized.ok) {
    throw new Error(normalized.message || "Unable to normalize YouTube input.");
  }

  if (normalized.kind === "channel") {
    const channel = await getChannelById(normalized.channel_id, context);
    return {
      normalized,
      target: channel,
    };
  }

  if (normalized.kind === "handle") {
    const channel = await getChannelByHandle(normalized.handle, context);
    return {
      normalized,
      target: channel,
    };
  }

  if (normalized.kind === "legacy_username") {
    const channel = await getChannelByUsername(normalized.username, context);
    return {
      normalized,
      target: channel,
    };
  }

  if (normalized.kind === "video") {
    const channel = await resolveChannelFromVideoId(normalized.video_id, context);
    return {
      normalized,
      target: channel,
    };
  }

  if (normalized.kind === "custom_channel_url") {
    throw new Error(
      "Custom /c/ YouTube URLs are not resolved yet. Use a channel URL, @handle, username URL, or a video URL instead.",
    );
  }

  throw new Error(`Unsupported YouTube target kind: ${normalized.kind}`);
}

function escapeRegex(value) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function decodeHtmlEntities(text) {
  return String(text || "")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&#x27;/g, "'");
}

function extractFirstMatch(text, regex) {
  const match = String(text || "").match(regex);
  return match ? decodeHtmlEntities(match[1]) : null;
}

function extractXmlTag(block, tag) {
  const safeTag = escapeRegex(tag);
  return extractFirstMatch(
    block,
    new RegExp(`<${safeTag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${safeTag}>`, "i"),
  );
}

function extractEntryLink(entryBlock) {
  return (
    extractFirstMatch(
      entryBlock,
      /<link\b[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["'][^>]*\/?>/i,
    ) ||
    extractFirstMatch(
      entryBlock,
      /<link\b[^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*\/?>/i,
    ) ||
    extractFirstMatch(entryBlock, /<link\b[^>]*href=["']([^"']+)["'][^>]*\/?>/i)
  );
}

function extractPageMetadata(html) {
  const feedUrl =
    extractFirstMatch(
      html,
      /<link\b[^>]*type=["']application\/rss\+xml["'][^>]*href=["']([^"']+)["'][^>]*>/i,
    ) ||
    extractFirstMatch(
      html,
      /<link\b[^>]*href=["']([^"']*feeds\/videos\.xml\?channel_id=[^"']+)["'][^>]*>/i,
    );

  const channelId =
    (feedUrl && extractFirstMatch(feedUrl, /channel_id=(UC[A-Za-z0-9_-]{22})/)) ||
    extractFirstMatch(html, /"channelId":"(UC[A-Za-z0-9_-]{22})"/) ||
    extractFirstMatch(html, /"externalId":"(UC[A-Za-z0-9_-]{22})"/) ||
    extractFirstMatch(html, /itemprop=["']channelId["'][^>]*content=["'](UC[A-Za-z0-9_-]{22})["']/i);

  const canonicalUrl =
    extractFirstMatch(html, /<link rel=["']canonical["'] href=["']([^"']+)["']/i) ||
    (channelId ? `https://www.youtube.com/channel/${channelId}` : null);

  const title =
    extractFirstMatch(html, /<meta property=["']og:title["'] content=["']([^"']+)["']/i) ||
    extractFirstMatch(html, /<title>([^<]+)<\/title>/i);

  return {
    channel_id: channelId,
    canonical_url: canonicalUrl,
    feed_url: feedUrl,
    title: title ? title.replace(/\s*-\s*YouTube\s*$/i, "").trim() : null,
  };
}

function parseYouTubeFeed(xml) {
  const feedChannelId =
    extractFirstMatch(xml, /<feed\b[\s\S]*?<yt:channelId>(UC[A-Za-z0-9_-]{22})<\/yt:channelId>/i) ||
    null;
  const feedTitle = extractFirstMatch(xml, /<feed\b[\s\S]*?<title>([\s\S]*?)<\/title>/i);
  const feedAuthor = extractFirstMatch(xml, /<feed\b[\s\S]*?<author>\s*<name>([\s\S]*?)<\/name>/i);
  const entries = Array.from(String(xml || "").matchAll(/<entry\b[\s\S]*?<\/entry>/gi)).map(
    (match) => {
      const entry = match[0];
      const videoId = extractXmlTag(entry, "yt:videoId");
      const channelId = extractXmlTag(entry, "yt:channelId") || feedChannelId;
      const title = extractXmlTag(entry, "title") || extractXmlTag(entry, "media:title");
      const description = extractXmlTag(entry, "media:description") || "";
      const publishedAt = extractXmlTag(entry, "published") || extractXmlTag(entry, "updated");
      const channelTitle = extractFirstMatch(
        entry,
        /<author>\s*<name>([\s\S]*?)<\/name>/i,
      ) || feedAuthor;

      return {
        video_id: videoId,
        video_url: extractEntryLink(entry) || (videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined),
        title: title || "Untitled video",
        description,
        published_at: publishedAt,
        channel_id: channelId,
        channel_title: channelTitle || feedTitle || null,
        thumbnails: {},
      };
    },
  );

  return {
    channel_id: feedChannelId,
    channel_title: feedAuthor || feedTitle || null,
    entries,
  };
}

async function resolveYouTubeTargetFromPublicSource(source, context = {}) {
  const normalized = normalizeYouTubeInput(source);

  if (!normalized.ok) {
    throw new Error(normalized.message || "Unable to normalize YouTube input.");
  }

  if (normalized.kind === "playlist") {
    throw new Error("Playlist inputs are not supported as creator watch targets.");
  }

  if (normalized.kind === "channel" && normalized.channel_id) {
    return {
      normalized,
      target: {
        kind: "channel",
        channel_id: normalized.channel_id,
        title: null,
        description: "",
        custom_url: "",
        published_at: null,
        thumbnails: {},
        uploads_playlist_id: null,
        canonical_url: normalized.canonical_url,
        follow_ready: true,
        unresolved: false,
      },
    };
  }

  const html = await fetchText(normalized.canonical_url, context);
  const metadata = extractPageMetadata(html);
  const channelId = metadata.channel_id;

  if (!channelId || !CHANNEL_ID_RE.test(channelId)) {
    throw new Error(
      "Could not resolve a stable YouTube channel ID without the API key. Try a channel URL, or configure youtube_api_key for stronger resolution.",
    );
  }

  return {
    normalized,
    target: {
      kind: "channel",
      channel_id: channelId,
      title: metadata.title || null,
      description: "",
      custom_url: normalized.kind === "handle" ? normalized.handle : "",
      published_at: null,
      thumbnails: {},
      uploads_playlist_id: null,
      canonical_url: metadata.canonical_url || `https://www.youtube.com/channel/${channelId}`,
      follow_ready: true,
      unresolved: false,
      resolved_from_video_id: normalized.kind === "video" ? normalized.video_id : undefined,
    },
  };
}

async function fetchLatestUploadsWithApi(source, options = {}, context = {}) {
  const limit = Math.max(1, Math.min(Number(options.limit) || 5, 10));
  const resolution = await resolveYouTubeTargetWithApi(source, context);

  if (!resolution.target.uploads_playlist_id) {
    throw new Error("The resolved YouTube channel does not expose an uploads playlist.");
  }

  const payload = await fetchYouTubeJson(
    "playlistItems",
    {
      part: "snippet,contentDetails",
      playlistId: resolution.target.uploads_playlist_id,
      maxResults: limit,
    },
    context,
  );

  const uploads = Array.isArray(payload.items) ? payload.items.map(mapUploadItem) : [];

  return {
    normalized: resolution.normalized,
    target: resolution.target,
    uploads,
    latest_upload: uploads[0] || null,
  };
}

async function fetchLatestUploadsFromPublicFeed(source, options = {}, context = {}) {
  const limit = Math.max(1, Math.min(Number(options.limit) || 5, 10));
  const resolution = await resolveYouTubeTargetFromPublicSource(source, context);
  const feedUrl = `${YOUTUBE_FEED_BASE}?channel_id=${resolution.target.channel_id}`;
  const xml = await fetchText(feedUrl, context);
  const parsed = parseYouTubeFeed(xml);
  const uploads = parsed.entries.slice(0, limit);

  return {
    normalized: resolution.normalized,
    target: {
      ...resolution.target,
      title: resolution.target.title || parsed.channel_title,
    },
    uploads,
    latest_upload: uploads[0] || null,
  };
}

async function fetchLatestUploads(source, options = {}, context = {}) {
  if (hasApiKey(context)) {
    return fetchLatestUploadsWithApi(source, options, context);
  }

  return fetchLatestUploadsFromPublicFeed(source, options, context);
}

module.exports = {
  fetchLatestUploads,
  hasApiKey,
  resolveYouTubeTarget,
  getApiKey,
};


path: src/summary.js
content: function cleanLine(line) {
  return String(line || "")
    .replace(/\s+/g, " ")
    .trim();
}

function collectBulletCandidates(description) {
  const rawLines = String(description || "")
    .split("\n")
    .map(cleanLine)
    .filter(Boolean);

  const filteredLines = rawLines.filter((line) => {
    if (/^https?:\/\//i.test(line)) {
      return false;
    }
    if (/^(#|@)/.test(line)) {
      return false;
    }
    return true;
  });

  if (filteredLines.length > 0) {
    return filteredLines;
  }

  return String(description || "")
    .split(/[.!?]\s+/)
    .map(cleanLine)
    .filter(Boolean);
}

function truncate(text, maxLength) {
  if (!text || text.length <= maxLength) {
    return text;
  }
  return `${text.slice(0, maxLength - 1).trim()}…`;
}

function summarizeVideoMetadata(input) {
  const channelName = cleanLine(input.channel_name || input.channelName || "Creator");
  const title = cleanLine(input.video_title || input.videoTitle || "Untitled video");
  const description = String(input.video_description || input.videoDescription || "");
  const videoUrl = cleanLine(input.video_url || input.videoUrl || "");
  const publishedAt = cleanLine(input.published_at || input.publishedAt || "");

  const bulletCandidates = collectBulletCandidates(description)
    .slice(0, 3)
    .map((line) => truncate(line, 140));

  const summarySource = bulletCandidates[0] || title;
  const summary = truncate(`${channelName} published "${title}". ${summarySource}`, 220);
  const headline = `${channelName}: ${title}`;

  const markdownLines = [
    `**${headline}**`,
    publishedAt ? `Published: ${publishedAt}` : null,
    "",
    summary,
    "",
    ...bulletCandidates.map((line) => `- ${line}`),
    videoUrl ? "" : null,
    videoUrl ? `Watch: ${videoUrl}` : null,
  ].filter(Boolean);

  return {
    headline,
    summary,
    bullets: bulletCandidates,
    markdown: markdownLines.join("\n"),
  };
}

module.exports = {
  summarizeVideoMetadata,
};


path: scripts/check-release.js
content: #!/usr/bin/env node

const fs = require("node:fs");
const path = require("node:path");

const projectRoot = path.resolve(__dirname, "..");

function read(filePath) {
  return fs.readFileSync(path.join(projectRoot, filePath), "utf8");
}

function fail(message) {
  console.error(`FAIL: ${message}`);
  process.exitCode = 1;
}

function ok(message) {
  console.log(`OK: ${message}`);
}

function requireFile(filePath) {
  const abs = path.join(projectRoot, filePath);
  if (!fs.existsSync(abs)) {
    fail(`Missing required file: ${filePath}`);
    return false;
  }
  ok(`Found ${filePath}`);
  return true;
}

function extractFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---/);
  if (!match) {
    throw new Error("SKILL.md is missing YAML frontmatter.");
  }
  return match[1];
}

function extractYamlScalar(yamlText, key) {
  const match = yamlText.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
  if (!match) {
    return null;
  }
  return match[1].trim().replace(/^["']|["']$/g, "");
}

function assertEqual(name, actual, expected) {
  if (actual !== expected) {
    fail(`${name} mismatch. Expected "${expected}", got "${actual}".`);
    return;
  }
  ok(`${name} is aligned`);
}

function assertContains(name, haystack, needle) {
  if (!haystack.includes(needle)) {
    fail(`${name} is missing required content: ${needle}`);
    return;
  }
  ok(`${name} contains "${needle}"`);
}

function assertSemver(name, value) {
  if (!/^\d+\.\d+\.\d+$/.test(value || "")) {
    fail(`${name} must use semantic versioning. Got "${value}".`);
    return;
  }
  ok(`${name} uses semver`);
}

function main() {
  [
    "SKILL.md",
    "manifest.yaml",
    "README.md",
    "config.example.yaml",
    "package.json",
    "src/index.js",
    "test/index.test.js",
    "LICENSE",
  ].forEach(requireFile);

  const packageJson = JSON.parse(read("package.json"));
  const skillText = read("SKILL.md");
  const manifestText = read("manifest.yaml");
  const readmeText = read("README.md");
  const skillFrontmatter = extractFrontmatter(skillText);

  const skillName = extractYamlScalar(skillFrontmatter, "name");
  const skillVersion = extractYamlScalar(skillFrontmatter, "version");
  const manifestName = extractYamlScalar(manifestText, "name");
  const manifestVersion = extractYamlScalar(manifestText, "version");
  const manifestAuthor = extractYamlScalar(manifestText, "author");
  const manifestRuntime = extractYamlScalar(manifestText, "runtime");
  const manifestEntry = extractYamlScalar(manifestText, "entry");

  assertEqual("Skill name", skillName, manifestName);
  assertEqual("Version between SKILL.md and manifest.yaml", skillVersion, manifestVersion);
  assertEqual("Version between package.json and manifest.yaml", packageJson.version, manifestVersion);
  assertSemver("manifest.yaml version", manifestVersion);

  if (!manifestAuthor) {
    fail("manifest.yaml must define author.");
  } else {
    ok("manifest.yaml defines author");
  }

  if (!["node", "python"].includes(manifestRuntime)) {
    fail(`manifest.yaml runtime must be "node" or "python". Got "${manifestRuntime}".`);
  } else {
    ok(`manifest runtime is ${manifestRuntime}`);
  }

  if (manifestEntry !== "src/index.js") {
    fail(`manifest entry should be src/index.js for this project. Got "${manifestEntry}".`);
  } else {
    ok("manifest entry points at src/index.js");
  }

  assertContains("README.md", readmeText, "## Install in OpenClaw");
  assertContains("README.md", readmeText, "## Configuration");
  assertContains("README.md", readmeText, "## Watchlist Actions");
  assertContains("README.md", readmeText, "## Publish to ClawHub");
  assertContains("README.md", readmeText, "openclaw skill publish .");
  assertContains("manifest.yaml", manifestText, "notify_watchlist_updates");
  assertContains("config.example.yaml", read("config.example.yaml"), "youtube_api_key");

  if (process.exitCode) {
    console.error("\nRelease check failed.");
    process.exit(process.exitCode);
  }

  console.log("\nRelease check passed.");
}

main();


path: test/index.test.js
content: const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");

const handler = require("../src/index");
const { extractCaptionTracks, pickBestTrack, parseTranscriptXml } = require("../src/transcript");

async function makeContext(overrides = {}) {
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "creator-feed-watch-"));
  return {
    config: {
      watchlist_path: path.join(tempDir, "watchlist.json"),
      ...overrides.config,
    },
    fetch: overrides.fetch,
  };
}

function jsonResponse(payload, status = 200) {
  return {
    ok: status >= 200 && status < 300,
    status,
    text: async () => JSON.stringify(payload),
  };
}

function textResponse(payload, status = 200) {
  return {
    ok: status >= 200 && status < 300,
    status,
    text: async () => String(payload),
  };
}

test("normalizes a YouTube handle", async () => {
  const result = await handler({
    action: "normalize_target",
    source: "@OpenAI",
  });

  assert.equal(result.ok, true);
  assert.equal(result.normalized.kind, "handle");
  assert.equal(result.normalized.handle, "@OpenAI");
});

test("normalizes a channel URL with channel id", async () => {
  const result = await handler({
    action: "normalize_target",
    source: "https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw",
  });

  assert.equal(result.ok, true);
  assert.equal(result.normalized.kind, "channel");
  assert.equal(result.normalized.follow_ready, true);
});

test("normalizes a watch URL", async () => {
  const result = await handler({
    action: "normalize_target",
    source: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  });

  assert.equal(result.ok, true);
  assert.equal(result.normalized.kind, "video");
  assert.equal(result.normalized.video_id, "dQw4w9WgXcQ");
});

test("prepares an update card", async () => {
  const result = await handler({
    action: "prepare_update",
    channel_name: "OpenAI",
    video_title: "How we built the next demo",
    video_description:
      "A short behind-the-scenes walkthrough.\nWhat changed in the stack.\nWhy the demo matters.",
    video_url: "https://www.youtube.com/watch?v=abc123def45",
    published_at: "2026-03-20T12:00:00Z",
  });

  assert.equal(result.ok, true);
  assert.equal(result.update.headline, "OpenAI: How we built the next demo");
  assert.equal(result.update.bullets.length, 3);
  assert.match(
    result.update.markdown,
    /Watch: https:\/\/www\.youtube\.com\/watch\?v=abc123def45/,
  );
});

test("fetches latest uploads for a handle", async () => {
  const fetchStub = async (url) => {
    const parsed = new URL(url);
    const pathName = parsed.pathname;

    if (pathName.endsWith("/channels") && parsed.searchParams.get("forHandle") === "OpenAI") {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {
                default: { url: "https://img.example.com/channel-default.jpg" },
              },
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF1234567890123456789012"
    ) {
      return jsonResponse({
        items: [
          {
            id: "playlist-item-1",
            snippet: {
              title: "New Demo Walkthrough",
              description: "A short breakdown.\nMain takeaways.\nWhat ships next.",
              publishedAt: "2026-03-20T12:00:00Z",
              channelId: "UC1234567890123456789012",
              channelTitle: "OpenAI",
              position: 0,
              resourceId: {
                videoId: "abc123def45",
              },
              thumbnails: {
                medium: { url: "https://img.example.com/video-medium.jpg" },
              },
            },
            contentDetails: {
              videoId: "abc123def45",
              videoPublishedAt: "2026-03-20T12:00:00Z",
            },
          },
          {
            id: "playlist-item-2",
            snippet: {
              title: "Previous Update",
              description: "Last week's recap.",
              publishedAt: "2026-03-18T12:00:00Z",
              channelId: "UC1234567890123456789012",
              channelTitle: "OpenAI",
              position: 1,
              resourceId: {
                videoId: "xyz987uvw65",
              },
              thumbnails: {},
            },
            contentDetails: {
              videoId: "xyz987uvw65",
              videoPublishedAt: "2026-03-18T12:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse(
      {
        error: {
          message: `Unexpected request: ${url}`,
        },
      },
      404,
    );
  };

  const context = await makeContext({
    config: {
      youtube_api_key: "test-key",
    },
    fetch: fetchStub,
  });

  const result = await handler(
    {
      action: "fetch_latest_uploads",
      source: "@OpenAI",
      limit: 2,
    },
    context,
  );

  assert.equal(result.ok, true);
  assert.equal(result.target.channel_id, "UC1234567890123456789012");
  assert.equal(result.uploads.length, 2);
  assert.equal(result.latest_upload.video_id, "abc123def45");
  assert.match(result.latest_update.markdown, /New Demo Walkthrough/);
});

test("fetches latest uploads without an API key by using the public page and feed fallback", async () => {
  const feedXml = `<?xml version="1.0" encoding="UTF-8"?>
  <feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/">
    <title>OpenAI</title>
    <author><name>OpenAI</name></author>
    <yt:channelId>UC1234567890123456789012</yt:channelId>
    <entry>
      <yt:videoId>abc123def45</yt:videoId>
      <yt:channelId>UC1234567890123456789012</yt:channelId>
      <title>New Demo Walkthrough</title>
      <link rel="alternate" href="https://www.youtube.com/watch?v=abc123def45"/>
      <author><name>OpenAI</name></author>
      <published>2026-03-20T12:00:00Z</published>
      <media:group>
        <media:title>New Demo Walkthrough</media:title>
        <media:description>A short breakdown.</media:description>
      </media:group>
    </entry>
  </feed>`;

  const fetchStub = async (url) => {
    if (url === "https://www.youtube.com/@OpenAI") {
      return textResponse(
        `<html><head>
          <meta property="og:title" content="OpenAI">
          <link rel="alternate" type="application/rss+xml" title="RSS" href="https://www.youtube.com/feeds/videos.xml?channel_id=UC1234567890123456789012">
          <link rel="canonical" href="https://www.youtube.com/@OpenAI">
        </head></html>`,
      );
    }

    if (url === "https://www.youtube.com/feeds/videos.xml?channel_id=UC1234567890123456789012") {
      return textResponse(feedXml);
    }

    return jsonResponse({ error: { message: `Unexpected request: ${url}` } }, 404);
  };

  const context = await makeContext({
    fetch: fetchStub,
  });

  const result = await handler(
    {
      action: "fetch_latest_uploads",
      source: "@OpenAI",
    },
    context,
  );

  assert.equal(result.ok, true);
  assert.equal(result.target.channel_id, "UC1234567890123456789012");
  assert.equal(result.latest_upload.video_id, "abc123def45");
});

test("resolves a pasted video URL back to its channel before fetching uploads", async () => {
  const fetchStub = async (url) => {
    const parsed = new URL(url);
    const pathName = parsed.pathname;

    if (pathName.endsWith("/videos") && parsed.searchParams.get("id") === "dQw4w9WgXcQ") {
      return jsonResponse({
        items: [
          {
            id: "dQw4w9WgXcQ",
            snippet: {
              channelId: "UC9999999999999999999999",
              channelTitle: "Example Creator",
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/channels") &&
      parsed.searchParams.get("id") === "UC9999999999999999999999"
    ) {
      return jsonResponse({
        items: [
          {
            id: "UC9999999999999999999999",
            snippet: {
              title: "Example Creator",
              description: "Example channel.",
              publishedAt: "2024-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF9999999999999999999999",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF9999999999999999999999"
    ) {
      return jsonResponse({
        items: [
          {
            id: "playlist-item-video-resolution",
            snippet: {
              title: "Fresh Upload",
              description: "A quick update.",
              publishedAt: "2026-03-21T09:00:00Z",
              channelId: "UC9999999999999999999999",
              channelTitle: "Example Creator",
              position: 0,
              resourceId: {
                videoId: "newvideo1234",
              },
              thumbnails: {},
            },
            contentDetails: {
              videoId: "newvideo1234",
              videoPublishedAt: "2026-03-21T09:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse({ error: { message: `Unexpected request: ${url}` } }, 404);
  };

  const context = await makeContext({
    config: {
      youtube_api_key: "test-key",
    },
    fetch: fetchStub,
  });

  const result = await handler(
    {
      action: "fetch_latest_uploads",
      source: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    },
    context,
  );

  assert.equal(result.ok, true);
  assert.equal(result.target.resolved_from_video_id, "dQw4w9WgXcQ");
  assert.equal(result.latest_upload.video_id, "newvideo1234");
});

test("adds a follow and lists it from the persisted watchlist", async () => {
  const fetchStub = async (url) => {
    const parsed = new URL(url);
    const pathName = parsed.pathname;

    if (pathName.endsWith("/channels") && parsed.searchParams.get("forHandle") === "OpenAI") {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF1234567890123456789012"
    ) {
      return jsonResponse({
        items: [
          {
            id: "playlist-item-1",
            snippet: {
              title: "New Demo Walkthrough",
              description: "A short breakdown.",
              publishedAt: "2026-03-20T12:00:00Z",
              channelId: "UC1234567890123456789012",
              channelTitle: "OpenAI",
              position: 0,
              resourceId: {
                videoId: "abc123def45",
              },
              thumbnails: {},
            },
            contentDetails: {
              videoId: "abc123def45",
              videoPublishedAt: "2026-03-20T12:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse({ error: { message: `Unexpected request: ${url}` } }, 404);
  };

  const context = await makeContext({
    config: {
      youtube_api_key: "test-key",
    },
    fetch: fetchStub,
  });

  const added = await handler(
    {
      action: "add_follow",
      source: "@OpenAI",
    },
    context,
  );

  assert.equal(added.ok, true);
  assert.equal(added.created, true);
  assert.equal(added.follow.channel_id, "UC1234567890123456789012");
  assert.equal(added.follow.last_seen_video_id, "abc123def45");

  const listed = await handler(
    {
      action: "list_follows",
    },
    context,
  );

  assert.equal(listed.ok, true);
  assert.equal(listed.count, 1);
  assert.equal(listed.follows[0].title, "OpenAI");
});

test("adds a follow without an API key by using the public fallback", async () => {
  const feedXml = `<?xml version="1.0" encoding="UTF-8"?>
  <feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/">
    <title>OpenAI</title>
    <author><name>OpenAI</name></author>
    <yt:channelId>UC1234567890123456789012</yt:channelId>
    <entry>
      <yt:videoId>abc123def45</yt:videoId>
      <yt:channelId>UC1234567890123456789012</yt:channelId>
      <title>New Demo Walkthrough</title>
      <link rel="alternate" href="https://www.youtube.com/watch?v=abc123def45"/>
      <author><name>OpenAI</name></author>
      <published>2026-03-20T12:00:00Z</published>
      <media:group>
        <media:description>A short breakdown.</media:description>
      </media:group>
    </entry>
  </feed>`;

  const fetchStub = async (url) => {
    if (url === "https://www.youtube.com/@OpenAI") {
      return textResponse(
        `<html><head>
          <meta property="og:title" content="OpenAI">
          <link rel="alternate" type="application/rss+xml" title="RSS" href="https://www.youtube.com/feeds/videos.xml?channel_id=UC1234567890123456789012">
          <link rel="canonical" href="https://www.youtube.com/@OpenAI">
        </head></html>`,
      );
    }

    if (url === "https://www.youtube.com/feeds/videos.xml?channel_id=UC1234567890123456789012") {
      return textResponse(feedXml);
    }

    return jsonResponse({ error: { message: `Unexpected request: ${url}` } }, 404);
  };

  const context = await makeContext({
    fetch: fetchStub,
  });

  const added = await handler(
    {
      action: "add_follow",
      source: "@OpenAI",
    },
    context,
  );

  assert.equal(added.ok, true);
  assert.equal(added.follow.channel_id, "UC1234567890123456789012");
  assert.equal(added.follow.last_seen_video_id, "abc123def45");
});

test("detects new uploads in the watchlist and marks them seen by default", async () => {
  let playlistCall = 0;
  const fetchStub = async (url) => {
    const parsed = new URL(url);
    const pathName = parsed.pathname;

    if (pathName.endsWith("/channels") && parsed.searchParams.get("forHandle") === "OpenAI") {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/channels") &&
      parsed.searchParams.get("id") === "UC1234567890123456789012"
    ) {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF1234567890123456789012"
    ) {
      playlistCall += 1;
      const latestVideoId = playlistCall === 1 ? "seedvideo001" : "freshvideo02";
      const latestTitle = playlistCall === 1 ? "Seed Upload" : "Fresh Upload";

      return jsonResponse({
        items: [
          {
            id: `playlist-item-${playlistCall}`,
            snippet: {
              title: latestTitle,
              description: "New details.",
              publishedAt: "2026-03-20T12:00:00Z",
              channelId: "UC1234567890123456789012",
              channelTitle: "OpenAI",
              position: 0,
              resourceId: {
                videoId: latestVideoId,
              },
              thumbnails: {},
            },
            contentDetails: {
              videoId: latestVideoId,
              videoPublishedAt: "2026-03-20T12:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse({ error: { message: `Unexpected request: ${url}` } }, 404);
  };

  const context = await makeContext({
    config: {
      youtube_api_key: "test-key",
    },
    fetch: fetchStub,
  });

  const added = await handler(
    {
      action: "add_follow",
      source: "@OpenAI",
    },
    context,
  );

  assert.equal(added.ok, true);
  assert.equal(added.follow.last_seen_video_id, "seedvideo001");

  const checked = await handler(
    {
      action: "check_watchlist_updates",
    },
    context,
  );

  assert.equal(checked.ok, true);
  assert.equal(checked.update_count, 1);
  assert.equal(checked.updates[0].latest_upload.video_id, "freshvideo02");

  const listed = await handler(
    {
      action: "list_follows",
    },
    context,
  );

  assert.equal(listed.follows[0].last_seen_video_id, "freshvideo02");
});

test("removes a follow from the persisted watchlist", async () => {
  const fetchStub = async (url) => {
    const parsed = new URL(url);
    const pathName = parsed.pathname;

    if (pathName.endsWith("/channels") && parsed.searchParams.get("forHandle") === "OpenAI") {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF1234567890123456789012"
    ) {
      return jsonResponse({
        items: [
          {
            id: "playlist-item-1",
            snippet: {
              title: "Seed Upload",
              description: "Initial item.",
              publishedAt: "2026-03-20T12:00:00Z",
              channelId: "UC1234567890123456789012",
              channelTitle: "OpenAI",
              position: 0,
              resourceId: {
                videoId: "seedvideo001",
              },
              thumbnails: {},
            },
            contentDetails: {
              videoId: "seedvideo001",
              videoPublishedAt: "2026-03-20T12:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse({ error: { message: `Unexpected request: ${url}` } }, 404);
  };

  const context = await makeContext({
    config: {
      youtube_api_key: "test-key",
    },
    fetch: fetchStub,
  });

  await handler(
    {
      action: "add_follow",
      source: "@OpenAI",
    },
    context,
  );

  const removed = await handler(
    {
      action: "remove_follow",
      source: "@OpenAI",
    },
    context,
  );

  assert.equal(removed.ok, true);
  assert.equal(removed.count, 0);

  const listed = await handler(
    {
      action: "list_follows",
    },
    context,
  );

  assert.equal(listed.count, 0);
});

test("notifies new watchlist updates through a runtime delivery adapter", async () => {
  let playlistCall = 0;
  const delivered = [];
  const fetchStub = async (url) => {
    const parsed = new URL(url);
    const pathName = parsed.pathname;

    if (pathName.endsWith("/channels") && parsed.searchParams.get("forHandle") === "OpenAI") {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/channels") &&
      parsed.searchParams.get("id") === "UC1234567890123456789012"
    ) {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF1234567890123456789012"
    ) {
      playlistCall += 1;
      const latestVideoId = playlistCall === 1 ? "seedvideo001" : "freshvideo02";
      const latestTitle = playlistCall === 1 ? "Seed Upload" : "Fresh Upload";

      return jsonResponse({
        items: [
          {
            id: `playlist-item-${playlistCall}`,
            snippet: {
              title: latestTitle,
              description: "New details.",
              publishedAt: "2026-03-20T12:00:00Z",
              channelId: "UC1234567890123456789012",
              channelTitle: "OpenAI",
              position: 0,
              resourceId: {
                videoId: latestVideoId,
              },
              thumbnails: {},
            },
            contentDetails: {
              videoId: latestVideoId,
              videoPublishedAt: "2026-03-20T12:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse({ error: { message: `Unexpected request: ${url}` } }, 404);
  };

  const context = await makeContext({
    config: {
      youtube_api_key: "test-key",
      delivery_target: "telegram:test-room",
    },
    fetch: fetchStub,
  });
  context.deliver = async (notification) => {
    delivered.push(notification);
    return { accepted: true };
  };

  await handler(
    {
      action: "add_follow",
      source: "@OpenAI",
    },
    context,
  );

  const notified = await handler(
    {
      action: "notify_watchlist_updates",
    },
    context,
  );

  assert.equal(notified.ok, true);
  assert.equal(notified.delivered_count, 1);
  assert.equal(delivered.length, 1);
  assert.equal(delivered[0].delivery_target, "telegram:test-room");
  assert.match(delivered[0].markdown, /Fresh Upload/);

  const listed = await handler(
    {
      action: "list_follows",
    },
    context,
  );

  assert.equal(listed.follows[0].last_seen_video_id, "freshvideo02");
});

test("returns prepared notifications when no runtime delivery adapter exists", async () => {
  let playlistCall = 0;
  const fetchStub = async (url) => {
    const parsed = new URL(url);
    const pathName = parsed.pathname;

    if (pathName.endsWith("/channels") && parsed.searchParams.get("forHandle") === "OpenAI") {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/channels") &&
      parsed.searchParams.get("id") === "UC1234567890123456789012"
    ) {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "OpenAI",
              description: "AI research and products.",
              customUrl: "@OpenAI",
              publishedAt: "2015-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF1234567890123456789012"
    ) {
      playlistCall += 1;
      const latestVideoId = playlistCall === 1 ? "seedvideo001" : "freshvideo02";

      return jsonResponse({
        items: [
          {
            id: `playlist-item-${playlistCall}`,
            snippet: {
              title: playlistCall === 1 ? "Seed Upload" : "Fresh Upload",
              description: "New details.",
              publishedAt: "2026-03-20T12:00:00Z",
              channelId: "UC1234567890123456789012",
              channelTitle: "OpenAI",
              position: 0,
              resourceId: {
                videoId: latestVideoId,
              },
              thumbnails: {},
            },
            contentDetails: {
              videoId: latestVideoId,
              videoPublishedAt: "2026-03-20T12:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse({ error: { message: `Unexpected request: ${url}` } }, 404);
  };

  const context = await makeContext({
    config: {
      youtube_api_key: "test-key",
    },
    fetch: fetchStub,
  });

  await handler(
    {
      action: "add_follow",
      source: "@OpenAI",
    },
    context,
  );

  const notified = await handler(
    {
      action: "notify_watchlist_updates",
    },
    context,
  );

  assert.equal(notified.ok, false);
  assert.equal(notified.delivered_count, 0);
  assert.equal(notified.notifications.length, 1);

  const listed = await handler(
    {
      action: "list_follows",
    },
    context,
  );

  assert.equal(listed.follows[0].last_seen_video_id, "seedvideo001");
});

// --- Transcript tests ---

test("extractCaptionTracks parses caption tracks from YouTube page HTML", () => {
  const html = `var ytInitialPlayerResponse = {"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://example.com/timedtext?lang=zh-Hans","languageCode":"zh-Hans"},{"baseUrl":"https://example.com/timedtext?lang=en","languageCode":"en"}]}}};`;

  const tracks = extractCaptionTracks(html);
  assert.equal(tracks.length, 2);
  assert.equal(tracks[0].languageCode, "zh-Hans");
  assert.equal(tracks[1].languageCode, "en");
});

test("extractCaptionTracks returns empty array when no captions exist", () => {
  const html = `<html><body>No captions here</body></html>`;
  const tracks = extractCaptionTracks(html);
  assert.deepEqual(tracks, []);
});

test("pickBestTrack prefers Chinese over English", () => {
  const tracks = [
    { languageCode: "en", baseUrl: "https://example.com/en" },
    { languageCode: "zh-Hans", baseUrl: "https://example.com/zh" },
  ];
  const best = pickBestTrack(tracks);
  assert.equal(best.languageCode, "zh-Hans");
});

test("pickBestTrack falls back to English when no Chinese", () => {
  const tracks = [
    { languageCode: "fr", baseUrl: "https://example.com/fr" },
    { languageCode: "en", baseUrl: "https://example.com/en" },
  ];
  const best = pickBestTrack(tracks);
  assert.equal(best.languageCode, "en");
});

test("pickBestTrack falls back to first track when no zh or en", () => {
  const tracks = [
    { languageCode: "ja", baseUrl: "https://example.com/ja" },
    { languageCode: "ko", baseUrl: "https://example.com/ko" },
  ];
  const best = pickBestTrack(tracks);
  assert.equal(best.languageCode, "ja");
});

test("parseTranscriptXml extracts text from XML captions", () => {
  const xml = `<?xml version="1.0" encoding="utf-8"?>
<transcript>
  <text start="0" dur="5.2">Hello world</text>
  <text start="5.2" dur="3.1">This is a test &amp; demo</text>
  <text start="8.3" dur="4.0">Final segment</text>
</transcript>`;

  const text = parseTranscriptXml(xml);
  assert.equal(text, "Hello world This is a test & demo Final segment");
});

// --- Analyze video tests ---

test("analyzes a video URL with transcript (API-backed)", async () => {
  const captionXml = `<?xml version="1.0" encoding="utf-8"?>
<transcript>
  <text start="0" dur="5">Welcome to this podcast about AI agents</text>
  <text start="5" dur="5">Today we discuss the future of autonomous systems</text>
</transcript>`;

  const pageHtml = `<html><head>
    <script>var ytInitialPlayerResponse = {"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://example.com/timedtext?lang=en","languageCode":"en"}]}}};</script>
  </head></html>`;

  const fetchStub = async (url) => {
    const parsed = typeof url === "string" ? new URL(url) : url;
    const urlStr = parsed.toString();
    const pathName = parsed.pathname;

    if (urlStr.startsWith("https://www.youtube.com/watch?v=testvid1234")) {
      return textResponse(pageHtml);
    }

    if (urlStr.startsWith("https://example.com/timedtext")) {
      return textResponse(captionXml);
    }

    if (pathName.endsWith("/videos") && parsed.searchParams.get("id") === "testvid1234") {
      return jsonResponse({
        items: [
          {
            id: "testvid1234",
            snippet: {
              channelId: "UC1234567890123456789012",
              channelTitle: "AI Podcast",
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/channels") &&
      parsed.searchParams.get("id") === "UC1234567890123456789012"
    ) {
      return jsonResponse({
        items: [
          {
            id: "UC1234567890123456789012",
            snippet: {
              title: "AI Podcast",
              description: "A podcast about AI.",
              publishedAt: "2024-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF1234567890123456789012",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF1234567890123456789012"
    ) {
      return jsonResponse({
        items: [
          {
            id: "playlist-item-1",
            snippet: {
              title: "AI Agents Deep Dive",
              description: "A comprehensive look at AI agents and their future.",
              publishedAt: "2026-03-20T12:00:00Z",
              channelId: "UC1234567890123456789012",
              channelTitle: "AI Podcast",
              position: 0,
              resourceId: { videoId: "testvid1234" },
              thumbnails: {},
            },
            contentDetails: {
              videoId: "testvid1234",
              videoPublishedAt: "2026-03-20T12:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse({ error: { message: `Unexpected request: ${urlStr}` } }, 404);
  };

  const context = await makeContext({
    config: { youtube_api_key: "test-key" },
    fetch: fetchStub,
  });

  const result = await handler(
    {
      action: "analyze_video",
      source: "https://www.youtube.com/watch?v=testvid1234",
    },
    context,
  );

  assert.equal(result.ok, true);
  assert.equal(result.action, "analyze_video");
  assert.equal(result.video_id, "testvid1234");
  assert.equal(result.channel_name, "AI Podcast");
  assert.equal(result.has_transcript, true);
  assert.equal(result.transcript_language, "en");
  assert.match(result.transcript_text, /AI agents/);
  assert.match(result.message, /with transcript/);
});

test("analyzes a video URL without transcript available", async () => {
  const pageHtml = `<html><head><title>No captions video</title></head></html>`;

  const fetchStub = async (url) => {
    const parsed = typeof url === "string" ? new URL(url) : url;
    const urlStr = parsed.toString();
    const pathName = parsed.pathname;

    if (urlStr.startsWith("https://www.youtube.com/watch?v=nocaption12")) {
      return textResponse(pageHtml);
    }

    if (pathName.endsWith("/videos") && parsed.searchParams.get("id") === "nocaption12") {
      return jsonResponse({
        items: [
          {
            id: "nocaption12",
            snippet: {
              channelId: "UC9999999999999999999999",
              channelTitle: "No Caption Channel",
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/channels") &&
      parsed.searchParams.get("id") === "UC9999999999999999999999"
    ) {
      return jsonResponse({
        items: [
          {
            id: "UC9999999999999999999999",
            snippet: {
              title: "No Caption Channel",
              description: "",
              publishedAt: "2024-01-01T00:00:00Z",
              thumbnails: {},
            },
            contentDetails: {
              relatedPlaylists: {
                uploads: "UULF9999999999999999999999",
              },
            },
          },
        ],
      });
    }

    if (
      pathName.endsWith("/playlistItems") &&
      parsed.searchParams.get("playlistId") === "UULF9999999999999999999999"
    ) {
      return jsonResponse({
        items: [
          {
            id: "playlist-item-1",
            snippet: {
              title: "Video Without Captions",
              description: "This video has no subtitles.",
              publishedAt: "2026-03-20T12:00:00Z",
              channelId: "UC9999999999999999999999",
              channelTitle: "No Caption Channel",
              position: 0,
              resourceId: { videoId: "nocaption12" },
              thumbnails: {},
            },
            contentDetails: {
              videoId: "nocaption12",
              videoPublishedAt: "2026-03-20T12:00:00Z",
            },
          },
        ],
      });
    }

    return jsonResponse({ error: { message: `Unexpected request: ${urlStr}` } }, 404);
  };

  const context = await makeContext({
    config: { youtube_api_key: "test-key" },
    fetch: fetchStub,
  });

  const result = await handler(
    {
      action: "analyze_video",
      source: "https://www.youtube.com/watch?v=nocaption12",
    },
    context,
  );

  assert.equal(result.ok, true);
  assert.equal(result.video_id, "nocaption12");
  assert.equal(result.has_transcript, false);
  assert.equal(result.transcript_text, null);
  assert.match(result.message, /without transcript/);
});

test("analyze_video returns error for missing source", async () => {
  const result = await handler({
    action: "analyze_video",
  });

  assert.equal(result.ok, false);
  assert.match(result.message, /Missing required input/);
});


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