Skill: kevin1220/kevin-toutiao-auto-publisher (version 2.0.0)

--- name: 头条全自动发布 description: 自动发布图文文章和微头条到头条号，支持选题、写文、配图生成、Cookie登录、图文混排发布的全自动流程，也可定时自动化执行。 --- # 头条全自动发布 Skill 全自动发布图文文章和微头条到头条号的 AI Agent Skill。 ## 功能特性 - **图文文章发布**：从选题 → 写文 → 配图 → 发布的完整流程 - **微头条发布**：快速发布短内容（~200字）到微头条 - **配图生成**：自动生成水墨中国风配图（支持自定义风格） - **Cookie 登录**：通过 Cookie 注入实现免验证码登录 - **图文混排**：正文与配图混排，排版美观 - **飞书通知**：发布成功/失败后自动推送通知 - **定时自动化**：支持每小时自动发布（可配置时间段） - **热门话题获取**：自动从话题邀请中筛选围观最多的话题 ## 前置条件 1. 已有头条号（mp.toutiao.com） 2. 已安装 Python 3.8+ 和 Playwright 3. Chrome 浏览器已安装 4. 已获取头条号的 Cookie（JSON 格式） ## 快速开始 ### 第一步：配置账号 复制 `config/account_config.json.example` 为 `config/account_config.json`，填入你的信息： ```json { "account_id": "你的头条账号ID", "account_name": "你的头条号名称", "cookie_file": "/path/to/toutiao_cookies.json", "inject_script": "/path/to/inject_cookies.sh", "feishu_config": { "app_id": "可选，飞书机器人APP_ID", "app_secret": "可选，飞书机器人APP_SECRET", "open_id": "可选，飞书通知接收人OPEN_ID" } } ``` ### 第二步：获取 Cookie 1. 用 Chrome 打开 https://mp.toutiao.com 并登录 2. 按 F12 → Application → Cookies → 复制所有 Cookie 到 JSON 文件 3. Cookie 有效期约 30 天，过期需重新获取 ### 第三步：安装依赖 ```bash pip install playwright playwright install chromium pip install -r requirements.txt ``` ## 使用方法 ### 方式一：AI Agent 自动执行（推荐） 安装此 Skill 后，直接用自然语言指示 Agent： - "帮我发布一篇关于「中年焦虑」的头条文章" - "写一篇微头条，主题是职场困境，200字" - "生成一张水墨中国风的配图" - "设置每天 6 点到 23 点每小时自动发微头条" Agent 会自动完成：选题 → 内容生成 → 配图生成 → Cookie 注入 → 发布 → 通知 ### 方式二：脚本调用 ```bash # 发布图文文章 python3 scripts/publish_article.py python3 scripts/publish_article.py --topic "中年困境" python3 scripts/publish_article.py --no-publish # 只生成不发布 # 发布微头条 python3 scripts/publish_weitoutiao.py python3 scripts/publish_weitoutiao.py --content "你的内容" python3 scripts/publish_weitoutiao.py --auto-topic # 自动选题 # 定时自动化 python3 scripts/run_scheduler.py --type article python3 scripts/run_scheduler.py --type weitoutiao python3 scripts/run_scheduler.py --type both ``` ## 工作流程 ### 图文文章发布 ``` 选题/指定话题 → 生成文章内容 → 生成1张水墨风配图 → Cookie注入登录 → 导航到发布页 → 填写标题 → 填写正文 → 插入配图到正文 → 设置封面 → 勾选声明 → 发布 → 通知 ``` **发布 URL**：`https://mp.toutiao.com/profile_v4/graphic/publish` **关键步骤**： 1. 通过 Playwright CDP 连接已有 Chrome（端口 9222） 2. Cookie 注入后导航到发布页 3. 标题用 JS 原生 setter + 事件触发（避免 ProseMirror 拦截） 4. 正文用 `insertHTML` 一次性插入段落 5. 配图通过工具栏图片按钮 → file input 上传 6. 封面选择"单图"模式上传 7. 勾选"个人观点，仅供参考"声明 8. 点击"预览并发布" → "确认发布" ### 微头条发布 ``` 获取热门话题 → 生成短内容（~200字） → 生成配图 → Cookie注入登录 → 导航到微头条发布页 → 填写内容 → 上传配图 → 发布 → 通知 ``` **发布 URL**：`https://mp.toutiao.com/profile_v4/weitoutiao/publish` ## 配置说明 ### account_config.json | 字段 | 必填 | 说明 | |------|------|------| | account_id | 是 | 头条账号 ID | | account_name | 是 | 头条号名称 | | cookie_file | 是 | Cookie JSON 文件路径 | | inject_script | 否 | Cookie 注入脚本路径 | | feishu_config | 否 | 飞书通知配置 | ### automation_settings.json ```json { "article": { "enabled": true, "schedule": "0 * 6-23 * * *", "max_retries": 3, "timeout": 300 }, "weitoutiao": { "enabled": true, "schedule": "0 * 6-23 * * *", "auto_select_topic": true, "timeout": 180 } } ``` ## 核心模块 API ### PlaywrightPublisher（浏览器自动化发布引擎） ```python from core.playwright_publisher import PlaywrightPublisher pub = PlaywrightPublisher(headless=False, account_name="你的账号名") await pub.start() # 启动 CDP 连接 await pub.load_cookies("/path/to/cookies.json") # 注入 Cookie await pub.navigate_to_publish() # 导航到发布页 await pub.fill_title("文章标题") # 填写标题 await pub.fill_content("文章正文") # 填写正文 await pub.insert_image("/path/to/img.png") # 插入正文配图 await pub.upload_cover_image("/path/to/img.png") # 设置封面 await pub.check_declarations() # 勾选声明 success = await pub.publish() # 发布 ``` ### PublisherManager（API 方式发布） ```python from core.publisher import PublisherManager pub = PublisherManager(config_file="config/account_config.json") # 图文发布 result = await pub.publish_article( title="标题", content="内容", images=["/path/to/img1.png"], options={"personal_view": True} ) # 微头条发布 result = await pub.publish_weitoutiao( content="微头条内容", image="/path/to/img.png" ) # 获取热门话题 topics = await pub.get_topic_invitations() topic = pub.get_hot_topic(topics) # 围观最多的话题 ``` ## 配图生成 支持通过 AI 工具自动生成水墨中国风配图： ```python # 推荐提示词模板 prompt = "水墨中国风画，竹子，宣纸底色，红色印章，极简留白" size = "1024x1024" ``` **风格关键词**：竹子、宣纸、红色印章、极简、留白、山水、松树 ## 故障排查 | 问题 | 原因 | 解决方案 | |------|------|----------| | 发布失败，提示重新登录 | Cookie 过期 | 重新获取 Cookie（有效期约30天） | | 封面上传失败 | DOM 选择器变化 | 查看 logs/ 目录截图调试 | | 配图生成失败 | AI 服务不可用 | 检查网络或换时段重试 | | 预览对话框未弹出 | 页面加载慢 | 增加等待时间或检查截图 | | 定时任务不执行 | schedule 配置错误 | 检查 automation_settings.json | | AI 助手抽屉遮挡操作 | 头条 SPA 的 AI 浮层 | 自动用 JS 隐藏所有 drawer | ## 项目结构 ``` toutiao-publisher/ ├── SKILL.md # 本文件（ClawHub 标准格式） ├── README.md # 详细文档 ├── requirements.txt # Python 依赖 ├── config/ │ ├── account_config.json.example # 账号配置模板 │ └── automation_settings.json # 自动化设置 ├── core/ │ ├── playwright_publisher.py # Playwright 自动化发布引擎 │ ├── publisher.py # API 方式发布器 │ ├── article_generator.py # 文章内容生成 │ ├── image_generator.py # 配图生成 │ ├── cookie_manager.py # Cookie 管理 │ └── notifier.py # 通知系统 ├── scripts/ │ ├── publish_article.py # 图文发布脚本 │ ├── publish_weitoutiao.py # 微头条发布脚本 │ └── run_scheduler.py # 定时任务脚本 ├── templates/ │ ├── article_prompts/ # 文章生成提示词 │ └── weitoutiao_prompts/ # 微头条提示词 └── logs/ # 日志目录 ``` ## 安全建议 - **不要**将 Cookie 文件或包含密钥的配置文件上传到公开仓库 - 使用 `.gitignore` 忽略 `config/account_config.json` - 定期更新 Cookie（有效期约 30 天） - 遵守头条平台规则，避免违规操作 ## 许可证 MIT License - 欢迎自由使用和修改 ## 变更日志 ### v2.0.0 - 包含实际可执行代码 - 新增完整 Python 核心代码（core/ 目录） - 所有硬编码个人信息替换为配置变量（从 config/account_config.json 读取） - 新增 publish_article.py / publish_weitoutiao.py / run_scheduler.py 脚本 - 支持命令行参数调用 - 支持自定义个人画像（ArticleGenerator 支持传入 personal_profile） - 新增 .gitignore 保护敏感配置 ### v1.0.0 - 初始发布 - 文档和配置模板 ## 版本 v2.0.0 - 包含完整代码，通用化配置，可直接使用

path: core/__init__.py
content: """
头条全自动发布 - 核心模块
"""

from .playwright_publisher import PlaywrightPublisher
from .publisher import Publisher
from .article_generator import ArticleGenerator
from .image_generator import ImageGenerator
from .cookie_manager import CookieManager
from .feishu_notifier import FeishuNotifier

__all__ = [
    'PlaywrightPublisher',
    'Publisher',
    'ArticleGenerator',
    'ImageGenerator',
    'CookieManager',
    'FeishuNotifier'
]


path: core/article_generator.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
文章生成器 - 根据话题邀请生成头条文章
结合国学经典哲学思想 + 中年男人共鸣视角
支持自定义个人画像
"""

import random
from datetime import datetime


class ArticleGenerator:
    """文章生成器"""

    def __init__(self, personal_profile=None):
        """
        初始化文章生成器

        Args:
            personal_profile: 个人画像字典，包含 age/job/relationship/family/assets/dream/skills/current_action
                             如果不传，使用默认的通用画像
        """
        # 个人画像（可自定义）
        if personal_profile:
            self.personal_profile = personal_profile
        else:
            # 默认通用画像（示例，用户应根据自己情况修改）
            self.personal_profile = {
                "age": 36,
                "job": "企业员工",
                "relationship": "有对象（未婚）",
                "family": "丁克",
                "assets": "有车贷，房子还没买",
                "dream": "想有个属于自己的房子",
                "skills": ["编程", "视频剪辑", "AI"],
                "current_action": "正在用编程+AI做副业"
            }

        # 国学经典名句
        self.classics = {
            "道德经": [
                "上善若水。水善利万物而不争。",
                "企者不立，跨者不行。",
                "千里之行，始于足下。",
                "柔弱胜刚强。",
                "知人者智，自知者明。",
                "大巧若拙，大辩若讷。",
                "道常无为而无不为。"
            ],
            "易经": [
                "否极泰来。",
                "天行健，君子以自强不息。",
                "地势坤，君子以厚德载物。",
                "穷则变，变则通，通则久。",
                "君子藏器于身，待时而动。"
            ],
            "论语": [
                "学而不思则罔，思而不学则殆。",
                "己所不欲，勿施于人。",
                "三人行，必有我师焉。",
                "知之为知之，不知为不知，是知也。"
            ],
            "庄子": [
                "井蛙不可以语于海者，拘于虚也。",
                "吾生也有涯，而知也无涯。",
                "君子之交淡如水，小人之交甘若醴。"
            ]
        }

        # 自然时间表达
        self.time_expressions = [
            "今天晚上",
            "今天下班回家的路上",
            "躺到床上刷手机时",
            "靠在阳台抽烟时",
            "早上醒来刷头条时",
            "刚跟朋友喝完酒回家",
            "深夜加班到很晚时"
        ]

        # 故事类型（多元化）
        self.story_types = [
            "犹豫型",  # 想改变但犹豫
            "迷茫型",  # 不知方向
            "后悔型",  # 后悔过去选择
            "绝望型",  # 感到无助
            "觉醒型"   # 开始行动
        ]

    def generate_article(self, topic, view_count=0):
        """
        根据话题生成文章

        Args:
            topic: 话题标题
            view_count: 话题围观人数

        Returns:
            dict: 包含标题、内容的文章
        """
        # 1. 选择自然时间开头
        time_expr = random.choice(self.time_expressions)

        # 2. 选择经典名句
        classic_source = random.choice(list(self.classics.keys()))
        classic_quote = random.choice(self.classics[classic_source])

        # 3. 选择故事类型
        story_type = random.choice(self.story_types)

        # 4. 生成文章
        article = self._generate_article_content(
            topic=topic,
            time_expr=time_expr,
            classic_source=classic_source,
            classic_quote=classic_quote,
            story_type=story_type,
            view_count=view_count
        )

        # 5. 生成标题
        title = self._generate_title(topic, story_type)

        return {
            "title": title,
            "content": article,
            "topic": topic,
            "classic_source": classic_source,
            "classic_quote": classic_quote,
            "story_type": story_type,
            "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        }

    def _generate_article_content(self, topic, time_expr, classic_source, classic_quote, story_type, view_count):
        """生成文章内容"""
        # 根据不同话题类型生成不同内容
        content = f"{time_expr}，刷到一个热门话题：\"{topic}\"。\n\n"

        # 根据围观人数增加热度描述
        if view_count > 0:
            content += f"这个话题有{view_count}人围观，下面评论几万条。"

        # 生成正文
        content += self._generate_body_by_topic(topic, classic_source, classic_quote, story_type)

        return content

    def _generate_body_by_topic(self, topic, classic_source, classic_quote, story_type):
        """根据话题类型生成正文"""
        # 根据话题关键词匹配不同的内容模板
        if any(keyword in topic for keyword in ["35岁", "失业", "裁员", "辞职", "工作"]):
            return self._generate_career_content(classic_source, classic_quote, story_type)
        elif any(keyword in topic for keyword in ["缺钱", "买房", "车贷", "房贷", "钱"]):
            return self._generate_money_content(classic_source, classic_quote, story_type)
        elif any(keyword in topic for keyword in ["焦虑", "迷茫", "崩溃", "压力"]):
            return self._generate_anxiety_content(classic_source, classic_quote, story_type)
        else:
            return self._generate_general_content(classic_source, classic_quote, story_type)

    def _generate_career_content(self, classic_source, classic_quote, story_type):
        """生成职场相关内容"""
        content = ""

        # 个人经历
        content += f"我今年{self.personal_profile['age']}，在一家{self.personal_profile['job']}上班。这行什么情况大家心里有数，我现在还能干活，不代表明年还能。我知道这个年纪的人，一失业就是失业，再找工作太难。\n\n"

        # 认知转变
        content += "我以前总觉得，只要自己努力，就能撑下去。但我错了。\n\n"

        # 经典引用
        content += f"《{classic_source}》里说：\"{classic_quote}\"\n\n"

        # 经典解释
        content += self._explain_classic(classic_source, classic_quote, "career")

        # 转折和微光
        content += self._generate_hope()

        return content

    def _generate_money_content(self, classic_source, classic_quote, story_type):
        """生成金钱相关内容"""
        content = ""

        # 个人经历
        content += f"我和对象躺床上算账，{self.personal_profile['assets']}。{self.personal_profile['dream']}，我也想。但算来算去，存款差得远。\n\n"
        content += f"我们{self.personal_profile['relationship']}，是{self.personal_profile['family']}，压力好像比人家小。但我知道，这只是表面。房子没着落，心里总不踏实。\n\n"

        # 认知转变
        content += "我以前总觉得，只要努力工作，总能攒够钱买房。但我错了。\n\n"

        # 经典引用
        content += f"《{classic_source}》里说：\"{classic_quote}\"\n\n"

        # 经典解释
        content += self._explain_classic(classic_source, classic_quote, "money")

        # 转折和微光
        content += self._generate_hope()

        return content

    def _generate_anxiety_content(self, classic_source, classic_quote, story_type):
        """生成焦虑相关内容"""
        content = ""

        # 个人经历
        skills_str = "、".join(self.personal_profile['skills'])
        content += f"我也爱折腾，今年{self.personal_profile['age']}了。学{skills_str}，天天晚上忙到半夜。\n\n"
        content += f"我在一家{self.personal_profile['job']}上班，日子也过得去。但我心里知道，我不是不累，是不敢停下来。\n\n"

        # 认知转变
        content += "我以前总觉得，中年男人的焦虑是矫情。但我错了。\n\n"

        # 经典引用
        content += f"《{classic_source}》里说：\"{classic_quote}\"\n\n"

        # 经典解释
        content += self._explain_classic(classic_source, classic_quote, "anxiety")

        # 转折和微光
        content += self._generate_hope()

        return content

    def _generate_general_content(self, classic_source, classic_quote, story_type):
        """生成通用内容"""
        content = ""

        # 个人经历
        content += f"我今年{self.personal_profile['age']}，在一家{self.personal_profile['job']}上班。{self.personal_profile['assets']}。\n\n"
        content += f"我{self.personal_profile['relationship']}，{self.personal_profile['family']}。{self.personal_profile['dream']}，我想给对象更好的生活，但总感觉力不从心。\n\n"

        # 认知转变
        content += "我以前总觉得，只要坚持就能改变。但我错了。\n\n"

        # 经典引用
        content += f"《{classic_source}》里说：\"{classic_quote}\"\n\n"

        # 经典解释
        content += self._explain_classic(classic_source, classic_quote, "general")

        # 转折和微光
        content += self._generate_hope()

        return content

    def _explain_classic(self, source, quote, topic_type):
        """解释经典名句"""
        explanations = {
            "道德经": {
                "career": "我以前不懂这话什么意思，现在我明白了。水是最软的东西，但水能适应任何容器。水流到地上，就变成水沟；流到河里，就变成江河；流到海里，就变成大海。水不固执，水不争强，但水能活下去。\n\n中年男人最大的困境，不是不够努力，是太固执。我们习惯了按部就班，习惯了靠平台、靠公司，把自己变成了固定的形状。现在平台不要你了，你还是那个形状，但已经装不下了。\n\n水不是认命，是换个活法。",
                "money": "我以前总觉得，缺钱是因为我不够努力。但现在我才发现，不是这样的。房价越来越高，工资涨得远远跟不上。这不是我不努力，是这个世道变了。\n\n但我明白了这句话的意思：不是等好运来，是自己在困境中找到出路。",
                "anxiety": "意思是：踮着脚尖站不稳，跨大步走不远。想太高站不稳，想太快走不远。\n\n我以前总想着要稳定，要安稳，要一步步往上走。但我发现，中年男人最大的困境，不是不敢辞职，是没有别的路可走。\n\n我们习惯了靠公司、靠平台，把自己的路走窄了。现在公司不要你了，你连个出口都找不到。",
                "general": "这句话我以前听过很多次，但直到今天才真正理解。它不是让你躺平，是让你找到自己的节奏。\n\n我们这代人活得太累了，总想着要往上爬，要追上别人。但每个人都有自己的节奏，太快反而走不远。"
            },
            "易经": {
                "career": "古人说\"否极泰来\"，不是让你等好运，是让你在最困难的时候，看见希望的方向。困境不是终点，是转折点。",
                "money": "意思是阻碍、不通。后面跟着\"泰卦\"，意思是通泰、安宁。古人说\"否极泰来\"，不是让你等好运，是让你在最困难的时候，看见希望的方向。",
                "anxiety": "意思是：穷则变，变则通，通则久。走到尽头了就要改变，改变了才能通达，通达了才能长久。\n\n我以前总觉得，中年男人的焦虑是矫情。现在我明白了：焦虑是因为走到尽头了，需要改变。不是让你放弃，是让你换个方向。",
                "general": "这句话我以前听过很多次，但直到今天才真正理解。它不是让你躺平，是让你找到自己的节奏。困境不是终点，是转折点。"
            },
            "论语": {
                "career": "学习很重要，但思考更重要。光靠蛮力干活，不如停下来想想方向。\n\n中年男人最大的问题，不是不够努力，是太习惯于不思考。公司让干什么就干什么，领导说什么就听什么，从来没想过自己到底想要什么。\n\n到了35岁，才突然发现，自己除了干活什么都不会。",
                "money": "自己不想要的，不要强加给别人。反过来也是一样——别人觉得好的路，未必适合你。\n\n有人觉得攒钱买房就是幸福，有人觉得租房自由也挺好。没有标准答案，关键是搞清楚自己要什么。",
                "anxiety": "焦虑不是因为做得不够多，是因为想得不够清楚。\n\n该停下来想想了：我到底在追求什么？是别人的认可，还是自己的内心？",
                "general": "三个人一起走路，其中一定有我的老师。意思是，每个人都有值得学习的地方。\n\n不要觉得自己年纪大了就不用学了。学无止境，只要还在学习，就还有希望。"
            },
            "庄子": {
                "career": "井底的青蛙，你跟它说大海有多大，它听不懂。因为它只见过井口那么大的天。\n\n中年人也是一样，我们被困在自己的舒适圈里，以为自己看到的就是全世界。但其实外面的世界大得多。\n\n打破舒适圈，才能看到更大的世界。",
                "money": "人的生命是有限的，但知识是无限的。用有限的生命去追求无限的东西，太累了。\n\n钱也是一样，赚不完的。别让钱成为你人生唯一的目标。",
                "anxiety": "真正的朋友之间，关系是淡的，像白开水一样，不热烈，但长久。\n\n中年人之间的焦虑，很多是跟别人比较出来的。别比了，过好自己的日子。",
                "general": "这句话的意思是，不要被自己有限的认知束缚住了。\n\n我们每个人都是那只井底的青蛙，只是井口大小不同罢了。承认自己的无知，才是真正的智慧。"
            }
        }

        return explanations.get(source, {}).get(topic_type, f"这句话的意思很深，值得细细品味。")

    def _generate_hope(self):
        """生成转折和微光"""
        hope = f"但最近我明白了一个事：路不是等来的，是自己开出来的。\n\n"
        hope += f"我现在{self.personal_profile['current_action']}，虽然还没起色，但至少方向是对的。我知道这很难，也知道很慢，但至少这是我自己的路。\n\n"
        hope += "中年男人的安全感，从来都不是别人给的，是自己一步一步走出来的。"

        return hope

    def _generate_title(self, topic, story_type):
        """生成文章标题"""
        age = self.personal_profile.get('age', '')
        titles = [
            f"关于{topic[:10]}，我想了很久",
            f"今天刷到\"{topic[:15]}\"的话题，我想起了一句话",
            f"{age}岁，聊聊{topic[:8]}" if age else f"聊聊{topic[:8]}",
            f"中年男人的困境：{topic[:12]}",
            f"关于{topic[:10]}，我才发现自己错了"
        ]

        return random.choice(titles)


def main():
    """测试文章生成器"""
    generator = ArticleGenerator()

    # 测试生成文章
    topic = "35岁被裁员，该何去何从"
    article = generator.generate_article(topic, view_count=50000)

    print(f"标题：{article['title']}")
    print(f"话题：{article['topic']}")
    print(f"经典来源：{article['classic_source']}")
    print(f"经典名句：{article['classic_quote']}")
    print(f"故事类型：{article['story_type']}")
    print(f"生成时间：{article['generated_at']}")
    print("\n" + "="*50 + "\n")
    print(article['content'])


if __name__ == "__main__":
    main()


path: core/cookie_manager.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Cookie管理器 - 管理头条Cookie登录
支持从配置文件读取Cookie文件路径
"""

import os
import json
from datetime import datetime


class CookieManager:
    """Cookie管理器"""

    def __init__(self, cookie_path=None, config_file=None):
        """
        初始化Cookie管理器

        Args:
            cookie_path: Cookie文件路径（优先级更高）
            config_file: 配置文件路径，包含 cookie_file 字段
        """
        # 优先使用直接传入的路径
        if cookie_path:
            self.cookie_path = cookie_path
        elif config_file and os.path.exists(config_file):
            # 从配置文件读取
            with open(config_file, 'r', encoding='utf-8') as f:
                config = json.load(f)
            self.cookie_path = config.get("cookie_file")
        else:
            self.cookie_path = None
        
        self.cookies = None
        if self.cookie_path:
            self.load_cookies()

    def load_cookies(self):
        """加载Cookie"""
        if self.cookie_path and os.path.exists(self.cookie_path):
            with open(self.cookie_path, 'r', encoding='utf-8') as f:
                self.cookies = json.load(f)
            print(f"[Cookie管理器] Cookie加载成功: {os.path.basename(self.cookie_path)}")
            return True
        else:
            print(f"[Cookie管理器] Cookie文件不存在或未指定: {self.cookie_path}")
            return False

    def get_cookies(self):
        """获取Cookie"""
        if self.cookies is None and self.cookie_path:
            self.load_cookies()
        return self.cookies

    def get_cookie_string(self):
        """获取Cookie字符串"""
        cookies = self.get_cookies()

        if cookies:
            # 检查Cookie格式：如果是数组格式，转换为字典
            if isinstance(cookies, list):
                cookie_dict = {}
                for cookie in cookies:
                    if isinstance(cookie, dict) and 'name' in cookie and 'value' in cookie:
                        cookie_dict[cookie['name']] = cookie['value']
                cookies = cookie_dict

            # 生成Cookie字符串
            if isinstance(cookies, dict):
                cookie_string = "; ".join([f"{k}={v}" for k, v in cookies.items()])
                return cookie_string
        return None

    def is_valid(self):
        """检查Cookie是否有效"""
        if self.cookies is None:
            return False

        # 检查Cookie格式：如果是数组，检查数组长度；如果是字典，检查键数量
        if isinstance(self.cookies, list):
            return len(self.cookies) > 0
        elif isinstance(self.cookies, dict):
            return len(self.cookies) > 0

        return False

    def refresh_cookies(self, new_cookies):
        """
        刷新Cookie

        Args:
            new_cookies: 新的Cookie字典
        """
        self.cookies = new_cookies
        self.save_cookies()

    def save_cookies(self):
        """保存Cookie到文件"""
        if self.cookies and self.cookie_path:
            with open(self.cookie_path, 'w', encoding='utf-8') as f:
                json.dump(self.cookies, f, ensure_ascii=False, indent=2)
            print(f"[Cookie管理器] Cookie保存成功: {os.path.basename(self.cookie_path)}")
            return True
        return False

    def get_headers(self):
        """
        获取包含Cookie的请求头

        Returns:
            dict: 请求头
        """
        cookie_string = self.get_cookie_string()
        if cookie_string:
            return {
                "Cookie": cookie_string,
                "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
            }
        return {}


def main():
    """测试Cookie管理器"""
    manager = CookieManager()

    print(f"Cookie有效: {manager.is_valid()}")
    print(f"Cookie数量: {len(manager.get_cookies()) if manager.get_cookies() else 0}")
    if manager.get_headers():
        print(f"请求头已准备")


if __name__ == "__main__":
    main()


path: core/feishu_notifier.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
飞书通知器 - 发送飞书通知
支持从配置文件读取飞书凭证
"""

import requests
import json
import os


class FeishuNotifier:
    """飞书通知器"""

    def __init__(self, app_id=None, app_secret=None, open_id=None, config_file=None):
        """
        初始化飞书通知器

        Args:
            app_id: 飞书应用ID
            app_secret: 飞书应用密钥
            open_id: 用户OpenID
            config_file: 配置文件路径，包含 feishu_config 字段
        """
        # 如果提供了配置文件，优先从中读取
        if config_file and os.path.exists(config_file):
            with open(config_file, 'r', encoding='utf-8') as f:
                config = json.load(f)
            feishu_config = config.get("feishu_config", {})
            self.app_id = app_id or feishu_config.get("app_id")
            self.app_secret = app_secret or feishu_config.get("app_secret")
            self.open_id = open_id or feishu_config.get("open_id")
        else:
            self.app_id = app_id
            self.app_secret = app_secret
            self.open_id = open_id

        self.access_token = None

    def get_tenant_access_token(self):
        """获取租户访问令牌"""
        if not self.app_id or not self.app_secret:
            print("[飞书通知器] 缺少 app_id 或 app_secret 配置，跳过通知")
            return None

        url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
        payload = {
            "app_id": self.app_id,
            "app_secret": self.app_secret
        }

        try:
            response = requests.post(url, json=payload)
            data = response.json()

            if data.get("code") == 0:
                self.access_token = data.get("tenant_access_token")
                return self.access_token
            else:
                print(f"[飞书通知器] 获取访问令牌失败: {data}")
                return None

        except Exception as e:
            print(f"[飞书通知器] 获取访问令牌异常: {e}")
            return None

    def send_notification(self, title, body, notification_type="info"):
        """
        发送飞书通知

        Args:
            title: 通知标题
            body: 通知内容
            notification_type: 通知类型（success/error/warning/info）

        Returns:
            bool: 是否发送成功
        """
        # 获取访问令牌
        if not self.access_token:
            self.get_tenant_access_token()

        if not self.access_token:
            print("[飞书通知器] 无法获取访问令牌，发送通知失败")
            return False

        # 构建消息内容
        message = self._build_message(title, body, notification_type)

        # 发送消息
        url = "https://open.feishu.cn/open-apis/message/v4/send"
        headers = {
            "Authorization": f"Bearer {self.access_token}",
            "Content-Type": "application/json"
        }

        payload = {
            "receive_id": self.open_id,
            "receive_id_type": "open_id",
            "msg_type": "interactive",
            "content": message
        }

        try:
            response = requests.post(url, headers=headers, json=payload)
            data = response.json()

            if data.get("code") == 0:
                print(f"[飞书通知器] 通知发送成功")
                return True
            else:
                print(f"[飞书通知器] 通知发送失败: {data}")
                return False

        except Exception as e:
            print(f"[飞书通知器] 发送通知异常: {e}")
            return False

    def _build_message(self, title, body, notification_type):
        """
        构建飞书消息内容

        Args:
            title: 通知标题
            body: 通知内容
            notification_type: 通知类型

        Returns:
            dict: 消息内容
        """
        # 根据类型选择图标
        icons = {
            "success": "🎉",
            "error": "❌",
            "warning": "⚠️",
            "info": "ℹ️"
        }

        icon = icons.get(notification_type, "ℹ️")

        # 构建卡片消息
        card_content = {
            "config": {
                "wide_screen_mode": True
            },
            "header": {
                "title": {
                    "content": f"{icon} {title}",
                    "tag": "plain_text"
                },
                "template": notification_type
            },
            "elements": [
                {
                    "tag": "div",
                    "text": {
                        "content": body,
                        "tag": "lark_md"
                    }
                },
                {
                    "tag": "hr"
                },
                {
                    "tag": "div",
                    "text": {
                        "content": f"发送时间: {self._get_current_time()}",
                        "tag": "plain_text"
                    }
                }
            ]
        }

        return card_content

    def _get_current_time(self):
        """获取当前时间"""
        from datetime import datetime
        return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def main():
    """测试飞书通知器"""
    notifier = FeishuNotifier()

    # 测试发送通知
    success = notifier.send_notification(
        title="测试通知",
        body="这是一条测试通知消息。",
        notification_type="success"
    )

    print(f"通知发送结果: {'成功' if success else '失败'}")


if __name__ == "__main__":
    main()


path: core/image_generator.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
配图生成器 - 生成水墨中国风配图
支持通过 AI 工具（image_gen 或即梦 API）生成配图
"""

import os
import json
import random
from datetime import datetime


class ImageGenerator:
    """配图生成器"""

    def __init__(self, output_dir=None):
        """
        初始化配图生成器

        Args:
            output_dir: 图片输出目录（默认使用临时目录）
        """
        # 设置输出目录
        if output_dir is None:
            output_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "generated-images")
        self.output_dir = output_dir
        os.makedirs(self.output_dir, exist_ok=True)

        # 水墨中国风配图主题
        self.image_themes = [
            "水墨竹子，宣纸底色，红色印章，中国风",
            "水墨山水，宣纸底色，淡雅意境，红色印章",
            "水墨梅兰竹菊，宣纸底色，古典雅致，红色印章",
            "水墨松鹤延年，宣纸底色，禅意，红色印章",
            "水墨荷花，宣纸底色，清雅，红色印章"
        ]

        # 配图风格
        self.style_keywords = [
            "水墨画风格",
            "传统中国画",
            "文人画风格",
            "国画风格",
            "古典水墨"
        ]

    def generate_prompt(self, theme=None):
        """
        生成配图提示词

        Args:
            theme: 配图主题（可选，随机选择）

        Returns:
            str: 配图提示词
        """
        if theme is None:
            theme = random.choice(self.image_themes)

        # 基础提示词
        base_prompt = f"{theme}，极简留白，高清，细节丰富"

        # 添加风格关键词
        style_keyword = random.choice(self.style_keywords)
        prompt = f"{base_prompt}，{style_keyword}"

        return prompt

    def generate_image_prompt_for_topic(self, article_topic):
        """
        根据文章话题生成配图提示词

        Args:
            article_topic: 文章话题

        Returns:
            str: 配图提示词
        """
        if any(keyword in article_topic for keyword in ["35岁", "失业", "裁员"]):
            theme = "水墨松柏，象征坚韧，宣纸底色，红色印章，中国风"
        elif any(keyword in article_topic for keyword in ["钱", "房", "车贷"]):
            theme = "水墨山水，象征生活起伏，宣纸底色，红色印章，中国风"
        elif any(keyword in article_topic for keyword in ["焦虑", "压力"]):
            theme = "水墨竹子，象征清高坚韧，宣纸底色，红色印章，中国风"
        else:
            theme = random.choice(self.image_themes)

        return self.generate_prompt(theme)

    def save_placeholder_image(self, filename=None):
        """
        创建占位图片（用于测试或AI配图服务不可用时）

        Args:
            filename: 图片文件名（可选）

        Returns:
            str: 图片文件路径
        """
        if filename is None:
            filename = f"placeholder_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"

        filepath = os.path.join(self.output_dir, filename)

        try:
            from PIL import Image
            img = Image.new('RGB', (1024, 1024), color='#F5F5DC')  # 米黄色宣纸底色
            img.save(filepath, 'PNG')
            print(f"[配图生成器] 占位图片已保存: {filepath}")
            return filepath
        except ImportError:
            # 如果没有PIL，创建一个最小的PNG文件
            with open(filepath, 'wb') as f:
                f.write(b'\x89PNG\r\n\x1a\n')
            print(f"[配图生成器] 占位图片已保存: {filepath}")
            return filepath
        except Exception as e:
            print(f"[配图生成器] 创建占位图片失败: {e}")
            return None


def main():
    """测试配图生成器"""
    generator = ImageGenerator()

    # 测试生成提示词
    prompt = generator.generate_prompt()
    print(f"配图提示词: {prompt}")

    prompt2 = generator.generate_image_prompt_for_topic("35岁被裁员")
    print(f"话题配图提示词: {prompt2}")

    # 测试保存占位图
    path = generator.save_placeholder_image()
    print(f"占位图片: {path}")


if __name__ == "__main__":
    main()


path: core/playwright_publisher.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
头条发布器 - 使用 Playwright 自动化发布文章
支持从配置文件读取所有配置信息（账号、Cookie、日志目录等）
"""

import asyncio
import json
import os
from datetime import datetime
from pathlib import Path
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError


class PlaywrightPublisher:
    """基于 Playwright 的头条发布器"""

    def __init__(self, headless=False, account_name=None, config_file=None, log_dir=None):
        """
        初始化发布器

        Args:
            headless: 是否无头模式运行（默认False，显示浏览器）
            account_name: 头条账号名称（可选，用于日志）
            config_file: 配置文件路径（可选）
            log_dir: 日志/截图保存目录（可选）
        """
        # 读取配置
        config = {}
        if config_file and os.path.exists(config_file):
            with open(config_file, 'r', encoding='utf-8') as f:
                config = json.load(f)

        self.headless = headless
        self.account_name = account_name or config.get("account_name", "未知账号")
        self.base_url = "https://mp.toutiao.com"
        self.browser = None
        self.context = None
        self.page = None

        # 日志/截图目录
        self.log_dir = log_dir or config.get("log_dir") or os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs"
        )
        os.makedirs(self.log_dir, exist_ok=True)

        # 配图目录（用于自动查找封面图）
        self.image_dir = config.get("image_dir") or os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "generated-images"
        )

        # 发布日志
        self.publish_log = []

    async def start(self):
        """启动浏览器"""
        self.playwright = await async_playwright().start()
        self.browser = await self.playwright.chromium.launch(
            headless=self.headless,
            args=[
                '--disable-blink-features=AutomationControlled',
                '--no-sandbox',
                '--disable-setuid-sandbox'
            ]
        )

        # 创建浏览器上下文
        self.context = await self.browser.new_context(
            viewport={'width': 1920, 'height': 1080},
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        )

        self.page = await self.context.new_page()
        print("[发布器] 浏览器已启动")

    async def close(self):
        """关闭浏览器"""
        if self.page:
            await self.page.close()
        if self.context:
            await self.context.close()
        if self.browser:
            await self.browser.close()
        if self.playwright:
            await self.playwright.stop()
        print("[发布器] 浏览器已关闭")

    async def load_cookies(self, cookies_file):
        """
        加载 Cookie

        Args:
            cookies_file: Cookie 文件路径（JSON 格式）
        """
        try:
            with open(cookies_file, 'r', encoding='utf-8') as f:
                cookies = json.load(f)

            # 转换 Cookie 格式（Playwright 要求 sameSite 为 Strict|Lax|None）
            playwright_cookies = []
            for cookie in cookies:
                same_site = cookie.get('sameSite')
                if same_site == 'no_restriction':
                    same_site = 'None'
                elif same_site == 'lax':
                    same_site = 'Lax'
                elif same_site == 'strict':
                    same_site = 'Strict'
                elif same_site is None:
                    same_site = None

                playwright_cookie = {
                    'name': cookie.get('name'),
                    'value': cookie.get('value'),
                    'domain': cookie.get('domain'),
                    'path': cookie.get('path', '/'),
                    'httpOnly': cookie.get('httpOnly', False),
                    'secure': cookie.get('secure', False),
                }

                if same_site:
                    playwright_cookie['sameSite'] = same_site

                if cookie.get('expirationDate'):
                    playwright_cookie['expires'] = int(cookie['expirationDate'])

                playwright_cookies.append(playwright_cookie)

            await self.context.add_cookies(playwright_cookies)
            print(f"[发布器] Cookie 已加载: {len(playwright_cookies)} 个")
            return True
        except Exception as e:
            print(f"[发布器] 加载 Cookie 失败: {e}")
            import traceback
            traceback.print_exc()
            return False

    async def navigate_to_publish(self):
        """导航到发布页面"""
        try:
            print("[发布器] 导航到发布页面...")

            await self.page.goto(f"{self.base_url}/", wait_until="domcontentloaded")
            await asyncio.sleep(2)

            current_url = self.page.url
            if 'login' in current_url:
                print("[发布器] 需要登录，Cookie 可能已过期")
                return False

            title = await self.page.title()
            print(f"[发布器] 主页标题: {title}")

            publish_urls = [
                f"{self.base_url}/profile_v4/graphic/publish",
                f"{self.base_url}/profile_v4/publish",
                f"{self.base_url}/profile_v4/articles/create",
                f"{self.base_url}/profile_v4/articles/create/post"
            ]

            success = False
            for url in publish_urls:
                try:
                    print(f"[发布器] 尝试 URL: {url}")
                    response = await self.page.goto(url, wait_until="networkidle", timeout=60000)

                    if response and response.status >= 400:
                        print(f"[发布器] 页面返回错误状态码: {response.status}")
                        if response.status == 502:
                            continue
                        elif response.status == 401 or response.status == 403:
                            return False
                    else:
                        print("[发布器] 等待页面加载...")
                        await asyncio.sleep(10)

                        prose_mirrors = await self.page.query_selector_all('.ProseMirror')
                        print(f"[发布器] 找到 {len(prose_mirrors)} 个 ProseMirror 元素")

                        content_editors = []
                        for el in prose_mirrors:
                            contenteditable = await el.get_attribute('contenteditable')
                            is_visible = await el.is_visible()
                            if contenteditable == 'true' and is_visible:
                                content_editors.append(el)

                        if len(content_editors) > 0:
                            print(f"[发布器] 找到 {len(content_editors)} 个可用正文编辑器")
                            success = True
                            break
                        else:
                            print(f"[发布器] 未找到可用的正文编辑器")
                except Exception as e:
                    print(f"[发布器] URL {url} 失败: {e}")
                    continue

            if not success:
                print("[发布器] 所有发布页面 URL 都尝试失败")
                return False

            current_url = self.page.url
            if 'login' in current_url:
                print("[发布器] 需要登录，请手动登录")
                return False

            print("[发布器] 已进入发布页面")
            return True

        except PlaywrightTimeoutError:
            print("[发布器] 导航超时")
            return False
        except Exception as e:
            print(f"[发布器] 导航异常: {e}")
            import traceback
            traceback.print_exc()
            return False

    async def fill_title(self, title):
        """
        填充标题

        Args:
            title: 文章标题

        Returns:
            bool: 是否成功
        """
        try:
            print(f"[发布器] 填充标题: {title}")
            await asyncio.sleep(3)

            title_selectors = [
                'input[placeholder*="标题"]',
                'input[placeholder*="文章"]',
                'input[placeholder*="请输入标题"]',
                '.title-input',
                'input[type="text"]',
                'textarea[placeholder*="标题"]',
                '[placeholder*="标题"]'
            ]

            title_found = False
            for selector in title_selectors:
                try:
                    elements = await self.page.query_selector_all(selector)
                    for el in elements:
                        is_visible = await el.is_visible()
                        if is_visible:
                            placeholder = await el.get_attribute('placeholder') or ''
                            print(f"  找到标题输入框: placeholder={placeholder}")
                            await el.click()
                            await self.page.keyboard.press('Control+a')
                            await self.page.keyboard.press('Backspace')
                            await self.page.type(selector, title, delay=50)
                            title_found = True
                            await asyncio.sleep(1)
                            break
                    if title_found:
                        break
                except:
                    continue

            if not title_found:
                print("[发布器] 尝试查找所有可见输入框...")
                inputs = await self.page.query_selector_all('input, textarea, [contenteditable="true"]')
                for i, input_el in enumerate(inputs):
                    is_visible = await input_el.is_visible()
                    if not is_visible:
                        continue

                    placeholder = await input_el.get_attribute('placeholder') or ''
                    class_name = await input_el.get_attribute('class') or ''
                    tag_name = await input_el.evaluate('el => el.tagName')

                    if 'ProseMirror' not in class_name and (not placeholder or '标题' in placeholder or 'title' in placeholder.lower()):
                        await input_el.click()
                        await self.page.keyboard.press('Control+a')
                        await self.page.keyboard.press('Backspace')
                        await input_el.type(title, delay=50)
                        title_found = True
                        await asyncio.sleep(1)
                        break

            if not title_found:
                raise Exception("未找到标题输入框")

            print("[发布器] 标题填充完成")
            return True

        except Exception as e:
            print(f"[发布器] 填充标题失败: {e}")
            import traceback
            traceback.print_exc()
            return False

    async def fill_content(self, content):
        """
        填充正文内容

        Args:
            content: 文章内容

        Returns:
            bool: 是否成功
        """
        try:
            print("[发布器] 填充正文...")
            await asyncio.sleep(3)

            # 优先用 execCommand('insertText') 方式
            import json as _json
            escaped_content = _json.dumps(content)

            result = await self.page.evaluate(f'''
                () => {{
                    var editors = document.querySelectorAll('[contenteditable=true]');
                    if (!editors.length) return 'NO_EDITOR';

                    var el = null;
                    for (var e of editors) {{
                        if (e.className && e.className.includes('ProseMirror')) {{
                            el = e; break;
                        }}
                    }}
                    if (!el) el = editors[0];

                    el.focus();
                    document.execCommand('selectAll', false, null);
                    var ok = document.execCommand('insertText', false, {escaped_content});
                    return 'ok:' + ok + ':len:' + el.textContent.length;
                }}
            ''')
            print(f"[发布器] execCommand 结果: {result}")

            if result and result.startswith('ok:') and 'NO_EDITOR' not in result:
                try:
                    length = int(result.split('len:')[-1])
                    if length > 10:
                        print(f"[发布器] 正文填充成功（字符数: {length}）")
                        await asyncio.sleep(1)
                        return True
                except:
                    pass

            # 备用方案
            print("[发布器] 尝试备用方案：键盘输入...")
            content_selectors = [
                '.ProseMirror[contenteditable="true"]',
                '[contenteditable="true"].ProseMirror',
                '[contenteditable="true"]',
            ]

            content_found = False
            for selector in content_selectors:
                try:
                    elements = await self.page.query_selector_all(selector)
                    for el in elements:
                        is_visible = await el.is_visible()
                        if is_visible:
                            class_name = await el.get_attribute('class') or ''
                            if 'ProseMirror' in class_name:
                                await el.click()
                                await asyncio.sleep(0.5)
                                await self.page.keyboard.press('Control+a')
                                await asyncio.sleep(0.3)
                                await self.page.keyboard.press('Backspace')
                                await asyncio.sleep(0.3)
                                paragraphs = content.split('\n')
                                for i, para in enumerate(paragraphs):
                                    if para:
                                        await self.page.keyboard.type(para, delay=5)
                                    if i < len(paragraphs) - 1:
                                        await self.page.keyboard.press('Enter')
                                content_found = True
                                await asyncio.sleep(1)
                                break
                    if content_found:
                        break
                except:
                    continue

            if not content_found:
                raise Exception("未找到正文编辑器")

            print("[发布器] 正文填充完成")
            return True

        except Exception as e:
            print(f"[发布器] 填充正文失败: {e}")
            import traceback
            traceback.print_exc()
            return False

    async def upload_images(self, image_paths):
        """
        上传配图（在正文编辑器中插入图片）

        Args:
            image_paths: 图片路径列表

        Returns:
            bool: 是否成功
        """
        try:
            if not image_paths:
                print("[发布器] 无需上传配图")
                return True

            print(f"[发布器] 上传配图: {len(image_paths)} 张")
            await asyncio.sleep(2)

            editor = await self.page.query_selector('.ProseMirror[contenteditable="true"]')
            if not editor:
                print("[发布器] 未找到正文编辑器，无法插入图片")
                return False

            for i, image_path in enumerate(image_paths):
                if not Path(image_path).exists():
                    print(f"[发布器] 图片不存在: {image_path}")
                    continue

                print(f"[发布器] 插入第 {i+1} 张图片: {Path(image_path).name}")

                await editor.click()
                await asyncio.sleep(0.5)
                await self.page.keyboard.press('End')
                await asyncio.sleep(0.3)
                await self.page.keyboard.press('Enter')
                await asyncio.sleep(0.3)

                try:
                    image_buttons = await self.page.query_selector_all('button[title*="图片"], button[title*="image"], button[aria-label*="图片"], button[aria-label*="image"]')
                    if image_buttons:
                        for btn in image_buttons:
                            try:
                                is_visible = await btn.is_visible()
                                if is_visible:
                                    await btn.click()
                                    await asyncio.sleep(2)
                                    file_inputs = await self.page.query_selector_all('input[type="file"]')
                                    if file_inputs:
                                        await self.page.set_input_files('input[type="file"]', image_path)
                                        await asyncio.sleep(3)
                                        print(f"[发布器] 图片上传成功")
                                        break
                            except:
                                continue

                    if not await self._try_paste_image(editor, image_path):
                        print(f"[发布器] 插入图片失败: {image_path}")
                        continue

                except Exception as e:
                    print(f"[发布器] 插入图片异常: {e}")

            print("[发布器] 配图上传完成")
            return True

        except Exception as e:
            print(f"[发布器] 上传配图失败: {e}")
            import traceback
            traceback.print_exc()
            return False

    async def _try_paste_image(self, editor, image_path):
        """尝试通过粘贴的方式插入图片"""
        try:
            import base64
            with open(image_path, 'rb') as f:
                image_data = base64.b64encode(f.read()).decode()

            await editor.evaluate(f'''
                (editor, imgData) => {{
                    const img = document.createElement('img');
                    img.src = 'data:image/png;base64,' + imgData;
                    img.style.maxWidth = '100%';
                    editor.appendChild(img);
                }}
            ''', image_data)

            await asyncio.sleep(1)
            return True
        except Exception as e:
            print(f"[发布器] 粘贴图片失败: {e}")
            return False

    async def set_options(self):
        """
        设置发布选项（取消勾选"发布得更多收益"、勾选"头条首发"、"个人观点"等）

        Returns:
            bool: 是否成功
        """
        try:
            print("[发布器] 设置发布选项...")
            await asyncio.sleep(3)

            await self.page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)')
            await asyncio.sleep(2)

            checkboxes = await self.page.query_selector_all('input[type="checkbox"]')
            print(f"[发布器] 找到 {len(checkboxes)} 个复选框")

            for checkbox in checkboxes:
                try:
                    parent = await checkbox.evaluate('el => el.parentElement?.innerText || ""')

                    if '发布得更多收益' in parent:
                        is_checked = await checkbox.is_checked()
                        if is_checked:
                            await checkbox.click()
                            await asyncio.sleep(0.5)
                            print("[发布器] 已取消勾选: 发布得更多收益")

                    if '同时发布微头条' in parent or '发布微头条' in parent:
                        is_checked = await checkbox.is_checked()
                        if is_checked:
                            await checkbox.click()
                            await asyncio.sleep(0.5)
                            print("[发布器] 已取消勾选: 同时发布微头条")

                except Exception as e:
                    continue

            # 勾选"头条首发"
            print("[发布器] 注意: '头条首发'选项经深度调研确认不可用(头条业务逻辑限制),跳过此项")

            # 勾选"个人观点，仅供参考"
            for checkbox in checkboxes:
                try:
                    parent = await checkbox.evaluate('el => el.parentElement?.innerText || ""')
                    if '个人观点' in parent:
                        is_checked = await checkbox.is_checked()
                        if not is_checked:
                            parent_el = await checkbox.evaluate_handle('el => el.parentElement')
                            try:
                                await parent_el.click()
                                await asyncio.sleep(1)
                                is_checked = await checkbox.is_checked()
                                if is_checked:
                                    print("[发布器] 已勾选: 个人观点，仅供参考")
                                else:
                                    print("[发布器] 勾选个人观点失败")
                            except:
                                print("[发布器] 勾选个人观点: 点击失败")
                        else:
                            print("[发布器] 个人观点已勾选，无需操作")
                except Exception as e:
                    continue

            print("[发布器] 发布选项设置完成")
            return True

        except Exception as e:
            print(f"[发布器] 设置选项失败: {e}")
            import traceback
            traceback.print_exc()
            return False

    async def upload_cover_image(self, image_path, max_retries=3):
        """
        上传封面图片

        Args:
            image_path: 图片文件路径
            max_retries: 最大重试次数

        Returns:
            bool: 是否成功
        """
        for attempt in range(max_retries):
            try:
                if attempt > 0:
                    print(f"[发布器] 尝试 {attempt + 1}/{max_retries}...")

                print(f"[发布器] 开始上传封面: {image_path}")

                if not Path(image_path).exists():
                    print(f"[发布器] 图片不存在: {image_path}")
                    return False

                # Step 1: 点击"单图"选项
                print("[发布器] 步骤1: 点击'单图'选项...")
                single_image_clicked = False
                for selector in ['label', 'button', '.byte-radio-button']:
                    try:
                        element = self.page.locator(selector).filter(has_text='单图').first
                        if await element.count() > 0:
                            await element.click(timeout=5000)
                            single_image_clicked = True
                            break
                    except:
                        continue

                if not single_image_clicked:
                    print("[发布器] 未找到'单图'选项")
                    if attempt < max_retries - 1:
                        await asyncio.sleep(2)
                        continue
                    return False

                await asyncio.sleep(5)

                # Step 2: 检查上传按钮
                cover_add_exists = await self.page.evaluate('''
                    () => {
                        const el = document.querySelector('.article-cover-add');
                        if (!el) return false;
                        return el.offsetParent !== null;
                    }
                ''')

                if not cover_add_exists:
                    print("[发布器] .article-cover-add 不存在或不可见")
                    if attempt < max_retries - 1:
                        await asyncio.sleep(2)
                        continue
                    return False

                # Step 3: 点击上传按钮
                try:
                    cover_add = self.page.locator('.article-cover-add')
                    await cover_add.click(timeout=5000)
                    await asyncio.sleep(3)
                except Exception as e:
                    print(f"[发布器] 点击上传按钮失败: {e}")
                    if attempt < max_retries - 1:
                        await asyncio.sleep(2)
                        continue
                    return False

                # Step 4: 等待文件选择器
                file_input_selector = None
                try:
                    await asyncio.sleep(3)
                    await self.page.wait_for_selector('#upload-drag-input', state='attached', timeout=10000)
                    file_input_selector = '#upload-drag-input'
                except:
                    file_inputs = await self.page.evaluate('''
                        () => {
                            const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
                            return inputs.map(i => ({
                                id: i.id,
                                visible: i.offsetParent !== null
                            }));
                        }
                    ''')
                    if file_inputs:
                        visible_inputs = [f for f in file_inputs if f['visible']]
                        if visible_inputs:
                            file_input_selector = f"#{visible_inputs[0]['id']}" if visible_inputs[0]['id'] else 'input[type="file"]'
                        else:
                            file_input_selector = 'input[type="file"]'
                    else:
                        if attempt < max_retries - 1:
                            await asyncio.sleep(2)
                            continue
                        return False

                # Step 5: 上传图片
                try:
                    file_input = self.page.locator(file_input_selector)
                    await file_input.set_input_files(image_path)
                    await asyncio.sleep(5)
                except Exception as e:
                    print(f"[发布器] 上传文件失败: {e}")
                    if attempt < max_retries - 1:
                        await asyncio.sleep(2)
                        continue
                    return False

                # Step 6: 点击确定按钮
                try:
                    confirm_result = await self.page.evaluate('''
                        () => {
                            const btns = Array.from(document.querySelectorAll('button'));
                            const confirmBtnTexts = ['确定', '确认', '上传', '确认发布', '完成'];
                            
                            const uploadPanel = document.querySelector('.upload-image-panel, .byte-drawer-wrapper');
                            if (uploadPanel) {
                                const panelBtns = uploadPanel.querySelectorAll('button');
                                for (let btn of panelBtns) {
                                    if (btn.offsetParent && !btn.disabled) {
                                        const text = btn.innerText.trim();
                                        if (confirmBtnTexts.includes(text)) {
                                            btn.click();
                                            return { clicked: true, text: text };
                                        }
                                    }
                                }
                            }
                            
                            for (let btn of btns) {
                                if (btn.offsetParent && !btn.disabled) {
                                    const text = btn.innerText.trim();
                                    if (confirmBtnTexts.includes(text)) {
                                        btn.click();
                                        return { clicked: true, text: text };
                                    }
                                }
                            }
                            
                            return { clicked: false };
                        }
                    ''')

                    if confirm_result['clicked']:
                        print(f"[发布器] 已点击确定按钮: {confirm_result['text']}")
                        await asyncio.sleep(8)
                    else:
                        await asyncio.sleep(8)
                except Exception as e:
                    await asyncio.sleep(8)

                # Step 7: 检查封面状态
                cover_status = await self.page.evaluate('''
                    () => {
                        const result = { uploaded: false, count: 0, images: [] };
                        const selectors = [
                            '.article-cover-add img',
                            '.article-cover-images img',
                            '[class*="cover"] img',
                            '.cover-preview img',
                            '.cover-img img'
                        ];
                        
                        for (let sel of selectors) {
                            const images = document.querySelectorAll(sel);
                            for (let img of images) {
                                const rect = img.getBoundingClientRect();
                                if (rect.width > 50 && rect.height > 50) {
                                    result.uploaded = true;
                                    result.count++;
                                    result.images.push({
                                        src: img.src.substring(0, 100),
                                        width: rect.width,
                                        height: rect.height,
                                        naturalWidth: img.naturalWidth,
                                        naturalHeight: img.naturalHeight,
                                        complete: img.complete
                                    });
                                }
                            }
                        }
                        
                        return result;
                    }
                ''')

                # 关闭上传面板
                try:
                    await self.page.keyboard.press('Escape')
                    await asyncio.sleep(2)
                except:
                    try:
                        await self.page.evaluate('''
                            () => {
                                const drawer = document.querySelector('.byte-drawer-wrapper');
                                if (drawer) drawer.style.display = 'none';
                                const uploadPanel = document.querySelector('.upload-image-panel');
                                if (uploadPanel) uploadPanel.style.display = 'none';
                                return 'done';
                            }
                        ''')
                    except:
                        pass

                if cover_status['uploaded'] and cover_status['count'] > 0:
                    print(f"[发布器] 封面上传成功! 数量: {cover_status['count']}")
                    return True
                else:
                    print("[发布器] 封面未上传成功")
                    if attempt < max_retries - 1:
                        await asyncio.sleep(2)
                        continue
                    return False

            except Exception as e:
                print(f"[发布器] 封面上传异常: {e}")
                try:
                    await self.page.keyboard.press('Escape')
                except:
                    pass
                if attempt < max_retries - 1:
                    await asyncio.sleep(2)
                    continue
                return False

        print(f"[发布器] 封面上传失败，已重试{max_retries}次")
        return False

    async def publish(self, cover_image_path=None):
        """
        点击"预览并发布"按钮并完成发布流程

        Args:
            cover_image_path: 封面图片路径（可选，不传则自动从 image_dir 查找）

        Returns:
            bool: 是否成功
        """
        try:
            print("[发布器] 查找发布按钮...")

            # 关闭所有可能遮挡的抽屉/面板
            print("[发布器] 关闭所有遮挡的抽屉/面板...")
            try:
                await self.page.evaluate('''
                    () => {
                        var wrappers = document.querySelectorAll('.ai-assistant-drawer, .byte-drawer-wrapper, .upload-image-panel');
                        wrappers.forEach(w => w.style.display = 'none');
                        return 'hidden';
                    }
                ''')
            except Exception as e:
                print(f"[发布器] 关闭面板失败(可忽略): {e}")

            await asyncio.sleep(1)

            # 上传封面图片
            print("[发布器] 上传封面图片...")
            cover_uploaded = False

            # 确定封面图片路径
            if not cover_image_path:
                # 自动从 image_dir 查找
                if os.path.exists(self.image_dir):
                    img_files = [f for f in os.listdir(self.image_dir) if f.endswith(('.png', '.jpg', '.jpeg'))]
                    if img_files:
                        cover_image_path = os.path.join(self.image_dir, img_files[0])

            if cover_image_path:
                cover_uploaded = await self.upload_cover_image(cover_image_path)
                if cover_uploaded:
                    print("[发布器] 封面上传成功")
                else:
                    print("[发布器] 封面上传失败，将尝试继续发布（可能有风险）")
            else:
                print("[发布器] 没有找到可用的封面图片,跳过上传")

            # 确保面板已关闭
            await self.page.evaluate('''
                () => {
                    const uploadPanel = document.querySelector('.upload-image-panel');
                    if (uploadPanel) uploadPanel.style.display = 'none';
                    const drawerWrapper = document.querySelector('.byte-drawer-wrapper');
                    if (drawerWrapper) drawerWrapper.style.display = 'none';
                    return 'hidden';
                }
            ''')
            await asyncio.sleep(1)

            # 点击"预览并发布"
            print("[发布器] 尝试点击发布按钮...")

            click_result = None
            try:
                span_locator = self.page.locator('.publish-btn.publish-btn-last span')
                if await span_locator.count() > 0:
                    await span_locator.click(timeout=5000)
                    print("[发布器] 已点击: .publish-btn.publish-btn-last span")
                    click_result = 'SPAN:预览并发布'
                else:
                    raise Exception("未找到 span")
            except Exception as e:
                try:
                    btn_locator = self.page.locator('.publish-btn.publish-btn-last')
                    await btn_locator.focus()
                    await self.page.keyboard.press('Enter')
                    click_result = 'FOCUS_ENTER:预览并发布'
                except Exception as e2:
                    try:
                        click_result = await self.page.evaluate('''
                            () => {
                                var btn = document.querySelector('.publish-btn.publish-btn-last');
                                var span = btn?.querySelector('span');
                                if (span) {
                                    span.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
                                    span.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
                                    span.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
                                    return 'SPAN_JS:预览并发布';
                                }
                                return 'NOT_FOUND';
                            }
                        ''')
                    except:
                        click_result = 'ALL_FAILED'

            if click_result and click_result.startswith('NOT_FOUND'):
                return False

            # 等待预览面板弹出
            print("[发布器] 等待预览面板弹出...")
            await asyncio.sleep(10)
            clicked_text = None

            for attempt in range(5):
                dialog_check = await self.page.evaluate('''
                    () => {
                        var url = location.href;
                        var text = document.body.innerText;

                        if (!url.includes('graphic/publish')) {
                            return 'JUMPED:' + url;
                        }

                        if (text.includes('发布成功') || text.includes('已发布') || text.includes('提交成功')) return 'SUCCESS';
                        if (text.includes('发布失败') || text.includes('提交失败')) return 'FAILED';

                        var dialogSelectors = [
                            '.drawer', '.modal', '.dialog', '.preview-dialog',
                            '[class*="drawer"]', '[class*="modal"]', '[class*="dialog"]', '[class*="preview"]'
                        ];

                        var visibleDialog = null;
                        for (var sel of dialogSelectors) {
                            var dialogs = Array.from(document.querySelectorAll(sel));
                            for (var i = 0; i < dialogs.length; i++) {
                                var d = dialogs[i];
                                var style = window.getComputedStyle(d);
                                if (style.display !== 'none' && style.opacity !== '0' && style.visibility !== 'hidden') {
                                    visibleDialog = { selector: sel, element: d };
                                    break;
                                }
                            }
                            if (visibleDialog) break;
                        }

                        var btns = Array.from(document.querySelectorAll('button'));
                        var confirmBtnTexts = ['确认发布', '确定发布', '发布', '确定', '提交', '立即发布', '马上发布', '预览发布'];
                        for (var ct of confirmBtnTexts) {
                            var found = btns.find(b => b.innerText.trim() === ct && !b.disabled && b.offsetParent);
                            if (found) {
                                found.click();
                                return 'CLICKED:' + ct;
                            }
                        }

                        var allBtnTexts = btns.filter(b => b.offsetParent).map(b => b.innerText.trim()).filter(t => t);
                        return 'WAITING|dialog:' + (visibleDialog ? visibleDialog.selector : 'none') + '|btns:' + allBtnTexts.join('|');
                    }
                ''')
                print(f"[发布器] 第{attempt+1}次检测结果: {dialog_check}")

                if not dialog_check:
                    await asyncio.sleep(3)
                    continue

                if dialog_check.startswith('JUMPED') or dialog_check == 'SUCCESS':
                    print("[发布器] 发布完成！")
                    return True

                if dialog_check.startswith('CLICKED:'):
                    clicked_text = dialog_check.split(':', 1)[1]
                    print(f"[发布器] 已点击确认按钮：[{clicked_text}]")
                    break

                if dialog_check == 'FAILED':
                    return False

                # 保存调试截图
                try:
                    await self.page.screenshot(path=os.path.join(self.log_dir, f"preview_waiting_attempt_{attempt+1}.png"))
                except:
                    pass
                await asyncio.sleep(3)

            # 等待最终结果
            if clicked_text:
                await asyncio.sleep(5)
                try:
                    await self.page.screenshot(path=os.path.join(self.log_dir, "after_confirm_publish.png"))
                except:
                    pass

                print("[发布器] 开始验证最终发布结果...")
                final_url = self.page.url
                print(f"[发布器] 最终URL: {final_url}")

                success_check = await self.page.evaluate('''
                    () => {
                        var url = location.href;
                        if (!url.includes('graphic/publish')) {
                            return { type: 'url', success: true, detail: 'URL已跳转: ' + url };
                        }

                        var bodyText = document.body.innerText;
                        if (bodyText.includes('发布成功') || bodyText.includes('已发布') || bodyText.includes('提交成功')) {
                            return { type: 'text', success: true, detail: '检测到成功提示文本' };
                        }

                        if (bodyText.includes('发布失败') || bodyText.includes('提交失败') || bodyText.includes('审核失败')) {
                            return { type: 'error', success: false, detail: '检测到失败提示文本' };
                        }

                        return { type: 'unknown', success: false, detail: '无法确定发布状态' };
                    }
                ''')

                print(f"[发布器] 最终状态: {success_check}")

                if success_check.get('success'):
                    print(f"[发布器] 发布成功! {success_check.get('detail')}")
                    return True
                else:
                    # 已点击确认按钮，但无法确认最终结果
                    return True

            # 兜底截图
            try:
                await self.page.screenshot(path=os.path.join(self.log_dir, "publish_unknown.png"))
            except:
                pass

            return False

        except Exception as e:
            print(f"[发布器] 发布失败: {e}")
            import traceback
            traceback.print_exc()
            return False

    async def check_publish_result(self):
        """检查发布结果"""
        try:
            print("[发布器] 开始检查发布结果...")
            await asyncio.sleep(5)

            current_url = self.page.url
            print(f"[发布器] 当前 URL: {current_url}")

            if 'success' in current_url or 'article' in current_url:
                return {"success": True, "url": current_url}

            try:
                success_text = await self.page.evaluate('''
                    () => {
                        const elements = Array.from(document.querySelectorAll('*'));
                        for (let el of elements) {
                            if (el.innerText && (el.innerText.includes('发布成功') || el.innerText.includes('提交成功') || el.innerText.includes('已发布'))) {
                                return el.innerText;
                            }
                        }
                        return '';
                    }
                ''')

                if success_text:
                    return {"success": True, "url": current_url}
            except:
                pass

            # 截图供人工判断
            screenshot_path = os.path.join(self.log_dir, "publish_result.png")
            try:
                await self.page.screenshot(path=screenshot_path)
            except:
                pass

            return {
                "success": False,
                "error": "发布状态未知，请查看截图",
                "screenshot": screenshot_path
            }

        except Exception as e:
            return {"success": False, "error": str(e)}

    async def publish_article(self, title, content, image_paths=None, cover_image_path=None, options=None):
        """
        完整的发布流程

        Args:
            title: 文章标题
            content: 文章内容
            image_paths: 配图路径列表（可选）
            cover_image_path: 封面图片路径（可选）
            options: 其他选项（可选）

        Returns:
            dict: 发布结果
        """
        result = {
            "success": False,
            "error": None,
            "url": None,
            "logs": []
        }

        try:
            if not await self.navigate_to_publish():
                result["error"] = "导航到发布页面失败"
                return result
            result["logs"].append("已导航到发布页面")

            if options or True:
                await self.set_options()
                result["logs"].append("已设置发布选项")

            if not await self.fill_title(title):
                result["error"] = "填充标题失败"
                return result
            result["logs"].append("标题填充完成")

            if not await self.fill_content(content):
                result["error"] = "填充正文失败"
                return result
            result["logs"].append("正文填充完成")

            if image_paths:
                await self.upload_images(image_paths)
                result["logs"].append(f"配图上传完成: {len(image_paths)} 张")

            if not await self.publish(cover_image_path=cover_image_path):
                result["error"] = "点击发布按钮失败"
                return result
            result["logs"].append("已点击发布按钮")

            publish_result = await self.check_publish_result()
            if publish_result["success"]:
                result["success"] = True
                result["url"] = publish_result.get("url", "")
                result["logs"].append("发布成功")

                log_entry = {
                    "title": title,
                    "url": result["url"],
                    "status": "success",
                    "published_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                }
                self.publish_log.append(log_entry)
            else:
                result["error"] = publish_result.get("error", "发布失败")

        except Exception as e:
            result["error"] = f"发布流程异常: {e}"

        return result

    def get_publish_log(self):
        """获取发布日志"""
        return self.publish_log

    def save_publish_log(self, log_path):
        """保存发布日志到文件"""
        with open(log_path, 'w', encoding='utf-8') as f:
            json.dump(self.publish_log, f, ensure_ascii=False, indent=2)
        print(f"[发布器] 发布日志已保存到: {log_path}")


async def main():
    """测试发布器"""
    import os

    # 查找配置文件
    config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
    config_file = os.path.join(config_dir, "account_config.json")

    if not os.path.exists(config_file):
        print("[发布器] 未找到配置文件 config/account_config.json")
        return

    with open(config_file, 'r', encoding='utf-8') as f:
        config = json.load(f)

    publisher = PlaywrightPublisher(
        headless=False,
        account_name=config.get("account_name"),
        config_file=config_file
    )

    await publisher.start()

    cookies_file = config.get("cookie_file")
    if cookies_file:
        await publisher.load_cookies(cookies_file)

    result = await publisher.publish_article(
        title="测试文章标题",
        content="这是一篇使用Playwright自动发布的测试文章。",
        image_paths=None
    )

    print(f"\n发布结果: {json.dumps(result, ensure_ascii=False, indent=2)}")

    await publisher.close()


if __name__ == "__main__":
    asyncio.run(main())


path: core/publisher.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
头条发布器 - 发布文章到头条（API方式）
支持从配置文件读取账号信息
"""

import requests
import json
import os
from datetime import datetime
from .cookie_manager import CookieManager
from .feishu_notifier import FeishuNotifier


class Publisher:
    """头条发布器"""

    def __init__(self, account_id=None, account_name=None, config_file=None):
        """
        初始化发布器

        Args:
            account_id: 头条账号ID（优先使用直接传入值）
            account_name: 头条账号名称
            config_file: 配置文件路径
        """
        # 从配置文件读取
        config = {}
        if config_file and os.path.exists(config_file):
            with open(config_file, 'r', encoding='utf-8') as f:
                config = json.load(f)

        self.account_id = account_id or config.get("account_id")
        self.account_name = account_name or config.get("account_name")

        # 初始化Cookie管理器
        cookie_path = config.get("cookie_file")
        self.cookie_manager = CookieManager(cookie_path=cookie_path)

        # 初始化飞书通知器
        self.feishu_notifier = FeishuNotifier(config_file=config_file)

        # 头条API配置
        self.base_url = "https://mp.toutiao.com"

        # 发布日志
        self.publish_log = []

    def get_topic_invitations(self):
        """
        获取话题邀请列表

        Returns:
            list: 话题邀请列表（按围观人数排序）
        """
        url = f"{self.base_url}/api/topic/invitation/list"
        headers = self.cookie_manager.get_headers()

        try:
            response = requests.get(url, headers=headers)
            data = response.json()

            if data.get("code") == 0:
                topics = data.get("data", {}).get("topics", [])

                # 按围观人数排序
                sorted_topics = sorted(
                    topics,
                    key=lambda x: x.get("view_count", 0),
                    reverse=True
                )

                return sorted_topics
            else:
                print(f"[发布器] 获取话题邀请失败: {data}")
                return []

        except Exception as e:
            print(f"[发布器] 获取话题邀请异常: {e}")
            return []

    def get_hot_topic(self):
        """
        获取最热门的话题

        Returns:
            dict: 最热门的话题
        """
        topics = self.get_topic_invitations()

        if topics:
            return topics[0]
        else:
            print("[发布器] 没有获取到话题邀请")
            return None

    def publish_article(self, article, images=None):
        """
        发布文章

        Args:
            article: 文章数据（包含标题、内容等）
            images: 配图列表

        Returns:
            dict: 发布结果
        """
        print(f"[发布器] 开始发布文章: {article['title']}")

        # 1. 准备发布数据
        publish_data = self._prepare_publish_data(article, images)

        # 2. 发布文章
        url = f"{self.base_url}/api/article/publish"
        headers = self.cookie_manager.get_headers()

        try:
            response = requests.post(url, json=publish_data, headers=headers)
            data = response.json()

            if data.get("code") == 0:
                article_url = data.get("data", {}).get("article_url", "")
                print(f"[发布器] 文章发布成功: {article_url}")

                # 记录发布日志
                log_entry = {
                    "title": article['title'],
                    "topic": article.get('topic', ''),
                    "url": article_url,
                    "status": "success",
                    "published_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                }
                self.publish_log.append(log_entry)

                # 发送飞书通知
                self._send_success_notification(article, article_url)

                return {
                    "success": True,
                    "url": article_url,
                    "data": log_entry
                }
            else:
                error_msg = data.get("message", "未知错误")
                print(f"[发布器] 文章发布失败: {error_msg}")
                self._send_error_notification(article, error_msg)
                return {
                    "success": False,
                    "error": error_msg
                }

        except Exception as e:
            print(f"[发布器] 发布文章异常: {e}")
            self._send_error_notification(article, str(e))
            return {
                "success": False,
                "error": str(e)
            }

    def _prepare_publish_data(self, article, images):
        """准备发布数据"""
        data = {
            "title": article['title'],
            "content": article['content'],
            "article_type": "article",
            "abstract": article['content'][:100],
            "category": "情感",
            "tags": ["中年", "人生", "成长"],
            "is_original": True,
            "disclaimer": True,
            "account_id": self.account_id
        }

        if images:
            data["cover_images"] = [img.get('url') if isinstance(img, dict) else img for img in images]

        return data

    def _send_success_notification(self, article, article_url):
        """发送成功通知"""
        title = "文章发布成功"
        body = f"**标题**: {article['title']}\n"
        body += f"**话题**: {article.get('topic', '无')}\n"
        body += f"**链接**: {article_url}\n"
        body += f"**账号**: {self.account_name}"
        self.feishu_notifier.send_notification(title, body, "success")

    def _send_error_notification(self, article, error_msg):
        """发送失败通知"""
        title = "文章发布失败"
        body = f"**标题**: {article['title']}\n"
        body += f"**话题**: {article.get('topic', '无')}\n"
        body += f"**错误**: {error_msg}\n"
        body += f"**账号**: {self.account_name}"
        self.feishu_notifier.send_notification(title, body, "error")

    def get_publish_log(self):
        """获取发布日志"""
        return self.publish_log

    def save_publish_log(self, log_path):
        """保存发布日志到文件"""
        with open(log_path, 'w', encoding='utf-8') as f:
            json.dump(self.publish_log, f, ensure_ascii=False, indent=2)
        print(f"[发布器] 发布日志已保存到: {log_path}")


def main():
    """测试发布器"""
    # 查找配置文件
    config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
    config_file = os.path.join(config_dir, "account_config.json")

    if os.path.exists(config_file):
        publisher = Publisher(config_file=config_file)
    else:
        print("[发布器] 未找到配置文件，请先配置 config/account_config.json")
        return

    # 获取热门话题
    hot_topic = publisher.get_hot_topic()
    print(f"最热门的话题: {hot_topic}")

    # 测试发布文章
    if hot_topic:
        article = {
            "title": f"关于{hot_topic['title']}的思考",
            "content": f"今天刷到一个话题：{hot_topic['title']}\n\n这篇文章的内容...",
            "topic": hot_topic['title']
        }
        result = publisher.publish_article(article)
        print(f"发布结果: {result}")


if __name__ == "__main__":
    main()


path: requirements.txt
content: playwright>=1.40.0
requests>=2.28.0
schedule>=1.2.0
Pillow>=9.0.0


path: scripts/publish_article.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
头条文章发布脚本 - Playwright完整版
整合文章生成、配图生成和浏览器自动化发布
"""

import sys
import os
import time
import json
import argparse
from pathlib import Path

# 添加项目根目录到Python路径
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT_DIR)

from core.article_generator import ArticleGenerator
from core.image_generator import ImageGenerator
from core.feishu_notifier import FeishuNotifier
from core.cookie_manager import CookieManager


def log(message):
    """日志输出"""
    timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
    print(f"[{timestamp}] {message}")


def load_config():
    """加载配置文件"""
    config_path = os.path.join(ROOT_DIR, "config", "account_config.json")
    if os.path.exists(config_path):
        with open(config_path, 'r', encoding='utf-8') as f:
            return json.load(f)
    else:
        print(f"[错误] 配置文件不存在: {config_path}")
        print("[提示] 请复制 config/account_config.json.example 为 config/account_config.json 并填入你的信息")
        return None


def load_cookies(config):
    """加载Cookie文件"""
    cookie_file = config.get("cookie_file")
    if not cookie_file or not os.path.exists(cookie_file):
        print(f"[错误] Cookie文件不存在: {cookie_file}")
        return None

    import json
    with open(cookie_file, 'r', encoding='utf-8') as f:
        data = json.load(f)

    cookies = []
    for c in data:
        cookie = {
            "name": c.get("name"),
            "value": c.get("value"),
            "domain": c.get("domain", ".toutiao.com"),
            "path": c.get("path", "/")
        }
        if c.get("httpOnly"):
            cookie["httpOnly"] = True
        if c.get("secure"):
            cookie["secure"] = True
        if c.get("sameSite"):
            ss = c["sameSite"]
            cookie["sameSite"] = ss if ss in ("Strict", "Lax", "None") else "Lax"
        cookies.append(cookie)

    return cookies


def main():
    parser = argparse.ArgumentParser(description="头条文章发布脚本")
    parser.add_argument("--topic", "-t", type=str, help="文章话题（不传则自动从话题邀请中获取）")
    parser.add_argument("--content", "-c", type=str, help="自定义正文内容")
    parser.add_argument("--title", type=str, help="自定义标题")
    parser.add_argument("--image", "-i", type=str, help="配图文件路径")
    parser.add_argument("--no-publish", action="store_true", help="只生成不发布")
    args = parser.parse_args()

    # 加载配置
    config = load_config()
    if not config:
        sys.exit(1)

    log("===== 头条文章发布开始 =====")
    log(f"账号: {config.get('account_name', '未知')}")

    # 1. 生成文章
    if args.content:
        article = {
            "title": args.title or "自定义文章",
            "content": args.content,
            "topic": args.topic or "自定义"
        }
    else:
        article_gen = ArticleGenerator()
        topic = args.topic or "中年男人的困境"
        article = article_gen.generate_article(topic)
        log(f"话题: {topic}")

    log(f"标题: {article['title']}")
    log(f"字数: {len(article['content'])}")

    if args.no_publish:
        log("===== 仅生成模式（不发布）=====")
        print(f"\n标题: {article['title']}")
        print(f"\n{article['content']}")
        return

    # 2. 获取配图
    image_path = args.image
    if not image_path:
        # 尝试从 generated-images 目录查找
        img_dir = os.path.join(ROOT_DIR, "generated-images")
        if os.path.exists(img_dir):
            img_files = [f for f in os.listdir(img_dir) if f.endswith(('.png', '.jpg', '.jpeg'))]
            if img_files:
                image_path = os.path.join(img_dir, img_files[0])
                log(f"使用已有配图: {image_path}")

    # 3. 连接浏览器发布
    from playwright.sync_api import sync_playwright

    cookies = load_cookies(config)
    if not cookies:
        sys.exit(1)

    with sync_playwright() as p:
        browser = p.chromium.launch(
            headless=False,
            slow_mo=80,
            args=["--no-sandbox"]
        )
        ctx = browser.new_context(
            viewport={"width": 1440, "height": 900},
            user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        )

        ctx.add_cookies(cookies)
        page = ctx.new_page()

        # 检查登录状态
        page.goto("https://mp.toutiao.com/profile_v4/index", wait_until="networkidle", timeout=30000)
        time.sleep(3)

        if "login" in page.url:
            log("Cookie已过期，需要重新登录")
            browser.close()
            sys.exit(1)

        log(f"登录状态正常")

        # 导航到发布页面
        log("导航到发布页面...")
        page.goto("https://mp.toutiao.com/profile_v4/graphic/publish", wait_until="networkidle", timeout=30000)
        time.sleep(5)

        # 填充标题
        log("填充标题...")
        try:
            title_input = page.wait_for_selector("textarea[placeholder*='标题'], input[placeholder*='标题']", timeout=5000)
            if title_input and title_input.is_visible():
                title_input.click()
                time.sleep(0.5)
                title_input.fill(article['title'])
                log(f"标题已填入: {article['title']}")
        except:
            log("未找到标题输入框")

        # 填充正文
        log("填充正文...")
        try:
            editors = page.query_selector_all("div[contenteditable='true']")
            for editor in editors:
                if editor.is_visible():
                    editor.click()
                    time.sleep(0.5)
                    editor.evaluate("el => el.innerText = ''")
                    time.sleep(0.3)
                    editor.type(article['content'], delay=5)
                    log(f"正文已填入 ({len(article['content'])} 字)")
                    break
        except Exception as e:
            log(f"填充正文失败: {e}")

        # 上传配图（如果有）
        if image_path and os.path.exists(image_path):
            log("上传配图...")
            try:
                img_btn = page.locator(".syl-toolbar-tool.image.static button")
                if img_btn.count() > 0:
                    img_btn.click()
                    time.sleep(2)
                    file_inputs = page.query_selector_all("input[type='file']")
                    for fi in file_inputs:
                        try:
                            fi.set_input_files(image_path)
                            log("配图已上传")
                            time.sleep(3)
                            break
                        except:
                            pass
            except Exception as e:
                log(f"配图上传失败: {e}")

        # 设置封面
        if image_path and os.path.exists(image_path):
            log("设置封面...")
            try:
                single_img = page.locator("label").filter(has_text="单图").first
                if single_img.count() > 0:
                    single_img.click()
                    time.sleep(5)
                    cover_add = page.locator(".article-cover-add")
                    if cover_add.count() > 0 and cover_add.is_visible():
                        cover_add.click()
                        time.sleep(3)
                        try:
                            page.wait_for_selector("#upload-drag-input", state="attached", timeout=10000)
                            file_input = page.locator("#upload-drag-input")
                            file_input.set_input_files(image_path)
                            time.sleep(5)
                            page.keyboard.press("Escape")
                            time.sleep(1)
                            log("封面设置完成")
                        except Exception as e:
                            log(f"封面上传异常: {e}")
            except Exception as e:
                log(f"封面设置失败: {e}")

        # 勾选声明
        log("勾选声明...")
        try:
            view_label = page.locator("label").filter(has_text="个人观点").first
            if view_label.is_visible(timeout=2000):
                view_label.click()
                log("已勾选个人观点")
        except:
            log("勾选个人观点失败")

        # 点击发布
        log("点击发布按钮...")
        try:
            publish_selectors = ["button:has-text('发布文章')", "button:has-text('发布')", ".publish-btn"]
            for sel in publish_selectors:
                btn = page.locator(sel).first
                if btn.is_visible(timeout=2000):
                    btn_text = btn.inner_text().strip()
                    if "预览" not in btn_text:
                        btn.click()
                        log(f"已点击发布: {btn_text}")
                        time.sleep(10)
                        break
        except Exception as e:
            log(f"发布失败: {e}")

        # 保存截图
        log_dir = os.path.join(ROOT_DIR, "logs")
        os.makedirs(log_dir, exist_ok=True)
        screenshot = os.path.join(log_dir, f"publish_{time.strftime('%Y%m%d_%H%M%S')}.png")
        try:
            page.screenshot(path=screenshot)
            log(f"截图已保存: {screenshot}")
        except:
            pass

        browser.close()

    # 发送飞书通知
    notifier = FeishuNotifier(config_file=os.path.join(ROOT_DIR, "config", "account_config.json"))
    notifier.send_notification(
        title="头条文章发布完成",
        body=f"标题: {article['title']}\n字数: {len(article['content'])}\n账号: {config.get('account_name')}",
        notification_type="success"
    )

    log("===== 发布流程结束 =====")


if __name__ == "__main__":
    main()


path: scripts/publish_weitoutiao.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
微头条发布脚本 - 快速发布短内容
"""

import sys
import os
import time
import json
import argparse

ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT_DIR)

from core.article_generator import ArticleGenerator
from core.image_generator import ImageGenerator
from core.feishu_notifier import FeishuNotifier


def log(message):
    """日志输出"""
    timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
    print(f"[{timestamp}] {message}")


def load_config():
    """加载配置文件"""
    config_path = os.path.join(ROOT_DIR, "config", "account_config.json")
    if os.path.exists(config_path):
        with open(config_path, 'r', encoding='utf-8') as f:
            return json.load(f)
    else:
        print(f"[错误] 配置文件不存在: {config_path}")
        return None


def main():
    parser = argparse.ArgumentParser(description="微头条发布脚本")
    parser.add_argument("--content", "-c", type=str, help="自定义微头条内容")
    parser.add_argument("--topic", "-t", type=str, help="话题")
    parser.add_argument("--image", "-i", type=str, help="配图文件路径")
    parser.add_argument("--auto-topic", action="store_true", help="自动从话题邀请中选题")
    args = parser.parse_args()

    config = load_config()
    if not config:
        sys.exit(1)

    log("===== 微头条发布开始 =====")

    # 1. 确定内容
    if args.content:
        content = args.content
        topic = args.topic or ""
    elif args.auto_topic:
        # 从话题邀请中获取
        from core.publisher import Publisher
        pub = Publisher(config_file=os.path.join(ROOT_DIR, "config", "account_config.json"))
        hot_topic = pub.get_hot_topic()
        if not hot_topic:
            log("没有获取到话题邀请，退出")
            sys.exit(1)
        topic = hot_topic.get('title', '')
        # 生成200字左右的微头条
        gen = ArticleGenerator()
        article = gen.generate_article(topic)
        # 截取前200字
        content = article['content'][:200] + "..."
        log(f"自动选题: {topic}")
    else:
        log("[错误] 请指定 --content 或 --auto-topic")
        sys.exit(1)

    log(f"内容长度: {len(content)} 字")

    # 2. 获取配图
    image_path = args.image
    if not image_path:
        img_dir = os.path.join(ROOT_DIR, "generated-images")
        if os.path.exists(img_dir):
            img_files = [f for f in os.listdir(img_dir) if f.endswith(('.png', '.jpg', '.jpeg'))]
            if img_files:
                image_path = os.path.join(img_dir, img_files[0])

    # 3. 使用 Playwright 发布到微头条
    from playwright.sync_api import sync_playwright

    # 加载Cookie
    cookie_file = config.get("cookie_file")
    if not cookie_file or not os.path.exists(cookie_file):
        log(f"[错误] Cookie文件不存在: {cookie_file}")
        sys.exit(1)

    with open(cookie_file, 'r', encoding='utf-8') as f:
        cookies_data = json.load(f)

    cookies = []
    for c in cookies_data:
        cookie = {
            "name": c.get("name"),
            "value": c.get("value"),
            "domain": c.get("domain", ".toutiao.com"),
            "path": c.get("path", "/")
        }
        if c.get("httpOnly"):
            cookie["httpOnly"] = True
        if c.get("secure"):
            cookie["secure"] = True
        cookies.append(cookie)

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False, args=["--no-sandbox"])
        ctx = browser.new_context(
            viewport={"width": 1440, "height": 900},
            user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
        )
        ctx.add_cookies(cookies)
        page = ctx.new_page()

        # 导航到微头条发布页
        log("导航到微头条发布页...")
        page.goto("https://mp.toutiao.com/profile_v4/weitoutiao/publish", wait_until="networkidle", timeout=30000)
        time.sleep(5)

        if "login" in page.url:
            log("Cookie已过期")
            browser.close()
            sys.exit(1)

        # 填写内容
        log("填写微头条内容...")
        try:
            editor = page.wait_for_selector("div[contenteditable='true']", timeout=10000)
            if editor and editor.is_visible():
                editor.click()
                time.sleep(0.5)
                editor.type(content, delay=10)
                log(f"内容已填入 ({len(content)} 字)")
        except Exception as e:
            log(f"填写内容失败: {e}")

        # 上传配图（如果有）
        if image_path and os.path.exists(image_path):
            log("上传配图...")
            try:
                file_inputs = page.query_selector_all("input[type='file']")
                for fi in file_inputs:
                    try:
                        fi.set_input_files(image_path)
                        log("配图已上传")
                        time.sleep(3)
                        break
                    except:
                        pass
            except Exception as e:
                log(f"配图上传失败: {e}")

        # 发布
        log("点击发布...")
        time.sleep(2)
        try:
            publish_btn = page.locator("button").filter(has_text="发布").first
            if publish_btn.count() > 0 and publish_btn.is_visible(timeout=2000):
                publish_btn.click()
                log("已点击发布")
                time.sleep(5)
        except Exception as e:
            log(f"发布失败: {e}")

        # 截图
        log_dir = os.path.join(ROOT_DIR, "logs")
        os.makedirs(log_dir, exist_ok=True)
        try:
            page.screenshot(path=os.path.join(log_dir, f"weitoutiao_{time.strftime('%Y%m%d_%H%M%S')}.png"))
        except:
            pass

        browser.close()

    # 飞书通知
    notifier = FeishuNotifier(config_file=os.path.join(ROOT_DIR, "config", "account_config.json"))
    notifier.send_notification(
        title="微头条发布完成",
        body=f"内容: {content[:50]}...\n话题: {topic}\n账号: {config.get('account_name')}",
        notification_type="success"
    )

    log("===== 微头条发布结束 =====")


if __name__ == "__main__":
    main()


path: scripts/run_scheduler.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
定时任务脚本 - 支持定时发布文章和微头条
"""

import sys
import os
import time
import json
import argparse
import schedule

ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT_DIR)

from core.article_generator import ArticleGenerator
from core.publisher import Publisher
from core.feishu_notifier import FeishuNotifier


def load_config():
    """加载配置文件"""
    config_path = os.path.join(ROOT_DIR, "config", "account_config.json")
    if os.path.exists(config_path):
        with open(config_path, 'r', encoding='utf-8') as f:
            return json.load(f)
    return None


def load_automation_config():
    """加载自动化配置"""
    auto_path = os.path.join(ROOT_DIR, "config", "automation_settings.json")
    if os.path.exists(auto_path):
        with open(auto_path, 'r', encoding='utf-8') as f:
            return json.load(f)
    return {
        "article": {"enabled": True, "schedule": "0 * 6-23 * * *"},
        "weitoutiao": {"enabled": True, "schedule": "0 * 6-23 * * *", "auto_select_topic": True}
    }


def run_article_task(config):
    """执行文章发布任务"""
    from datetime import datetime
    print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 开始执行文章发布任务...")

    try:
        publisher = Publisher(config_file=os.path.join(ROOT_DIR, "config", "account_config.json"))
        article_gen = ArticleGenerator()
        notifier = FeishuNotifier(config_file=os.path.join(ROOT_DIR, "config", "account_config.json"))

        # 获取热门话题
        hot_topic = publisher.get_hot_topic()
        if not hot_topic:
            print("[定时] 没有获取到话题邀请，跳过")
            return

        topic_title = hot_topic.get('title', '未知话题')
        topic_views = hot_topic.get('view_count', 0)
        print(f"[定时] 热门话题: {topic_title} (围观: {topic_views})")

        # 生成文章
        article = article_gen.generate_article(topic_title, view_count=topic_views)
        print(f"[定时] 文章标题: {article['title']}")

        # 发布文章
        result = publisher.publish_article(article)

        if result['success']:
            print(f"[定时] 发布成功!")
            notifier.send_notification(
                title="定时文章发布成功",
                body=f"标题: {article['title']}\n话题: {topic_title}",
                notification_type="success"
            )
        else:
            print(f"[定时] 发布失败: {result.get('error')}")
            notifier.send_notification(
                title="定时文章发布失败",
                body=f"错误: {result.get('error')}",
                notification_type="error"
            )

    except Exception as e:
        print(f"[定时] 执行异常: {e}")
        import traceback
        traceback.print_exc()


def main():
    parser = argparse.ArgumentParser(description="头条定时发布脚本")
    parser.add_argument("--type", choices=["article", "weitoutiao", "both"], default="article",
                        help="发布类型（article/weitoutiao/both）")
    parser.add_argument("--now", action="store_true", help="立即执行一次")
    args = parser.parse_args()

    config = load_config()
    if not config:
        print("[错误] 请先配置 config/account_config.json")
        sys.exit(1)

    auto_config = load_automation_config()

    print("=" * 60)
    print(f"头条定时发布器启动 (类型: {args.type})")
    print(f"账号: {config.get('account_name')}")
    print("=" * 60)

    # 配置定时任务
    if args.type in ("article", "both"):
        if auto_config["article"].get("enabled", True):
            schedule.every().hour.do(lambda: run_article_task(config))
            print("文章定时任务: 每小时执行一次")

    if args.now:
        print("立即执行一次...")
        run_article_task(config)

    # 持续运行
    print("\n定时器已启动，等待下一次执行...")
    while True:
        schedule.run_pending()
        time.sleep(60)


if __name__ == "__main__":
    main()


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