基于Playwright的番茄小说自动化发布工具开发实战

1. 项目概述:为什么需要一个小说自动化发布工具?

写小说,尤其是连载小说,最怕什么?不是没灵感,而是日复一日、枯燥繁琐的发布流程。每天打开浏览器,登录后台,复制粘贴章节内容,设置标题、分卷,检查格式,点击发布……这套动作重复几十上百次,不仅消磨创作热情,更挤占了宝贵的构思和写作时间。对于在番茄小说这类平台上进行连载的作者而言,保持稳定更新是吸引和留住读者的关键,但手动操作的低效和易错性,成了许多创作者的隐形负担。

这正是我动手设计并实现这个“番茄小说自动化发布工具”的初衷。它不是一个简单的“按键精灵”脚本,而是一个基于现代浏览器自动化框架 Playwright 构建的、能够模拟真人操作、稳定处理复杂网页交互的智能助手。其核心目标就一个: 将作者从重复的机械劳动中解放出来,让创作回归创作本身 。你只需要专注于写好每一章的内容,将整理好的文本文件交给这个工具,它就能帮你完成从登录到发布的全流程,甚至能处理一些常见的异常情况,比如网络波动导致的发布失败、章节内容格式校验等。

这个工具适合谁?首先是像我一样,在番茄小说进行多部作品连载的“肝帝”型作者。其次,是那些希望将作品同步发布到多个平台,寻求效率最大化的创作者。最后,对于任何想要学习如何将 Playwright 这类强大的自动化技术应用于实际生活与工作场景,解决具体痛点的开发者来说,这个项目也是一个绝佳的实战案例。它涉及了网页自动化、状态保持、文件处理、错误重试等一整套工程化思维,价值远超一个简单的脚本。

2. 核心设计思路与技术选型

2.1 为什么是 Playwright 而不是 Selenium?

在决定做这个工具时,第一个面临的选择就是自动化框架。Selenium 是老牌王者,生态成熟,但 Playwright 作为后起之秀,在多个维度上展现出了更贴合我们需求的特质。

第一,无头浏览器(Headless Browser)的稳定与高效。 Playwright 为 Chromium、Firefox 和 WebKit 都提供了高质量的原生支持,其无头模式运行极其稳定,渲染速度快,且对现代 Web 技术(如单页应用 SPA)的支持更好。番茄小说的作者后台正是一个典型的复杂单页应用,大量操作依赖前端 JavaScript 动态加载和交互。Playwright 能够更可靠地等待页面元素加载完成,减少了因页面状态未就绪而导致的脚本失败。

第二,强大的自动等待(Auto-waiting)机制。 这是 Playwright 对比 Selenium 的一个巨大优势。在 Selenium 中,我们经常需要手动编写 WebDriverWait 来等待某个元素出现、可点击或可见,代码冗长且易出错。Playwright 的大多数操作(如 click , fill , type )内置了智能等待:它会等待元素可交互(例如,按钮不再被禁用,输入框可见且可编辑)后再执行操作。这大大简化了代码,提升了脚本的健壮性。在发布章节时,我们需要等待富文本编辑器加载完成、等待“发布”按钮变为可点击状态,Playwright 的自动等待让这些逻辑变得异常简洁。

第三,网络拦截与模拟(Network Interception)。 Playwright 可以轻松监听和修改网络请求。虽然在这个发布工具的第一版中我们可能用不到太复杂的网络操控,但这个特性为未来可能的扩展留下了空间,比如自动处理图片上传、监控发布是否成功的 API 请求等。

第四,跨平台与易于部署。 Playwright 通过 playwright install 命令可以一键安装所需的浏览器二进制文件,部署非常方便。无论是 Windows、macOS 还是 Linux 服务器,都能快速搭建环境。这对于希望将工具部署到云服务器实现定时自动发布的作者来说,是个福音。

基于以上几点,Playwright 在开发效率、运行稳定性和面向未来的扩展性上,都成为了不二之选。

2.2 工具整体架构设计

这个自动化发布工具不是一个庞然大物,它的核心架构清晰而紧凑,主要分为四个模块:

  1. 配置与初始化模块 :负责读取用户配置(如账号密码、作品ID、本地章节文件路径等),初始化 Playwright 浏览器实例,并设置必要的上下文(如视窗大小、是否无头运行、忽略 HTTPS 错误等)。
  2. 身份认证与会话管理模块 :这是工具的“钥匙”。它需要安全地登录番茄小说作者后台,并妥善管理登录状态(Cookies)。为了避免每次运行都重新登录,我们会实现会话持久化功能,将登录后的 Cookies 保存到本地文件,下次启动时直接加载,既安全又高效。
  3. 内容解析与发布模块 :这是工具的“核心引擎”。它需要读取本地的章节文件(通常是 Markdown 或纯文本),解析出章节标题和正文内容。然后,导航到目标作品的发布页面,将内容填充到网页编辑器中,并模拟点击发布按钮。这里需要精细处理富文本编辑器的交互,可能涉及模拟键盘输入、处理文件上传(如章节封面图)等。
  4. 流程控制与异常处理模块 :这是工具的“安全网”。它需要控制发布的节奏(例如,章节间发布间隔),监控每个步骤的执行状态。一旦遇到网络超时、元素找不到、发布失败等异常,工具应能进行重试、记录错误日志,并在可能的情况下跳过当前章节继续后续任务,而不是整体崩溃。

整个工具的数据流是线性的:读取配置 -> 登录/恢复会话 -> 遍历章节文件 -> 逐个发布 -> 生成报告。但通过异常处理模块,这条流水线具备了容错能力。

注意 :在设计之初就必须考虑道德和法律边界。这个工具的目的是提升创作者个人的工作效率, 严禁 用于恶意刷量、攻击平台、干扰正常服务或任何违反番茄小说用户协议的行为。使用时请务必遵守平台规则,合理控制请求频率,避免对服务器造成不必要的压力。

3. 实战开发:从零构建发布工具

3.1 环境准备与基础依赖安装

工欲善其事,必先利其器。我们选择 Python 作为开发语言,因为它语法简洁,生态丰富,Playwright 对 Python 的支持也非常完善。

首先,创建一个新的项目目录,并初始化虚拟环境,这是保持项目依赖隔离的好习惯。

mkdir tomato-novel-publisher
cd tomato-novel-publisher
python -m venv venv  # Windows 使用 `venv\Scripts\activate` 激活
source venv/bin/activate # macOS/Linux 激活虚拟环境

接着,安装核心依赖 Playwright for Python。

pip install playwright

安装完成后,需要安装 Playwright 所需的浏览器驱动。这里我们选择 Chromium,因为它与 Chrome 兼容性好,且是 Playwright 支持最全面的。

playwright install chromium

为了更方便地管理配置和记录日志,我们还可以安装两个辅助库:

pip install python-dotenv # 用于从 .env 文件加载敏感配置(如密码)
pip install loguru # 更美观、功能更强大的日志库

现在,基本的开发环境就搭建好了。你的项目根目录下应该有一个 venv 文件夹和一个 requirements.txt (可以通过 pip freeze > requirements.txt 生成)。

3.2 实现安全的登录与会话持久化

登录是自动化的第一步,也是最容易出问题的一步。番茄小说后台的登录页面可能有验证码、动态加载等问题。我们的策略是:首次手动登录,保存会话;后续使用保存的会话恢复登录状态。

首先,创建一个 config.py 文件来管理配置,并使用 .env 文件存储敏感信息。

.env 文件 (切记加入 .gitignore ):

TOMATO_USERNAME=your_phone_number
TOMATO_PASSWORD=your_password

config.py 文件:

import os
from pathlib import Path
from dotenv import load_dotenv

load_dotenv()  # 加载 .env 文件中的环境变量

class Config:
    # 基础路径
    BASE_DIR = Path(__file__).parent
    COOKIES_FILE = BASE_DIR / "cookies.json"
    CHAPTERS_DIR = BASE_DIR / "chapters"
    
    # 账号信息(从环境变量读取)
    USERNAME = os.getenv("TOMATO_USERNAME")
    PASSWORD = os.getenv("TOMATO_PASSWORD")
    
    # 作品ID(需要从番茄后台URL中获取)
    BOOK_ID = "123456789"  # 替换为你的作品ID
    
    # 浏览器配置
    HEADLESS = False  # 开发时设为 False 方便调试,生产环境可设为 True
    VIEWPORT = {"width": 1920, "height": 1080}
    SLOW_MO = 100  # 操作延迟毫秒数,调试时有用,生产时可设为 0 或较小值
    
    # 发布间隔(秒),避免请求过快
    PUBLISH_INTERVAL = 5

config = Config()

接下来,创建核心的 publisher.py ,实现登录类。

import json
import asyncio
from loguru import logger
from playwright.async_api import async_playwright, BrowserContext
from config import config

class TomatoPublisher:
    def __init__(self):
        self.context: BrowserContext = None
        self.browser = None
        self.page = None
        
    async def __aenter__(self):
        """异步上下文管理器入口,初始化浏览器"""
        self.playwright = await async_playwright().start()
        # 启动浏览器,使用 persistent context 有助于会话管理
        self.browser = await self.playwright.chromium.launch(
            headless=config.HEADLESS,
            slow_mo=config.SLOW_MO
        )
        # 尝试从文件加载 cookies
        if config.COOKIES_FILE.exists():
            logger.info(f"尝试从 {config.COOKIES_FILE} 加载 cookies...")
            self.context = await self.browser.new_context(
                viewport=config.VIEWPORT,
                storage_state=config.COOKIES_FILE  # 关键:加载持久化状态
            )
        else:
            self.context = await self.browser.new_context(viewport=config.VIEWPORT)
            
        self.page = await self.context.new_page()
        return self
        
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """异步上下文管理器出口,关闭资源"""
        if self.browser:
            await self.browser.close()
        await self.playwright.stop()
        
    async def login_if_needed(self):
        """检查当前会话是否有效,如果无效则执行登录"""
        # 导航到作者后台首页,这是一个需要登录才能访问的页面
        await self.page.goto("https://author.tomatolearning.com/")
        
        # 通过判断页面是否跳转到登录页,或者是否存在用户信息元素,来判断登录状态
        # 这里以检查页面标题或特定元素为例
        try:
            # 假设登录后页面会有“创作中心”之类的字样
            await self.page.wait_for_selector("text=创作中心", timeout=5000)
            logger.success("会话有效,已处于登录状态。")
            return True
        except Exception as e:
            logger.warning("会话已失效或未登录,开始执行登录流程...")
            return await self._perform_login()
            
    async def _perform_login(self):
        """执行实际的登录操作"""
        login_url = "https://passport.tomatolearning.com/login"  # 示例,实际URL需确认
        await self.page.goto(login_url)
        
        # 等待登录表单加载
        await self.page.wait_for_selector("input[type='text'], input[type='tel']", timeout=10000)
        
        # 填充账号密码
        # 注意:选择器需要根据番茄小说实际登录页面的HTML结构进行调整
        await self.page.fill('input[placeholder="手机号"]', config.USERNAME)
        await self.page.fill('input[type="password"]', config.PASSWORD)
        
        # 点击登录按钮
        login_btn = self.page.locator('button:has-text("登录")').first
        await login_btn.click()
        
        # 等待登录成功,跳转到作者后台
        try:
            await self.page.wait_for_url(https://blog.csdn.net/"**/author/**",%20timeout=15000)
            logger.success("登录成功!")
            # 登录成功后,立即保存 cookies 到文件
            await self.context.storage_state(path=config.COOKIES_FILE)
            logger.info(f"会话已保存至 {config.COOKIES_FILE}")
            return True
        except Exception as e:
            logger.error(f"登录失败或超时: {e}")
            # 可以在这里截图以便调试
            await self.page.screenshot(path="login_error.png")
            return False

关键点解析与避坑指南:

  1. 使用异步 ( async/await ) : Playwright 推荐使用异步 API 以获得最佳性能。我们整个工具都采用异步编程模型。
  2. BrowserContext storage_state : 这是实现会话持久化的核心。 BrowserContext 代表一个独立的浏览器会话环境,其 storage_state() 方法可以导出当前上下文的所有 Cookies、LocalStorage 等数据。反之,在创建 new_context 时传入 storage_state 参数,即可还原整个会话状态。这比手动处理 Cookie 文件更可靠。
  3. 选择器策略 : 登录页面的元素选择器必须准确。建议使用 Playwright 的代码生成器( playwright codegen )来录制登录操作,快速获取可靠的选择器。优先使用 text= role 定位,它们比复杂的 CSS 选择器更稳定。
  4. 等待策略 : wait_for_selector , wait_for_url 是确保页面状态就绪的关键。结合 Playwright 的自动等待,可以构建出健壮的操作链。
  5. 错误处理与调试 : 登录环节最容易遇到验证码或动态安全策略。我们的代码中加入了超时控制和错误截图。如果遇到无法绕过的验证码,可能需要考虑半自动化方案(如工具暂停,提示用户手动完成验证后再继续)。

3.3 章节内容解析与发布流程实现

登录问题解决后,就进入了核心的发布环节。我们假设章节文件以特定的格式存储在本地 chapters 目录下,例如 001_第一章标题.txt ,文件内容第一行为标题,空一行后是正文。

首先,实现一个简单的章节文件解析器。

# file_parser.py
import re
from pathlib import Path
from typing import List, Tuple
from loguru import logger

class ChapterParser:
    @staticmethod
    def parse_chapter_file(file_path: Path) -> Tuple[str, str]:
        """解析章节文件,返回(标题, 正文)元组"""
        try:
            content = file_path.read_text(encoding='utf-8')
            lines = content.strip().split('\n')
            
            if len(lines) < 2:
                raise ValueError(f"文件 {file_path.name} 内容格式错误,至少需要标题和正文。")
                
            title = lines[0].strip()
            # 假设标题行之后有一个空行,然后全是正文
            body_lines = []
            found_empty_line = False
            for line in lines[1:]:
                if not found_empty_line and line.strip() == "":
                    found_empty_line = True
                    continue
                if found_empty_line:
                    body_lines.append(line.rstrip()) # 保留行尾空格可能影响格式
            
            body = '\n'.join(body_lines).strip()
            if not body:
                raise ValueError(f"文件 {file_path.name} 正文内容为空。")
                
            logger.debug(f"解析文件成功: {file_path.name} -> 标题: {title[:20]}...")
            return title, body
            
        except Exception as e:
            logger.error(f"解析章节文件 {file_path} 时出错: {e}")
            raise
            
    @staticmethod
    def get_chapter_files(chapters_dir: Path) -> List[Path]:
        """获取指定目录下所有的章节文件,并按文件名排序"""
        if not chapters_dir.exists():
            raise FileNotFoundError(f"章节目录不存在: {chapters_dir}")
            
        # 假设文件名为 数字序号_标题.txt 的格式
        files = list(chapters_dir.glob("*.txt"))
        # 按文件名中的数字部分排序
        files.sort(key=lambda x: int(re.search(r'^(\d+)', x.stem).group(1)) if re.search(r'^(\d+)', x.stem) else 0)
        logger.info(f"在目录 {chapters_dir} 中找到 {len(files)} 个章节文件。")
        return files

接下来,在 TomatoPublisher 类中添加发布章节的核心方法。

# 在 publisher.py 的 TomatoPublisher 类中继续添加
async def publish_chapter(self, book_id: str, title: str, content: str):
    """发布单个章节到指定作品"""
    logger.info(f"开始发布章节: 《{title}》")
    
    # 1. 导航到作品发布页面
    publish_url = f"https://author.tomatolearning.com/books/{book_id}/chapters/new" # 示例URL
    await self.page.goto(publish_url)
    
    # 2. 等待页面关键元素加载完成
    # 通常包括章节标题输入框和正文编辑器
    try:
        await self.page.wait_for_selector('input[placeholder="请输入章节标题"]', timeout=10000)
        # 富文本编辑器可能是一个 iframe 或 contenteditable div,需要特殊处理
        # 这里假设编辑器是一个 contenteditable 的 div
        await self.page.wait_for_selector('div[contenteditable="true"]', timeout=10000)
    except Exception as e:
        logger.error(f"发布页面元素加载超时: {e}")
        await self.page.screenshot(path=f"publish_page_error_{title[:10]}.png")
        raise
    
    # 3. 填写章节标题
    await self.page.fill('input[placeholder="请输入章节标题"]', title)
    
    # 4. 填写章节正文 - 这是最复杂的一步
    editor_selector = 'div[contenteditable="true"]'
    editor = self.page.locator(editor_selector).first
    
    # 方法A:直接设置 innerHTML (如果编辑器支持且简单)
    # await editor.evaluate('(element, content) => element.innerHTML = `<p>${content.replace(/\\n/g, "</p><p>")}</p>`', content)
    
    # 方法B:模拟键盘输入(更贴近真人操作,但较慢)
    await editor.click()  # 聚焦编辑器
    await self.page.keyboard.press("Control+A")  # 全选(清除可能存在的默认提示文字)
    await self.page.keyboard.press("Delete")
    # 将内容按段落分割后输入
    paragraphs = content.split('\n')
    for i, para in enumerate(paragraphs):
        if para.strip():  # 非空段落
            await self.page.keyboard.type(para)
        if i < len(paragraphs) - 1:  # 不是最后一段,则按回车换行
            await self.page.keyboard.press("Enter")
    
    logger.debug("章节正文填充完成。")
    
    # 5. (可选)设置分卷、封面图等
    # 这里需要根据番茄后台的实际界面进行适配,可能涉及点击下拉框、上传文件等操作。
    # 示例:选择分卷
    # await self.page.click('text=选择分卷')
    # await self.page.click('li:has-text("第一卷")')
    
    # 6. 点击发布按钮
    publish_button = self.page.locator('button:has-text("发布"), button:has-text("确认发布")').first
    # 等待按钮变为可点击状态(Playwright 的 click 已内置此等待)
    await publish_button.click()
    
    # 7. 等待发布成功反馈
    try:
        # 等待成功提示出现,或者页面跳转
        await self.page.wait_for_selector('text=发布成功', timeout=10000)
        # 或者等待页面URL变化
        # await self.page.wait_for_url(https://blog.csdn.net/"**/chapters/**",%20timeout=10000)
        logger.success(f"章节《{title}》发布成功!")
        return True
    except Exception as e:
        logger.error(f"章节《{title}》发布后未检测到成功状态: {e}")
        # 截图以便排查
        await self.page.screenshot(path=f"publish_fail_{title[:10]}.png")
        # 检查是否有错误提示
        error_msg = await self.page.locator('.error-message, .ant-message-error').text_content(timeout=2000)
        if error_msg:
            logger.error(f"页面错误提示: {error_msg}")
        return False

关键点解析与避坑指南:

  1. 富文本编辑器交互 : 这是最大的挑战。不同平台的富文本编辑器实现千差万别。上述代码提供了两种思路:直接注入 HTML(快,但可能被过滤或触发异常)和模拟键盘输入(慢,但最接近真人操作)。 必须通过实际测试确定哪种方法有效 。使用 playwright codegen 录制一次手动发布操作,是分析编辑器交互逻辑的最佳方式。
  2. 等待与稳定性 : 在点击“发布”按钮后,一定要等待明确的成功信号(如成功提示弹窗、页面跳转)。不要假设点击完就万事大吉,网络延迟或后端处理可能导致发布实际未成功。
  3. 错误恢复 : 发布失败后,工具记录了错误截图和可能的错误信息。在实际的批量发布中,我们可能希望将失败的章节记录到一个列表中,稍后重试,而不是让整个流程中断。
  4. 节奏控制 : 在 config 中我们设置了 PUBLISH_INTERVAL 。在批量发布循环中,每发布一章后使用 await asyncio.sleep(config.PUBLISH_INTERVAL) 进行等待,是对平台友好的做法,避免被误判为恶意请求。

3.4 主流程整合与批量发布

最后,我们将所有模块整合起来,实现一个完整的、可处理批量章节发布的 main 函数。

# main.py
import asyncio
from pathlib import Path
from loguru import logger
from publisher import TomatoPublisher
from file_parser import ChapterParser
from config import config

async def main():
    """主发布流程"""
    # 初始化日志
    logger.add("publish.log", rotation="10 MB", level="INFO")
    
    # 1. 检查配置
    if not config.USERNAME or not config.PASSWORD:
        logger.error("请在 .env 文件中配置 TOMATO_USERNAME 和 TOMATO_PASSWORD。")
        return
    if not config.BOOK_ID or config.BOOK_ID == "123456789":
        logger.error("请在 config.py 中配置正确的 BOOK_ID。")
        return
        
    # 2. 获取章节文件列表
    try:
        chapter_files = ChapterParser.get_chapter_files(config.CHAPTERS_DIR)
    except FileNotFoundError as e:
        logger.error(e)
        return
        
    if not chapter_files:
        logger.warning("未找到任何章节文件,程序退出。")
        return
        
    # 3. 使用异步上下文管理器启动浏览器和发布器
    async with TomatoPublisher() as publisher:
        # 4. 登录或恢复会话
        login_success = await publisher.login_if_needed()
        if not login_success:
            logger.critical("登录失败,无法继续发布流程。")
            return
            
        # 5. 遍历并发布章节
        success_count = 0
        fail_list = []
        
        for idx, chap_file in enumerate(chapter_files, start=1):
            logger.info(f"处理进度: [{idx}/{len(chapter_files)}] - {chap_file.name}")
            try:
                # 解析章节
                title, content = ChapterParser.parse_chapter_file(chap_file)
                
                # 发布章节
                result = await publisher.publish_chapter(config.BOOK_ID, title, content)
                
                if result:
                    success_count += 1
                    logger.info(f"成功发布: {title}")
                else:
                    fail_list.append((chap_file.name, title))
                    logger.error(f"发布失败: {title}")
                    
            except Exception as e:
                fail_list.append((chap_file.name, str(e)))
                logger.exception(f"处理文件 {chap_file.name} 时发生未预期错误: {e}")
                
            # 发布间隔,避免请求过快
            if idx < len(chapter_files):
                await asyncio.sleep(config.PUBLISH_INTERVAL)
                
        # 6. 发布结果汇总
        logger.info("="*50)
        logger.info(f"批量发布完成!")
        logger.info(f"总计章节: {len(chapter_files)}")
        logger.info(f"成功发布: {success_count}")
        logger.info(f"失败章节: {len(fail_list)}")
        if fail_list:
            logger.info("失败详情:")
            for file_name, reason in fail_list:
                logger.info(f"  - {file_name}: {reason}")
        logger.info("="*50)

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

至此,一个具备基础功能的番茄小说自动化发布工具就完成了。你可以通过运行 python main.py 来启动它。首次运行会弹出浏览器窗口让你手动登录,之后便会自动保存会话,后续运行可实现全自动发布。

4. 进阶优化与异常处理实战

一个能用的工具和一个好用的工具之间,差的就是细节处理和健壮性。在实际使用中,你肯定会遇到各种意想不到的问题。下面分享几个我踩过坑后总结的进阶优化点。

4.1 增强的富文本编辑器处理策略

上述的编辑器填充方法(模拟键盘输入)虽然稳定,但速度慢,且对于超长章节(几万字)可能不稳定。我们可以采用混合策略,并增加容错。

async def _fill_editor_robust(self, content: str, max_retries: int = 2):
    """更健壮的编辑器填充方法"""
    editor = self.page.locator('div[contenteditable="true"]').first
    for attempt in range(max_retries):
        try:
            await editor.click()
            await self.page.keyboard.press("Control+A")
            await self.page.keyboard.press("Delete")
            await asyncio.sleep(0.5)  # 给编辑器一点反应时间
            
            # 尝试快速注入(如果编辑器允许)
            if attempt == 0:
                try:
                    # 尝试用 evaluate 直接设置文本内容(非HTML)
                    await editor.evaluate('(el, txt) => el.textContent = txt', content)
                    # 触发一次 input 事件,让编辑器知道内容变了
                    await editor.evaluate('el => el.dispatchEvent(new Event("input", { bubbles: true }))')
                    await asyncio.sleep(1)
                    # 检查内容是否成功注入
                    actual_content = await editor.text_content()
                    if actual_content and len(actual_content) > len(content) * 0.8: # 粗略检查
                        logger.debug("使用快速文本注入成功。")
                        return True
                except Exception as e:
                    logger.debug(f"快速注入失败,回退到模拟输入: {e}")
            
            # 回退方案:分段模拟输入
            logger.debug("使用分段模拟键盘输入。")
            # 将内容分成小块,避免单次操作过长
            chunks = [content[i:i+500] for i in range(0, len(content), 500)]
            for chunk in chunks:
                await self.page.keyboard.type(chunk)
                await asyncio.sleep(0.05)  # 小块之间的微小延迟
            return True
            
        except Exception as e:
            logger.warning(f"编辑器填充第{attempt+1}次尝试失败: {e}")
            if attempt < max_retries - 1:
                await asyncio.sleep(2)
                # 可以尝试重新聚焦编辑器
                await editor.click({'force': True})
            else:
                raise Exception(f"编辑器填充失败,已重试{max_retries}次。")

4.2 网络异常与元素失联的重试机制

网络不稳定或页面动态加载可能导致元素定位失败。我们需要一个带有重试的通用操作包装器。

async def retry_operation(self, operation, *args, max_attempts=3, delay=2, **kwargs):
    """重试装饰器,用于包装可能失败的操作"""
    last_exception = None
    for attempt in range(1, max_attempts + 1):
        try:
            return await operation(*args, **kwargs)
        except Exception as e:
            last_exception = e
            logger.warning(f"操作第 {attempt} 次尝试失败: {e}")
            if attempt < max_attempts:
                logger.info(f"{delay}秒后重试...")
                await asyncio.sleep(delay)
            else:
                logger.error(f"操作在 {max_attempts} 次尝试后均失败。")
                raise last_exception

在发布章节时,可以这样使用:

# 在 publish_chapter 方法中,替换原有的 goto 和 wait_for_selector
await self.retry_operation(self.page.goto, publish_url, timeout=15000)
await self.retry_operation(self.page.wait_for_selector, 'input[placeholder="请输入章节标题"]', timeout=10000)

4.3 发布状态的双重校验

仅仅等待“发布成功”的提示可能不够,因为提示可能一闪而过,或者页面有延迟。更可靠的做法是结合多种信号进行校验。

async def _verify_publish_success(self, chapter_title: str) -> bool:
    """验证章节是否发布成功"""
    verification_passed = False
    
    # 信号1:等待成功提示文本
    try:
        await self.page.wait_for_selector('text=发布成功', timeout=8000)
        logger.debug("检测到‘发布成功’提示。")
        verification_passed = True
    except:
        logger.debug("未检测到明确的‘发布成功’文本提示。")
    
    # 信号2:检查URL是否跳转到章节列表页或编辑页
    current_url = self.page.url
    if "/chapters/" in current_url and "/new" not in current_url:
        logger.debug(f"URL跳转成功: {current_url}")
        verification_passed = True
    
    # 信号3:(可选)检查页面中是否出现刚发布的章节标题
    # 这可能需要导航到章节列表页,实现较复杂,可作为备选
    
    if not verification_passed:
        # 最终手段:短暂等待后,尝试获取任何错误信息
        await asyncio.sleep(3)
        error_elements = await self.page.locator('.ant-alert-error, .error-text, [class*="error"], [class*="fail"]').all()
        for elem in error_elements:
            error_text = await elem.text_content()
            if error_text and len(error_text) < 100: # 避免过长文本
                logger.error(f"页面显示错误: {error_text}")
                return False
        # 如果既无成功信号,也无错误信号,我们倾向于认为可能成功了(但记录警告)
        logger.warning(f"章节《{chapter_title}》发布状态不明确,请手动确认。")
        return True # 或 False,取决于你的策略
    
    return verification_passed

然后在 publish_chapter 方法的最后,用 _verify_publish_success(title) 替换简单的 wait_for_selector

4.4 配置文件与命令行参数增强

让工具更易用,可以支持命令行参数来覆盖配置文件。

# config.py 补充
import argparse
def update_config_from_cli():
    parser = argparse.ArgumentParser(description='番茄小说自动化发布工具')
    parser.add_argument('--book-id', help='指定作品ID,覆盖配置文件')
    parser.add_argument('--headless', action='store_true', help='以无头模式运行浏览器')
    parser.add_argument('--dir', help='指定章节文件目录,覆盖配置文件')
    parser.add_argument('--start-from', type=int, default=1, help='从第几个章节文件开始发布(基于排序后)')
    parser.add_argument('--dry-run', action='store_true', help='试运行,填充内容但不点击发布按钮')
    
    args = parser.parse_args()
    
    if args.book_id:
        config.BOOK_ID = args.book_id
    if args.headless:
        config.HEADLESS = True
    if args.dir:
        config.CHAPTERS_DIR = Path(args.dir)
    config.START_FROM = args.start_from
    config.DRY_RUN = args.dry_run

main.py 中调用 update_config_from_cli() ,并在发布循环中加入判断:

if config.DRY_RUN:
    logger.warning("*** 试运行模式已开启,不会实际点击发布按钮!***")
# 在发布函数内部
if not config.DRY_RUN:
    await publish_button.click()
else:
    logger.info(f"[试运行] 模拟点击发布按钮,章节《{title}》")
    await asyncio.sleep(1) # 模拟等待时间

5. 部署与持续运行建议

开发完成后,你可能希望工具能定时自动运行,比如每天凌晨自动发布存稿。

方案一:本地定时任务(Windows 任务计划 / macOS/Linux crontab) 这是最简单的方式。创建一个批处理脚本( .bat .sh ),激活虚拟环境并运行 python main.py ,然后让系统定时执行这个脚本。

  • 缺点 :电脑需要一直开机。

方案二:云服务器部署 购买一台低配的云服务器(如腾讯云、阿里云的轻量应用服务器),将代码部署上去。在服务器上同样使用 crontab 设置定时任务。

  • 关键点
    1. 在服务器上安装必要的依赖(包括 Chromium)。
    2. 首次运行需要在有图形界面的环境下完成登录(或者使用 xvfb 等虚拟显示框架在无头环境下处理可能的登录验证码)。
    3. 将保存了有效 Cookies 的 cookies.json 文件备份好,服务器重启或失效后可以手动恢复。

方案三:容器化部署(Docker) 这是更优雅和可移植的方案。创建一个 Dockerfile,包含 Python 环境、Playwright 及其浏览器依赖。将代码、配置和持久化的 Cookies 文件通过卷(Volume)挂载到容器中。然后使用 Docker 的定时任务工具或配合 Kubernetes CronJob 来运行。

  • 优势 :环境隔离,一次构建,随处运行。
  • 挑战 :需要一定的 Docker 知识,且处理无头浏览器在容器中的运行可能需要额外的系统依赖。

重要安全提醒 :无论采用哪种部署方式,务必妥善保管你的 .env 文件和 cookies.json 文件。它们包含了你的账号敏感信息。切勿将其提交到公开的代码仓库(如 GitHub)。务必在 .gitignore 中添加 .env , cookies.json , *.png (截图), publish.log 等条目。

这个基于 Playwright 的自动化发布工具,从构思到实现,再到不断优化以应对真实场景中的各种“坑”,整个过程本身就是一次宝贵的全栈工程实践。它不仅仅帮你节省了时间,更让你深入理解了浏览器自动化的精髓——如何让代码像人一样与复杂的 Web 应用交互,同时保持比人更高的准确性和不知疲倦的稳定性。希望这个详细的实战记录,能为你自己的自动化之路提供一个坚实的起点。

打开链接下载源码: https://pan.quark.cn/s/331a85e1b463 在数字化时代背景下,软件授权与保护显得极为关键,微狗(MicroDog)作为一款硬件加密狗,其主要功能是保障软件的合法使用,避免盗版和未经授权的访问。为了达成这一目的,微狗驱动发挥着不可或缺的作用。驱动程序充当硬件与操作系统之间的沟通纽带,确保两者能够和谐协作。现阶段,64位微狗驱动(UMI64位)已经兼容Windows 11、Windows 10以及Windows 7操作系统,为不同的系统环境提供坚实可靠的支持。 随着Windows操作系统的持续升级,对驱动程序的兼容性需求也在逐步提高。微狗驱动UMI64位版本正是为了应对兼容性问题而研发的。它不仅适配最新版的Windows 11,同时也与过去几年中普遍应用的Windows 10和Windows 7保持兼容。如此全面的系统支持,使得微狗加密狗能够在多种环境中稳定运作,确保软件授权管理不受操作系统版本的限制。 在这个驱动中,特别强调了支持UMI V4.1版本。UMI可能代表Unique Machine Identifier,即用于标识特定硬件设备的唯一序列号。提及UMI V4.1表明该驱动能够精准识别并支援微狗加密狗的此特定型号。同时,这也暗示驱动可能与其他版本的微狗硬件兼容,这意味着用户可以在不同版本的微狗加密狗之间切换而不必频繁更换驱动程序。 UMI64位标签凸显了驱动程序的核心特征,即它专为64位系统进行优化。相较于32位系统,64位系统在处理海量数据、运行大型应用时展现出显著优势,例如能够支持更大的内存地址空间。随着软件复杂性的提升,对硬件资源的需求持续增长,因此64位系统能够提供更优越的性能和稳定性。UMI系列硬件与...
代码下载地址: https://pan.quark.cn/s/a4b39357ea24 ### Xilinx Vivado硬件诊断:ILA与VIO的应用指南 #### 一、背景信息 在FPGA的设计阶段,硬件诊断和验证工作占据着至关重要的地位。根据相关数据统计,在一个典型的FPGA开发流程中,硬件诊断和验证所占用的开发周期比例通常在30%到40%之间。因此,精通FPGA设计工具的调试功能对于提升开发效率具有显著作用。 #### 二、ILA与VIO的功能说明 ##### 1. ILA (Integrated Logic Analyzer) ILA是Xilinx公司提供的一种用于监测FPGA内部信号的逻辑分析仪工具。该工具能够捕获并保存FPGA内部信号波形,从而为开发者提供调试支持。ILA的核心结构如图1所示: **图1 ILA Core** ILA的主要构成部分包括时钟输入端、探针输入端口以及用于存储采样数据的BRAM(Block RAM)。设计人员可以通过配置ILA核来指定探针的总数、采样深度以及每个探针的位宽。此外,ILA还支持通过JTAG接口与外部调试设备进行通信。 - **探针输入端口**:用于连接FPGA内部信号线路。 - **采样深度**:决定了能够存储的样本数量。 - **探针位宽**:指定了每个探针可以监控的信号位数。 - **通信机制**:通过JTAG接口与调试核心集线器实现交互。 ##### 2. VIO (Virtual Input/Output core) VIO是一种能够实时监控和驱动FPGA内部信号的内核。与ILA的不同之处在于,VIO无需额外的片上或片外存储器来保存数据。 - **信号类型**: - **Input Probes**:...
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值