Skill: poca233/douyin-video-skills (version 1.0.0)

--- name: douyin-video-skills description: "抖音视频搜索、筛选、链接获取、文案提取与修正工具。支持在抖音网页中登录后搜索自定义关键词，按筛选参数从搜索结果中选择合适视频；点开候选视频后先校验当前弹层标题与目标搜索结果标题是否一致，若不一致则自动关闭弹层并继续尝试后续候选，再提取视频语音文案并输出原始稿、修正版、修正说明。适用场景包括抖音二次创作前的素材采集、竞品研究、视频文案提取与清洗。" --- # 抖音视频搜索、文案提取与修正 从抖音网页中搜索自定义关键词，筛选并选定目标视频，校验当前视频是否正确，再提取视频语音文案，并对抽取结果进行修正和落盘。 ## 功能概述 - **搜索视频**: 从抖音首页输入关键词并进入视频搜索结果，避免直接跳搜索 URL - **筛选结果**: 按标题关键词、账号类型、点赞、时长、内容类型等参数筛选候选视频 - **锁定目标视频**: 点开候选视频后，校验标题是否匹配（支持归一化匹配、包含匹配、相似度匹配） - **获取稳定链接**: 优先使用页面 `modal_id` 组装稳定视频链接，必要时再尝试分享按钮 - **提取文案**: 下载视频、提取音频、调用 ASR 获取语音文案 - **修正文案**: 结合标题、标签、上下文和领域常识修正 ASR 错误 - **自动保存**: 输出原始稿、修正版、修正说明、meta 信息等文件 ## 环境要求 ### 依赖安装 ```bash pip install requests ffmpeg-python ``` ### 系统要求 - FFmpeg 必须安装在系统中（用于音视频处理） - macOS: `brew install ffmpeg` - Ubuntu: `apt install ffmpeg` ### 浏览器自动化要求 需要可用的 `playwright-cli` 以及它的 skills。 #### 安装 playwright-cli ```bash npm install -g @playwright/cli@latest ``` #### 安装 playwright-cli 的 skills ```bash playwright-cli install --skills ``` #### 校验安装 ```bash playwright-cli --version ``` 推荐使用有头模式、持久化登录态，并显式指定 profile 目录： ```bash playwright-cli -s=douyinflow open https://www.douyin.com/ --headed --persistent --profile ~/.playwright/douyinflow ``` ### API 密钥配置（仅文案提取需要） 文案提取功能使用硅基流动 API，需要设置环境变量： ```bash export [REDACTED_SECRET]" ``` 对 OpenClaw / Gateway 环境，建议写入： ```bash ~/.openclaw/.env ``` 获取 API 密钥: https://cloud.siliconflow.cn/ ## 使用方法 ### 方法一：使用总控脚本（推荐） ```bash python3 skills/douyin-video-skills/scripts/run_pipeline.py \ --keyword "青少年无人机" \ --pick-index 1 \ --must-include 青少年 \ --must-include 无人机 \ --content-type-hint 培训 \ --content-type-hint 科普 \ --account-hint 教育 \ --profile ~/.playwright/douyinflow \ --human-delay-min-ms 800 \ --human-delay-max-ms 2500 \ --captcha-max-waits 3 \ --title-match-mode default \ --title-min-similarity 0.82 \ --max-title-retry 5 \ --headed \ --persistent ``` ### 总控脚本支持的主要参数 ```bash --keyword 自定义搜索词（必填） --pick-index 选择第几个符合条件的结果（默认 1） --must-include 标题必须包含的词，可多次传入 --exclude-word 需要排除的词，可多次传入 --content-type-hint 内容类型提示，如 培训 / 科普 / 推荐，可多次传入 --account-hint 账号类型提示，如 教育 / 俱乐部 / 教练，可多次传入 --min-likes 最低点赞量 --duration-min-sec 最短时长（秒） --duration-max-sec 最长时长（秒） --profile playwright-cli 持久化浏览器 profile 目录（默认 ~/.playwright/douyinflow） --human-delay-min-ms 人类化操作最短等待时间（毫秒） --human-delay-max-ms 人类化操作最长等待时间（毫秒） --captcha-max-waits 验证码暂停等待次数（默认 3） --title-match-mode 标题匹配模式：strict / default / loose --title-min-similarity 标题最小相似度阈值（默认 0.82） --max-title-retry 标题校验失败后最多尝试多少个候选（默认 5） --headed 浏览器前台运行 --persistent 使用持久化登录态 --output-dir 输出目录（默认 output） ``` ### 方法二：分步执行 #### 1. 打开抖音并复用登录态（首次运行后会在 `~/.playwright/douyinflow` 生成登录态文件，后续可复用） ```bash playwright-cli -s=douyinflow open https://www.douyin.com/ --headed --persistent --profile ~/.playwright/douyinflow ``` #### 2. 从首页输入搜索词并进入视频结果页 推荐让总控脚本自动完成，不要直接手写跳转搜索 URL。这样更接近真人操作，更利于减少验证码。 #### 3. 选定视频后做标题一致性校验 ```bash python3 skills/douyin-video-skills/scripts/title_match_check.py \ --expected "目标搜索结果标题" \ --actual "当前弹层标题" \ --mode default \ --min-similarity 0.82 ``` > 默认不是死板的逐字完全相等，而是按“归一化标题 + 包含关系 + 相似度阈值”综合判断。 > 若仍不匹配，不继续当前视频提取；应自动关闭弹层并尝试下一个候选。 #### 4. 提取视频文案 ```bash python3 skills/douyin-video-skills/scripts/douyin_downloader.py \ --link "https://www.iesdouyin.com/share/video/<videoId>" \ --action extract \ --output ./output ``` #### 5. 修正文案并生成附加文件 ```bash python3 skills/douyin-video-skills/scripts/transcript_cleanup.py \ --title "视频标题" \ --raw ./output/<videoId>/transcript.md \ --outdir ./output/<videoId> ``` ### 筛选参数示例 ```json { "keyword": "青少年无人机", "pickIndex": 1, "mustInclude": ["青少年", "无人机"], "excludeWords": ["直播", "纯广告", "录播回放"], "minLikes": 0, "durationMinSec": 15, "durationMaxSec": 120, "contentTypeHints": ["培训", "科普", "推荐"], "accountHints": ["教育", "俱乐部", "教练"] } ``` 更详细的筛选思路参考： - `references/filter-rules.md` 发布文案参考： - `references/publish-copy.md` ## 输出目录结构 完整执行后，每个视频建议保存到独立文件夹： ```text output/ └── <videoId>/ ├── meta.json ├── source-link.txt ├── transcript.md ├── transcript-raw.md ├── transcript-clean.md └── transcript-fixes.md ``` ### 文件说明 - `meta.json`: 标题、关键词、视频ID、筛选参数、提取时间 - `source-link.txt`: 最终用于提取的链接 - `transcript.md`: 提取器原始产物 - `transcript-raw.md`: 原始转写稿 - `transcript-clean.md`: 修正版文案 - `transcript-fixes.md`: 修正说明与待确认项 ## 工作流程 ### 搜索与筛选 1. 打开抖音首页并复用登录态 2. 以较慢节奏输入搜索词并点击搜索 3. 切换到视频结果页 4. 从搜索结果中读取候选视频列表 5. 按筛选参数过滤候选项 6. 选中第 N 个符合条件的视频 ### 标题一致性校验 1. 记录目标搜索结果标题 2. 点开视频进入弹层 3. 读取当前页面 `modal_id` 4. 读取当前弹层标题 5. 执行标题校验脚本（默认使用归一化匹配 + 相似度阈值） 6. 若标题不一致，自动关闭弹层，回到结果列表继续尝试后续候选，直到找到校验通过的视频 ### 提取与修正 1. 用 `modal_id` 组装稳定视频链接：`https://www.iesdouyin.com/share/video/<videoId>` 2. 下载视频到临时目录 3. 使用 FFmpeg 从视频中提取音频（MP3 格式） 4. 调用硅基流动 SenseVoice API 进行语音识别 5. 保存 `transcript.md` 6. 基于标题、标签、上下文和领域常识修正 ASR 文案 7. 输出 `transcript-raw.md`、`transcript-clean.md`、`transcript-fixes.md` ## 常见问题 ### 搜索时频繁触发验证码怎么办 建议优先使用以下方式减少验证码： - 使用固定 profile 目录复用登录态 - 从首页输入搜索，不要直接跳搜索 URL - 增加人类化延迟，不要秒点秒跳 - 遇到验证码时先手工完成，再继续流程 当前脚本已支持： - 人类化延迟参数：`--human-delay-min-ms` / `--human-delay-max-ms` - 验证码暂停恢复：检测到验证码后暂停，等待人工处理完成再继续 ### 搜索结果点开后，当前视频变了 这是抖音网页常见问题。必须增加校验： - 标题先做归一化 - 再判断包含关系 - 最后再看相似度是否达到阈值 若仍不一致，不要在错误视频上继续提取。应执行：关闭弹层 → 回到结果列表 → 尝试下一个候选标题 → 重新校验，直到找到正确视频。 ### 分享按钮不稳定 PC 端分享按钮可能： - 只在悬停时出现 - 位于播放器浮层中，不易点击 - DOM 结构经常变化 因此本 skill 默认： - 优先读取 `modal_id` - 用 `modal_id` 组装稳定视频链接 - 不把“复制链接”当成唯一依赖 ### 提取文案失败 - 检查 `API_KEY` 是否已设置 - 确保 API 密钥有效且有足够额度 - 确保 FFmpeg 已正确安装 - 确保输入链接为有效抖音分享链接或可解析的视频链接 ### 文案有错别字或语义不通 ASR 常见问题包括： - 同音错字 - 连词错误 - 重复词 - 断句错误 - 领域术语误识别 因此提取后必须执行修正步骤。 ## 注意事项 - 本工具仅供学习和研究使用 - 使用时需遵守相关法律法规 - 请勿用于任何侵犯版权或违法的目的 - 不要把分享按钮点击成功作为唯一成功条件 - 不要直接信任 ASR 文案用于分析 - 不要在未完成标题一致性校验前就进入提取 - 不要把 Playwright 的 element ref 写死成固定编号

path: references/filter-rules.md
content: # Filter Rules for Douyin Video Selection

## Purpose

Use this file when selecting a target video from Douyin search results before transcript extraction.

## Recommended parameter set

```json
{
  "keyword": "青少年无人机",
  "pickIndex": 1,
  "mustInclude": ["青少年", "无人机"],
  "excludeWords": ["直播", "纯广告", "录播回放"],
  "minLikes": 0,
  "preferRecentDays": 365,
  "durationMinSec": 15,
  "durationMaxSec": 120,
  "contentTypeHints": ["培训", "推荐", "科普", "选购"],
  "accountHints": ["教练", "教育", "俱乐部", "培训"]
}
```

## Selection priority

### 1. Hard filters
Reject candidates when any of the following is true:
- Title does not match the main topic
- Title/account contains excluded words
- Video is obviously unrelated to the task
- Duration is too short to contain useful spoken content

### 2. Soft preferences
Prefer candidates with:
- clearer title-topic match
- stronger relevance to the user goal
- recent publish time
- enough spoken content for transcript extraction
- lower ambiguity between card title and modal title

### 3. Topic fit heuristics
For educational / training topics like 青少年无人机, prefer:
- training / education / recommendation / parenting guidance angles
- explicit benefit explanation
- explicit age range / scenario / selection advice

Avoid when possible:
- live replay
- pure promo with no spoken value
- montage / B-roll only
- off-topic entertainment clips

## Validation after click-through

After opening a candidate video, always validate:
1. card title
2. current modal_id
3. current modal/video title (if readable)

If mismatch is detected, do not continue extraction blindly.


path: references/publish-copy.md
content: # douyin-video-skills 发布文案

## 标题
抖音视频搜索、文案提取与修正 Skill

## 一句话介绍
从抖音搜索结果中筛选目标视频，校验当前视频是否正确，再提取并修正视频文案。

## 短描述
适用于抖音二次创作、竞品研究、素材采集。支持：
- 自定义搜索词
- 登录后网页搜索与筛选
- 当前弹层标题与目标搜索结果标题的复合匹配校验（归一化 + 包含 + 相似度）
- 从首页输入搜索词 + 人类化延迟 + 验证码暂停恢复
- 视频文案提取
- 文案修正与多文件落盘

## 完整描述
这是一个面向抖音素材采集与文本提取的完整工作流 skill。

它覆盖的不只是“给我一个链接，然后提文案”，而是更前面的真实流程：
1. 打开抖音并复用登录态
2. 使用固定 profile 目录复用登录态
3. 搜索自定义关键词
3. 按筛选参数选择候选视频
4. 从首页输入搜索词并进入视频结果页，必要时等待人工过验证码
5. 点进视频后做标题复合匹配校验（归一化 + 包含 + 相似度）
6. 如果不一致，关闭弹层并继续尝试后续候选
7. 用稳定视频链接提取文案
6. 对 ASR 结果进行语义修正
7. 输出原始稿、修正版、修正说明、meta 信息

## 功能卖点
- 不把“复制链接”当唯一依赖
- 优先使用 `modal_id` 组装稳定视频链接
- 内置标题复合匹配校验与自动重试，避免点错视频还继续提取
- 支持自定义搜索词、筛选参数、固定 profile 目录复用登录态，以及验证码暂停恢复
- 提取后自动输出 `transcript-raw` / `transcript-clean` / `transcript-fixes`
- 适合 analyst / planner 的前置素材采集与文案清洗流程

## 推荐标签
- douyin
- video
- transcript
- playwright
- asr
- content-research
- creator-tools

## 发布说明
### v1.0.0
- 支持抖音网页搜索、筛选、视频锁定
- 支持标题复合匹配校验（归一化 + 包含 + 相似度）
- 支持视频文案提取与修正
- 支持输出 meta / source-link / transcript 系列文件

## 打包文件检查结果
当前 `.skill` 包内包含：
- `douyin-video-skills/SKILL.md`
- `douyin-video-skills/references/filter-rules.md`
- `douyin-video-skills/references/publish-copy.md`
- `douyin-video-skills/scripts/run_pipeline.py`
- `douyin-video-skills/scripts/title_match_check.py`
- `douyin-video-skills/scripts/douyin_downloader.py`
- `douyin-video-skills/scripts/transcript_cleanup.py`

未发现多余脏文件（如 `.DS_Store`）。


path: scripts/douyin_downloader.py
content: #!/usr/bin/env python3
"""
抖音无水印视频下载和文案提取工具（douyin-video-skills 内置版）

功能:
1. 从抖音分享链接获取无水印视频下载链接
2. 下载视频并提取音频
3. 使用硅基流动 API 从音频中提取文本
4. 自动保存文案到文件 (一个视频一个文件夹)

环境变量:
- API_KEY: 硅基流动 API 密钥 (用于文案提取功能)
"""

import os
import re
import sys
import json
import argparse
import tempfile
import shutil
from pathlib import Path
from typing import Optional
from datetime import datetime


def check_dependencies():
    missing = []
    try:
        import requests  # noqa: F401
    except ImportError:
        missing.append("requests")
    try:
        import ffmpeg  # noqa: F401
    except ImportError:
        missing.append("ffmpeg-python")

    if missing:
        print(f"缺少依赖: {', '.join(missing)}")
        print(f"请运行: pip install {' '.join(missing)}")
        sys.exit(1)


check_dependencies()

import requests
import ffmpeg

HEADERS = {
    'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/121.0.2277.107 Version/17.0 Mobile/15E148 Safari/604.1'
}

DEFAULT_API_BASE_URL = "https://api.siliconflow.cn/v1/audio/transcriptions"
DEFAULT_MODEL = "FunAudioLLM/SenseVoiceSmall"


class DouyinProcessor:
    def __init__(self, api_key: str = "", api_base_url: Optional[str] = None, model: Optional[str] = None):
        self.[REDACTED_SECRET]
        self.api_base_url = api_base_url or DEFAULT_API_BASE_URL
        self.model = model or DEFAULT_MODEL
        self.temp_dir = Path(tempfile.mkdtemp())

    def __del__(self):
        if hasattr(self, 'temp_dir') and self.temp_dir.exists():
            shutil.rmtree(self.temp_dir, ignore_errors=True)

    def parse_share_url(self, share_text: str) -> dict:
        urls = re.findall(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', share_text)
        if not urls:
            raise ValueError("未找到有效的分享链接")

        share_url = urls[0]
        share_response = requests.get(share_url, headers=HEADERS)
        share_response.raise_for_status()
        video_id = share_response.url.split("?")[0].strip("/").split("/")[-1]
        share_url = f'https://www.iesdouyin.com/share/video/{video_id}'

        response = requests.get(share_url, headers=HEADERS)
        response.raise_for_status()

        pattern = re.compile(r"window\._ROUTER_DATA\s*=\s*(.*?)</script>", flags=re.DOTALL)
        find_res = pattern.search(response.text)
        if not find_res or not find_res.group(1):
            raise ValueError("从HTML中解析视频信息失败")

        json_data = json.loads(find_res.group(1).strip())
        video_page_key = "video_(id)/page"
        note_page_key = "note_(id)/page"

        if video_page_key in json_data["loaderData"]:
            original_video_info = json_data["loaderData"][video_page_key]["videoInfoRes"]
        elif note_page_key in json_data["loaderData"]:
            original_video_info = json_data["loaderData"][note_page_key]["videoInfoRes"]
        else:
            raise Exception("无法从JSON中解析视频或图集信息")

        data = original_video_info["item_list"][0]
        video_url = data["video"]["play_addr"]["url_list"][0].replace("playwm", "play")
        desc = data.get("desc", "").strip() or f"douyin_{video_id}"
        desc = re.sub(r'[\\/:*?"<>|]', '_', desc)

        return {"url": video_url, "title": desc, "video_id": video_id}

    def download_video(self, video_info: dict, output_dir: Optional[Path] = None, show_progress: bool = True) -> Path:
        if output_dir is None:
            output_dir = self.temp_dir
        else:
            output_dir = Path(output_dir)
            output_dir.mkdir(parents=True, exist_ok=True)

        filename = f"{video_info['video_id']}.mp4"
        filepath = output_dir / filename

        if show_progress:
            print(f"正在下载视频: {video_info['title']}")

        response = requests.get(video_info['url'], headers=HEADERS, stream=True)
        response.raise_for_status()
        total_size = int(response.headers.get('content-length', 0))

        downloaded = 0
        with open(filepath, 'wb') as f:
            for chunk in response.iter_content(chunk_size=8192):
                if chunk:
                    f.write(chunk)
                    downloaded += len(chunk)
                    if show_progress and total_size > 0:
                        progress = downloaded / total_size * 100
                        print(f"\r下载进度: {progress:.1f}%", end="", flush=True)

        if show_progress:
            print(f"\n视频下载完成: {filepath}")
        return filepath

    def extract_audio(self, video_path: Path, show_progress: bool = True) -> Path:
        audio_path = video_path.with_suffix('.mp3')
        if show_progress:
            print("正在提取音频...")
        try:
            (
                ffmpeg.input(str(video_path))
                .output(str(audio_path), acodec='libmp3lame', q=0)
                .run(capture_stdout=True, capture_stderr=True, overwrite_output=True)
            )
            if show_progress:
                print(f"音频提取完成: {audio_path}")
            return audio_path
        except Exception as e:
            raise Exception(f"提取音频时出错: {str(e)}")

    def get_audio_info(self, audio_path: Path) -> dict:
        try:
            probe = ffmpeg.probe(str(audio_path))
            duration = float(probe['format'].get('duration', 0))
            size = audio_path.stat().st_size
            return {'duration': duration, 'size': size}
        except Exception:
            return {'duration': 0, 'size': audio_path.stat().st_size}

    def split_audio(self, audio_path: Path, segment_duration: int = 600, show_progress: bool = True) -> list:
        audio_info = self.get_audio_info(audio_path)
        duration = audio_info['duration']
        if duration <= segment_duration:
            return [audio_path]

        segments = []
        segment_index = 0
        current_time = 0
        if show_progress:
            total_segments = int(duration / segment_duration) + 1
            print(f"音频时长 {duration:.0f} 秒，将分割为 {total_segments} 段...")

        while current_time < duration:
            segment_path = self.temp_dir / f"segment_{segment_index}.mp3"
            try:
                (
                    ffmpeg.input(str(audio_path), ss=current_time, t=segment_duration)
                    .output(str(segment_path), acodec='libmp3lame', q=0)
                    .run(capture_stdout=True, capture_stderr=True, overwrite_output=True)
                )
                segments.append(segment_path)
                if show_progress:
                    print(f"  分割片段 {segment_index + 1}: {current_time:.0f}s - {min(current_time + segment_duration, duration):.0f}s")
            except Exception as e:
                raise Exception(f"分割音频片段 {segment_index} 时出错: {str(e)}")
            current_time += segment_duration
            segment_index += 1

        return segments

    def transcribe_single_audio(self, audio_path: Path) -> str:
        files = {
            'file': (audio_path.name, open(audio_path, 'rb'), 'audio/mpeg'),
            'model': (None, self.model)
        }
        headers = {"Authorization": f"Bearer {self.api_key}"}
        try:
            response = requests.post(self.api_base_url, files=files, headers=headers)
            response.raise_for_status()
            result = response.json()
            if 'text' in result:
                return result['text']
            return response.text
        except Exception as e:
            raise Exception(f"提取文字时出错: {str(e)}")
        finally:
            files['file'][1].close()

    def extract_text_from_audio(self, audio_path: Path, show_progress: bool = True) -> str:
        if not self.api_key:
            raise ValueError("未设置环境变量 API_KEY，请先配置硅基流动密钥")

        audio_info = self.get_audio_info(audio_path)
        max_duration = 3600
        max_size = 50 * 1024 * 1024
        need_split = audio_info['duration'] > max_duration or audio_info['size'] > max_size

        if not need_split:
            if show_progress:
                print("正在识别语音...")
            return self.transcribe_single_audio(audio_path)

        if show_progress:
            print(f"音频文件较大（时长: {audio_info['duration']:.0f}秒, 大小: {audio_info['size'] / 1024 / 1024:.1f}MB）")
            print("将自动分段处理...")

        segments = self.split_audio(audio_path, segment_duration=540, show_progress=show_progress)
        all_texts = []
        for i, segment_path in enumerate(segments):
            if show_progress:
                print(f"正在识别第 {i + 1}/{len(segments)} 段...")
            text = self.transcribe_single_audio(segment_path)
            all_texts.append(text)
            if segment_path != audio_path:
                self.cleanup_files(segment_path)

        merged_text = ''.join(all_texts)
        if show_progress:
            print(f"语音识别完成，共处理 {len(segments)} 个片段")
        return merged_text

    def cleanup_files(self, *file_paths: Path):
        for file_path in file_paths:
            if file_path.exists():
                file_path.unlink()


def get_video_info(share_link: str) -> dict:
    processor = DouyinProcessor()
    return processor.parse_share_url(share_link)


def download_video(share_link: str, output_dir: str = ".") -> Path:
    processor = DouyinProcessor()
    video_info = processor.parse_share_url(share_link)
    return processor.download_video(video_info, Path(output_dir))


def extract_text(share_link: str, [REDACTED_SECRET] = None, output_dir: Optional[str] = None,
                 save_video: bool = False, show_progress: bool = True) -> dict:
    [REDACTED_SECRET] or os.getenv('API_KEY')
    if not api_key:
        raise ValueError("未设置环境变量 API_KEY，请先获取硅基流动 API 密钥")

    processor = DouyinProcessor(api_key)
    if show_progress:
        print("正在解析抖音分享链接...")
    video_info = processor.parse_share_url(share_link)

    if show_progress:
        print("正在下载视频...")
    video_path = processor.download_video(video_info, show_progress=show_progress)

    if show_progress:
        print("正在提取音频...")
    audio_path = processor.extract_audio(video_path, show_progress=show_progress)

    if show_progress:
        print("正在从音频中提取文本...")
    text_content = processor.extract_text_from_audio(audio_path, show_progress=show_progress)

    result = {"video_info": video_info, "text": text_content, "output_path": None}
    if output_dir:
        output_base = Path(output_dir)
        video_folder = output_base / video_info['video_id']
        video_folder.mkdir(parents=True, exist_ok=True)

        transcript_path = video_folder / "transcript.md"
        with open(transcript_path, 'w', encoding='utf-8') as f:
            f.write(f"# {video_info['title']}\n\n")
            f.write(f"| 属性 | 值 |\n")
            f.write(f"|------|----|\n")
            f.write(f"| 视频ID | `{video_info['video_id']}` |\n")
            f.write(f"| 提取时间 | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |\n")
            f.write(f"| 下载链接 | [点击下载]({video_info['url']}) |\n\n")
            f.write(f"---\n\n")
            f.write(f"## 文案内容\n\n")
            f.write(text_content)
        result['output_path'] = str(video_folder)
        if show_progress:
            print(f"文案已保存到: {transcript_path}")

        if save_video:
            saved_video_path = video_folder / f"{video_info['video_id']}.mp4"
            shutil.copy2(video_path, saved_video_path)
            if show_progress:
                print(f"视频已保存到: {saved_video_path}")

    if show_progress:
        print("正在清理临时文件...")
    processor.cleanup_files(video_path, audio_path)
    return result


def main():
    parser = argparse.ArgumentParser(description="抖音无水印视频下载和文案提取工具（douyin-video-skills 内置版）")
    parser.add_argument("--link", "-l", required=True, help="抖音分享链接或包含链接的文本")
    parser.add_argument("--action", "-a", choices=["info", "download", "extract"], default="info")
    parser.add_argument("--output", "-o", default="./output", help="输出目录")
    parser.add_argument("--api-key", "-k", help="硅基流动 API 密钥 (也可通过 API_KEY 环境变量设置)")
    parser.add_argument("--save-video", "-v", action="store_true", help="提取文案时同时保存视频")
    parser.add_argument("--quiet", "-q", action="store_true", help="安静模式")
    args = parser.parse_args()

    try:
        if args.action == "info":
            info = get_video_info(args.link)
            print(json.dumps(info, ensure_ascii=False, indent=2))
        elif args.action == "download":
            video_path = download_video(args.link, args.output)
            print(str(video_path))
        elif args.action == "extract":
            result = extract_text(args.link, args.api_key, output_dir=args.output, save_video=args.save_video, show_progress=not args.quiet)
            print(json.dumps(result, ensure_ascii=False, indent=2))
    except Exception as e:
        print(f"错误: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/run_pipeline.py
content: #!/usr/bin/env python3
import argparse
import json
import random
import re
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import List, Optional


ROOT = Path(__file__).resolve().parents[1]
DOWNLOADER = ROOT / 'scripts' / 'douyin_downloader.py'
TITLE_CHECK = ROOT / 'scripts' / 'title_match_check.py'
CLEANUP = ROOT / 'scripts' / 'transcript_cleanup.py'
MAX_TITLE_RETRY = 5
DEFAULT_PROFILE_DIR = str(Path.home() / '.playwright' / 'douyinflow')
DEFAULT_HUMAN_DELAY_MIN_MS = 800
DEFAULT_HUMAN_DELAY_MAX_MS = 2500
DEFAULT_CAPTCHA_MAX_WAITS = 3


@dataclass
class Candidate:
    ref: str
    title: str
    author: str = ''
    date_text: str = ''
    duration_sec: int = 0
    likes_text: str = ''


def run(cmd: List[str], check: bool = True, input_text: Optional[str] = None) -> str:
    res = subprocess.run(cmd, input=input_text, text=True, capture_output=True)
    if check and res.returncode != 0:
        raise RuntimeError(f"command failed: {' '.join(cmd)}\nSTDOUT:\n{res.stdout}\nSTDERR:\n{res.stderr}")
    return res.stdout.strip()


def pw(session: str, *args: str, check: bool = True) -> str:
    return run(['playwright-cli', f'-s={session}', *args], check=check)


def human_sleep(min_ms: int, max_ms: int):
    low = max(0, min(min_ms, max_ms))
    high = max(0, max(min_ms, max_ms))
    time.sleep(random.uniform(low, high) / 1000.0)


def get_snapshot(session: str) -> str:
    return pw(session, '--raw', 'snapshot')


def has_captcha(snapshot: str) -> bool:
    markers = [
        '验证码中间页',
        '点击两个形状相同的物体',
        '请完成下列验证后继续',
        '按住左边按钮拖动完成上方拼图',
        'captcha_container',
    ]
    return any(marker in snapshot for marker in markers)


def wait_for_captcha_resolution(session: str, max_waits: int = DEFAULT_CAPTCHA_MAX_WAITS):
    for _ in range(max_waits):
        snap = get_snapshot(session)
        if not has_captcha(snap):
            return
        print('检测到抖音验证码，请在浏览器中完成验证后按回车继续...', file=sys.stderr)
        input()
    snap = get_snapshot(session)
    if has_captcha(snap):
        raise SystemExit('当前会话被抖音验证码拦截，请先在浏览器中完成验证后重试')


def parse_duration(text: str) -> int:
    m = re.match(r'^(\d{2}):(\d{2})$', text.strip())
    if not m:
        return 0
    return int(m.group(1)) * 60 + int(m.group(2))


def parse_candidates(snapshot: str) -> List[Candidate]:
    candidates: List[Candidate] = []
    item_pattern = re.compile(r'(^\s*- listitem \[ref=(e\d+)\]:\n(?P<block>(?:\s+.*\n)+?))(?=^\s*- listitem \[ref=e\d+\]:|\Z)', re.M)
    leaf_generic = re.compile(r'- generic \[ref=e\d+\]: ?"?(.*?)"?$')
    date_re = re.compile(r'^(\d+分钟前|\d+小时前|\d+天前|\d+周前|\d+月前)$')

    for match in item_pattern.finditer(snapshot + '\n'):
        ref = match.group(2)
        block = match.group('block')
        title = ''
        author = ''
        date_text = ''
        duration_sec = 0
        likes_text = ''
        generic_values = []

        for raw_line in block.splitlines():
            line = raw_line.strip()
            if not line:
                continue

            m_dur = re.search(r'(^|\s)(\d{2}:\d{2})$', line)
            if m_dur and duration_sec == 0:
                duration_sec = parse_duration(m_dur.group(2))

            m_leaf = leaf_generic.search(line)
            if m_leaf:
                value = m_leaf.group(1).strip()
                if value:
                    generic_values.append(value)

        for value in generic_values:
            if value.startswith('@') and not author:
                author = value
                continue
            if date_re.fullmatch(value) and not date_text:
                date_text = value
                continue
            if re.fullmatch(r'[\d.]+万?|[\d.]+', value) and not likes_text:
                likes_text = value
                continue

        title_candidates = []
        for value in generic_values:
            if value == '合集':
                continue
            if value.startswith('@'):
                continue
            if date_re.fullmatch(value):
                continue
            if re.fullmatch(r'[\d.]+万?|[\d.]+', value):
                continue
            if re.fullmatch(r'\d{2}:\d{2}', value):
                continue
            if len(value) < 8:
                continue
            title_candidates.append(value)

        if title_candidates:
            title = max(title_candidates, key=len)

        if title:
            candidates.append(Candidate(ref=ref, title=title, author=author, date_text=date_text, duration_sec=duration_sec, likes_text=likes_text))
    return candidates


def like_to_num(text: str) -> float:
    text = text.strip()
    if not text:
        return 0
    if text.endswith('万'):
        try:
            return float(text[:-1]) * 10000
        except Exception:
            return 0
    try:
        return float(text)
    except Exception:
        return 0


def filter_candidates(candidates: List[Candidate], args) -> List[Candidate]:
    result = []
    for c in candidates:
        title = c.title
        author = c.author
        if args.must_include and not all(word in title for word in args.must_include):
            continue
        if args.exclude_word and any(word in title or word in author for word in args.exclude_word):
            continue
        if args.content_type_hint and not any(word in title for word in args.content_type_hint):
            continue
        if args.account_hint and not any(word in author for word in args.account_hint):
            continue
        if args.duration_min_sec and c.duration_sec and c.duration_sec < args.duration_min_sec:
            continue
        if args.duration_max_sec and c.duration_sec and c.duration_sec > args.duration_max_sec:
            continue
        if args.min_likes and like_to_num(c.likes_text) < args.min_likes:
            continue
        result.append(c)
    return result


def ensure_open(session: str, headed: bool, persistent: bool, profile: Optional[str] = None):
    cmd = ['playwright-cli', f'-s={session}', 'open', 'https://www.douyin.com/']
    if headed:
        cmd.append('--headed')
    if persistent:
        cmd.append('--persistent')
    if profile:
        cmd.extend(['--profile', profile])
    run(cmd, check=True)


def maybe_wait_for_login(session: str):
    snap = get_snapshot(session)
    if 'button "登录"' in snap or 'paragraph [ref=e167]: 登录' in snap:
        print('检测到未登录，请在打开的浏览器中手动登录后按回车继续...', file=sys.stderr)
        input()


def search_keyword(session: str, keyword: str, human_delay_min_ms: int, human_delay_max_ms: int, captcha_max_waits: int):
    pw(session, 'run-code', "async page => { await page.goto('https://www.douyin.com/'); await page.waitForTimeout(2200); }")
    human_sleep(human_delay_min_ms, human_delay_max_ms)
    wait_for_captcha_resolution(session, captcha_max_waits)

    fill_and_search = (
        "async page => {"
        f" const box = page.getByRole('textbox', {{ name: '搜索你感兴趣的内容' }});"
        f" await box.click();"
        f" await page.waitForTimeout({max(250, human_delay_min_ms // 2)});"
        f" await box.fill({json.dumps(keyword)});"
        f" await page.waitForTimeout({max(400, human_delay_min_ms)});"
        f" await page.getByRole('button', {{ name: '搜索' }}).click();"
        f" await page.waitForTimeout({max(3000, human_delay_max_ms + 1500)});"
        " }"
    )
    pw(session, 'run-code', fill_and_search)
    human_sleep(human_delay_min_ms, human_delay_max_ms)
    wait_for_captcha_resolution(session, captcha_max_waits)

    switch_video_tab = (
        "async page => {"
        " const tab = page.getByText('视频', { exact: true }).first();"
        " if (await tab.count()) {"
        "   await tab.click();"
        f"   await page.waitForTimeout({max(2200, human_delay_max_ms + 1200)});"
        " }"
        " }"
    )
    pw(session, 'run-code', switch_video_tab, check=False)
    human_sleep(human_delay_min_ms, human_delay_max_ms)
    wait_for_captcha_resolution(session, captcha_max_waits)


def click_candidate_by_title(session: str, title: str, human_delay_min_ms: int, human_delay_max_ms: int, captcha_max_waits: int):
    human_sleep(human_delay_min_ms, human_delay_max_ms)
    script = f"async page => {{ await page.getByText({json.dumps(title)}, {{ exact: true }}).click(); await page.waitForTimeout({max(1800, human_delay_max_ms + 800)}); }}"
    pw(session, 'run-code', script, check=False)
    wait_for_captcha_resolution(session, captcha_max_waits)


def close_modal(session: str):
    pw(session, 'key', 'Escape', check=False)


def get_modal_id(session: str) -> str:
    out = pw(session, 'eval', "new URL(location.href).searchParams.get('modal_id')")
    # Clean up: take first line and strip any markdown/code block markers
    lines = out.strip().splitlines()
    for line in lines:
        line = line.strip()
        if not line:
            continue
        # Remove markdown code block markers
        line = line.replace('```', '').strip()
        if line and not line.startswith('//') and not line.startswith('http'):
            # Looks like a video ID (numeric)
            if re.fullmatch(r'\d+', line):
                return line
    return ''


def check_title(expected: str, actual: str, mode: str = 'default', min_similarity: float = 0.82) -> dict:
    out = run([
        'python3', str(TITLE_CHECK),
        '--expected', expected,
        '--actual', actual,
        '--mode', mode,
        '--min-similarity', str(min_similarity),
    ], check=False)
    payload = json.loads(out)
    payload['exit_ok'] = True if payload.get('matched') else False
    return payload


def choose_valid_video(session: str, candidates: List[Candidate], start_index: int = 0, max_retry: int = MAX_TITLE_RETRY, title_match_mode: str = 'default', title_min_similarity: float = 0.82, human_delay_min_ms: int = DEFAULT_HUMAN_DELAY_MIN_MS, human_delay_max_ms: int = DEFAULT_HUMAN_DELAY_MAX_MS, captcha_max_waits: int = DEFAULT_CAPTCHA_MAX_WAITS):
    attempts = []
    upper = min(len(candidates), start_index + max_retry)
    for idx in range(start_index, upper):
        candidate = candidates[idx]
        click_candidate_by_title(session, candidate.title, human_delay_min_ms, human_delay_max_ms, captcha_max_waits)
        video_id = get_modal_id(session)
        if not video_id:
            attempts.append({
                'candidateIndex': idx + 1,
                'candidateTitle': candidate.title,
                'matched': False,
                'reason': 'missing-modal-id',
            })
            close_modal(session)
            continue

        share_info = get_share_info(video_id)
        actual_title = share_info['title']
        title_check = check_title(candidate.title, actual_title, mode=title_match_mode, min_similarity=title_min_similarity)
        attempts.append({
            'candidateIndex': idx + 1,
            'candidateTitle': candidate.title,
            'actualTitle': actual_title,
            'videoId': video_id,
            'matched': title_check.get('matched', False),
            'reason': title_check.get('reason', 'unknown'),
            'similarity': title_check.get('similarity'),
            'mode': title_check.get('mode', title_match_mode),
        })
        if title_check.get('matched'):
            return candidate, video_id, actual_title, attempts

        close_modal(session)

    return None, None, None, attempts


def get_share_info(video_id: str) -> dict:
    out = run(['python3', str(DOWNLOADER), '--link', f'https://www.iesdouyin.com/share/video/{video_id}', '--action', 'info'])
    return json.loads(out)


def write_meta(outdir: Path, payload: dict):
    outdir.mkdir(parents=True, exist_ok=True)
    (outdir / 'meta.json').write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
    if 'share_link' in payload:
        (outdir / 'source-link.txt').write_text(payload['share_link'] + '\n', encoding='utf-8')


def main():
    parser = argparse.ArgumentParser(description='抖音搜索→筛选→校验→提取→修正 一体化脚本')
    parser.add_argument('--keyword', required=True)
    parser.add_argument('--pick-index', type=int, default=1)
    parser.add_argument('--must-include', action='append', default=[])
    parser.add_argument('--exclude-word', action='append', default=[])
    parser.add_argument('--content-type-hint', action='append', default=[])
    parser.add_argument('--account-hint', action='append', default=[])
    parser.add_argument('--min-likes', type=float, default=0)
    parser.add_argument('--duration-min-sec', type=int, default=0)
    parser.add_argument('--duration-max-sec', type=int, default=0)
    parser.add_argument('--session', default='douyinflow')
    parser.add_argument('--profile', default=DEFAULT_PROFILE_DIR, help='playwright-cli 持久化浏览器 profile 目录')
    parser.add_argument('--title-match-mode', choices=['strict', 'default', 'loose'], default='default')
    parser.add_argument('--title-min-similarity', type=float, default=0.82)
    parser.add_argument('--max-title-retry', type=int, default=MAX_TITLE_RETRY)
    parser.add_argument('--human-delay-min-ms', type=int, default=DEFAULT_HUMAN_DELAY_MIN_MS)
    parser.add_argument('--human-delay-max-ms', type=int, default=DEFAULT_HUMAN_DELAY_MAX_MS)
    parser.add_argument('--captcha-max-waits', type=int, default=DEFAULT_CAPTCHA_MAX_WAITS)
    parser.add_argument('--headed', action='store_true')
    parser.add_argument('--persistent', action='store_true')
    parser.add_argument('--output-dir', default='output')
    parser.add_argument('--skip-login-check', action='store_true')
    args = parser.parse_args()

    ensure_open(args.session, args.headed, args.persistent, args.profile)
    if not args.skip_login_check:
        maybe_wait_for_login(args.session)

    search_keyword(args.session, args.keyword, args.human_delay_min_ms, args.human_delay_max_ms, args.captcha_max_waits)
    snapshot = get_snapshot(args.session)
    if has_captcha(snapshot):
        raise SystemExit('当前会话被抖音验证码拦截，请先在浏览器中完成验证后重试')
    candidates = parse_candidates(snapshot)
    candidates = filter_candidates(candidates, args)
    if not candidates:
        raise SystemExit('未找到符合筛选条件的视频候选项')

    if args.pick_index < 1 or args.pick_index > len(candidates):
        raise SystemExit(f'pick-index 超出范围，可选数量: {len(candidates)}')

    start_index = args.pick_index - 1
    target, video_id, actual_title, attempts = choose_valid_video(
        args.session,
        candidates,
        start_index=start_index,
        max_retry=args.max_title_retry,
        title_match_mode=args.title_match_mode,
        title_min_similarity=args.title_min_similarity,
        human_delay_min_ms=args.human_delay_min_ms,
        human_delay_max_ms=args.human_delay_max_ms,
        captcha_max_waits=args.captcha_max_waits,
    )
    if not target or not video_id or not actual_title:
        raise SystemExit('候选视频已尝试，但未找到标题校验通过的视频；请调整筛选条件、pick-index，或放宽标题匹配规则')

    share_link = f'https://www.iesdouyin.com/share/video/{video_id}'
    out = run(['python3', str(DOWNLOADER), '--link', share_link, '--action', 'extract', '--output', args.output_dir], check=True)

    output_path = None
    try:
        extracted = json.loads(out)
        output_path = extracted.get('output_path')
    except Exception:
        # fallback: conventional output dir
        output_path = str(Path(args.output_dir) / video_id)

    outdir = Path(output_path)
    transcript = outdir / 'transcript.md'
    run(['python3', str(CLEANUP), '--title', actual_title, '--raw', str(transcript), '--outdir', str(outdir)], check=True)

    meta = {
        'keyword': args.keyword,
        'pickIndex': args.pick_index,
        'selected': {
            'title': target.title,
            'author': target.author,
            'date': target.date_text,
            'durationSec': target.duration_sec,
            'likes': target.likes_text,
        },
        'validatedTitle': actual_title,
        'videoId': video_id,
        'share_link': share_link,
        'profile': args.profile,
        'humanDelayMinMs': args.human_delay_min_ms,
        'humanDelayMaxMs': args.human_delay_max_ms,
        'captchaMaxWaits': args.captcha_max_waits,
        'titleMatchMode': args.title_match_mode,
        'titleMinSimilarity': args.title_min_similarity,
        'titleValidationAttempts': attempts,
        'generatedAt': datetime.now().isoformat(timespec='seconds')
    }
    write_meta(outdir, meta)
    print(json.dumps(meta, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: scripts/title_match_check.py
content: #!/usr/bin/env python3
import argparse
import json
import re
import sys
from difflib import SequenceMatcher


PUNCT_RE = re.compile(r'[\-—_~·•|,，。！？!?:：;；()（）\[\]【】"“”‘’`…]')


def normalize_title(text: str) -> str:
    text = text.strip()
    text = re.sub(r'[#＃].*$', '', text)  # drop trailing hashtags
    text = PUNCT_RE.sub('', text)
    text = re.sub(r'\s+', '', text)
    return text.lower()


def title_similarity(expected: str, actual: str) -> float:
    return SequenceMatcher(None, expected, actual).ratio()


def is_match(expected: str, actual: str, mode: str, min_similarity: float) -> tuple[bool, str, float]:
    expected_n = normalize_title(expected)
    actual_n = normalize_title(actual)
    similarity = title_similarity(expected_n, actual_n)

    if expected_n == actual_n:
        return True, 'exact-match', similarity
    if expected_n and actual_n and (expected_n in actual_n or actual_n in expected_n):
        return True, 'contain-match', similarity

    threshold = min_similarity
    if mode == 'loose':
        threshold = min(min_similarity, 0.72)
    elif mode == 'strict':
        threshold = max(min_similarity, 0.9)

    if similarity >= threshold:
        return True, 'similarity-match', similarity
    return False, 'title-mismatch', similarity


def main():
    parser = argparse.ArgumentParser(description='校验当前弹层标题是否与目标搜索结果标题一致')
    parser.add_argument('--expected', required=True, help='目标搜索结果标题')
    parser.add_argument('--actual', required=True, help='当前弹层标题')
    parser.add_argument('--mode', choices=['strict', 'default', 'loose'], default='default', help='匹配模式')
    parser.add_argument('--min-similarity', type=float, default=0.82, help='最小标题相似度阈值，默认 0.82')
    args = parser.parse_args()

    matched, reason, similarity = is_match(args.expected, args.actual, args.mode, args.min_similarity)
    expected_n = normalize_title(args.expected)
    actual_n = normalize_title(args.actual)

    result = {
        'matched': matched,
        'expected': args.expected,
        'actual': args.actual,
        'expected_normalized': expected_n,
        'actual_normalized': actual_n,
        'mode': args.mode,
        'similarity': round(similarity, 4),
        'min_similarity': args.min_similarity,
        'reason': reason,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if not matched:
        sys.exit(2)


if __name__ == '__main__':
    main()


path: scripts/transcript_cleanup.py
content: #!/usr/bin/env python3
import argparse
import re
from pathlib import Path


def extract_body(md_text: str) -> str:
    marker = '## 文案内容'
    if marker in md_text:
        return md_text.split(marker, 1)[1].strip()
    return md_text.strip()


def normalize_text(text: str) -> str:
    text = text.replace('🎼', '').strip()
    text = re.sub(r'\s+', ' ', text)
    text = text.replace('。。', '。').replace('，，', '，')
    return text


def sentence_cleanup(text: str) -> str:
    replacements = [
        ('学学飞行原理', '学飞行原理'),
        ('靠挫力', '抗挫力'),
        ('一个能力', '能力'),
        ('在问题中成长，在挑战中去突破', '在问题中成长，在挑战中突破'),
        ('应用等技能拓展', '应用等技能拓展'),
        ('我要不要告诉他呢', '要不要告诉他呢'),
        ('都能学学', '都能学'),
        ('医生有保护圈', '机身有保护圈'),
    ]
    for old, new in replacements:
        text = text.replace(old, new)

    # 常见断句修正
    text = text.replace('第一呢', '第一，')
    text = text.replace('第二，能够', '第二，能够')
    text = text.replace('第三，培养', '第三，培养')
    text = text.replace('第四，打开', '第四，打开')
    text = re.sub(r'([。！？])(?=[^\n])', r'\1\n', text)
    return text.strip()


def build_fixes(title: str, raw: str, clean: str) -> str:
    fixes = []
    if '学学飞行原理' in raw and '学飞行原理' in clean:
        fixes.append(('- 学学飞行原理', '+ 学飞行原理', '重复词，结合上下文修正', '高'))
    if '靠挫力' in raw and '抗挫力' in clean:
        fixes.append(('- 靠挫力', '+ 抗挫力', '高概率同音误识别，结合常见教育表达修正', '高'))
    if '一个能力' in raw and '能力' in clean:
        fixes.append(('- 建立系统化解决问题的一个能力', '+ 建立系统化解决问题的能力', '语序更自然，语义不变', '中'))
    if '我要不要告诉他呢' in raw and '要不要告诉他呢' in clean:
        fixes.append(('- 我要不要告诉他呢', '+ 要不要告诉他呢', '去除疑似冗余口语重复', '中'))
    if '医生有保护圈' in raw and '机身有保护圈' in clean:
        fixes.append(('- 医生有保护圈', '+ 机身有保护圈', '结合标题语境与无人机常识，判断为高概率同音误识别', '高'))

    lines = [f'# 文案修正说明', '', f'- 标题：{title}', '']
    if not fixes:
        lines += ['- 本次未发现高置信明显错误，或仅做了轻度标点整理。', '', '## 待确认', '- 无']
        return '\n'.join(lines)

    lines += ['## 已修正项']
    for old, new, reason, conf in fixes:
        lines += [old, new, f'  - 依据：{reason}', f'  - 置信度：{conf}', '']
    lines += ['## 待确认', '- 如原视频存在角色对白或反问句，开头口语句仍建议人工复核。']
    return '\n'.join(lines)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--title', required=True)
    parser.add_argument('--raw', required=True)
    parser.add_argument('--outdir', required=True)
    args = parser.parse_args()

    raw_path = Path(args.raw)
    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)

    raw_md = raw_path.read_text(encoding='utf-8')
    raw_body = normalize_text(extract_body(raw_md))
    clean_body = sentence_cleanup(raw_body)
    fixes_md = build_fixes(args.title, raw_body, clean_body)

    raw_out = outdir / 'transcript-raw.md'
    clean_out = outdir / 'transcript-clean.md'
    fixes_out = outdir / 'transcript-fixes.md'

    raw_out.write_text(f'# 原始转写稿\n\n- 标题：{args.title}\n\n## 文案内容\n\n{raw_body}\n', encoding='utf-8')
    clean_out.write_text(f'# 修正版文案\n\n- 标题：{args.title}\n\n## 文案内容\n\n{clean_body}\n', encoding='utf-8')
    fixes_out.write_text(fixes_md + '\n', encoding='utf-8')

    print(str(raw_out))
    print(str(clean_out))
    print(str(fixes_out))


if __name__ == '__main__':
    main()


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