Skill: c4chuan/csdn-publisher (version 2.3.0)

--- name: csdn-publisher version: 2.3.0 description: 写文章并发布到 CSDN。使用浏览器自动化 + 扫码登录。支持通过 Telegram 发送二维码，无需 VNC。集成 blog-writer 写作方法论，产出高质量、有个人风格的技术文章。 --- # CSDN Publisher 通过浏览器自动化发布文章到 CSDN。支持扫码登录，二维码可通过 Telegram 发送。 **v2.0 新增**：集成 blog-writer 写作方法论，自动产出高质量、有个人风格的技术文章。 --- ## 🎯 核心工作流（v2.0） ``` 1. 用户说"帮我发篇 CSDN 文章"或提供主题/素材 2. 【内容创作阶段】调用 blog-writer 写作方法论 ├─ 阅读 style-guide-cn.md 校准写作风格 ├─ 参考 examples/ 目录中的示例文章 ├─ 整合用户提供的素材/研究材料 └─ 产出初稿，用户确认后继续 3. 检查登录状态 ├─ 已登录 → 继续 └─ 未登录 → 扫码登录流程 4. 启动浏览器，打开编辑器 5. 注入标题和内容 6. 添加标签，点击发布 7. 验证发布成功，返回文章链接 8. 【可选】用户确认终稿后，保存到 examples/ 目录 ``` --- ## ✍️ 内容创作阶段（核心） ### 触发条件 当用户请求写文章时，**必须先完成内容创作**，再进行发布。 触发词： - "帮我写篇文章" - "发布到 CSDN" - "写一篇关于 XXX 的博客" - 提供主题、素材、链接等 ### 写作流程 #### Step 1: 收集信息 向用户确认： - **主题**：写什么？ - **角度**：从什么视角切入？（教程、踩坑记录、观点输出、技术分析） - **素材**：有没有参考链接、笔记、代码片段？ - **长度**：简短（500-800字）/ 标准（800-1500字）/ 深度（1500-3000字） #### Step 2: 阅读风格指南 **必须阅读** `style-guide-cn.md` 校准写作风格。 核心原则： - 直接、有观点、不装腔作势 - 口语化表达，像跟朋友聊天 - 第一人称叙述个人经历 - 短段落（2-4句话） - 多用小标题分隔内容 #### Step 3: 参考示例文章 阅读 `examples/` 目录中的示例文章，感受目标风格。 #### Step 4: 撰写初稿 按照风格指南撰写，完成后展示给用户确认。 #### Step 5: 迭代修改 根据用户反馈修改，直到用户满意。 --- ## 📝 写作风格指南（中文版） 详见 `style-guide-cn.md`，核心要点： ### 开头模式 用强有力的观点或个人经历开场： ✅ 好的开头： - "搞了两个小时，终于把这个坑填上了。" - "说实话，我一开始是拒绝用 XXX 的。" - "作为一个写了 5 年代码的人，我可以负责任地说：这玩意儿真的有用。" ❌ 避免的开头： - "随着人工智能的快速发展..." - "在当今数字化时代..." - "众所周知..." ### 结构模式 ```markdown # [直接、有态度的标题] [开头：1-2句话抛出核心观点或问题] ### [小标题1：问题/背景] [2-3个短段落] ### [小标题2：过程/分析] [具体细节、代码、截图] ### [小标题3：解决方案/结论] [实操步骤或观点总结] ### 写在最后 [个人感想、行动号召、或前瞻性思考] ``` ### 语言风格 **用这些：** - "说实话"、"坦白讲" - "踩了个坑"、"折腾了半天" - "真香"、"血泪教训" - 第一人称："我发现"、"我的做法是" **避免这些：** - "首先...其次...最后..." - "值得注意的是" - "综上所述" - "不难发现" ### 新闻资讯类文章特别要求 写新闻汇总、行业日报等资讯类文章时： - **必须附带原文链接**：每条新闻都要有跳转链接，方便读者查看原文 - 链接格式：`[新闻标题](原文URL)` 或在新闻末尾标注 `👉 [原文链接](URL)` - 如果原文链接不可用，标注来源名称（如"来源：36氪"） ### 段落长度 - 每段 2-4 句话 - 单句成段用于强调 - 每 150-250 字一个小标题 --- ## 🔍 新闻去重（v2.3 新增） 发布新闻汇总类文章时，**必须先去重**，避免同一条新闻反复出现。 ### 去重流程 1. **获取已有新闻**：运行 `scripts/notion-query-recent.sh 14` 获取最近 14 天的 Notion 数据库记录 2. **逐条比对**：对每条搜索到的新闻，检查是否与已有记录重复： - **URL 精确匹配**：同一个 URL 已存在 → 跳过 - **语义重复**：标题描述的是同一件事（即使措辞不同）→ 跳过 3. **只保留新新闻**：去重后无新闻则跳过写文章和发布 ### 语义重复判断示例 以下算同一条新闻： - "宇树人形机器人日常训练视频爆火B站，播放量超376万" ≈ "宇树人形机器人日常训练视频爆火：播放量超375万" - "小鹏Iron人形机器人深圳首秀摔倒" ≈ "小鹏Iron机器人深圳商场演示翻车" - "Apptronik融资9.35亿美元" ≈ "Apptronik获5.2亿美元融资"（同一轮融资的不同报道） ### 脚本说明 | 脚本 | 用途 | |------|------| | `scripts/notion-query-recent.sh [天数]` | 查询最近 N 天的已有新闻，输出 `标题 \| URL \| 日期` | | `scripts/notion-check-duplicate.sh "标题" ["URL"]` | 精确检查单条新闻是否重复，返回 `duplicate` 或 `new` | ⚠️ 脚本中的 Notion API Key 和 Database ID 需要根据实际环境配置。 --- ## 🔧 技术发布流程 ### 前置条件 #### 1. 安装 Chrome ```bash cd /tmp && curl -sL \ "https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm" \ -o chrome.rpm && yum install -y ./chrome.rpm ``` #### 2. 安装 Python 依赖 ```bash pip install playwright -i https://pypi.org/simple/ playwright install chromium ``` #### 3. 配置 OpenClaw 浏览器 需要 headless + noSandbox 模式（服务器无显示器）： ```bash # 通过 gateway config.patch 添加： {"browser": {"headless": true, "noSandbox": true}} ``` --- ### 扫码登录流程 ✨ #### 完整流程（推荐） 1. **启动登录脚本** ```bash cd /root/.openclaw/workspace/skills/csdn-publisher nohup python scripts/login.py login --timeout 300 > /tmp/csdn-login.log 2>&1 & ``` 2. **等待二维码生成**（约 10-15 秒） ```bash ls ~/.openclaw/workspace/credentials/csdn-qr.png ``` 3. **通过 Telegram 发送二维码** ``` message(action="send", filePath="~/.openclaw/workspace/credentials/csdn-qr.png", target="用户ID", caption="请用 CSDN App 扫码登录") ``` 4. **用户扫码后，脚本自动保存 Cookie** ```bash cat /tmp/csdn-login.log ``` #### 检查 Cookie 有效性 ```bash python scripts/login.py check ``` --- ### 发布文章流程（browser 工具） #### Step 1: 启动浏览器并检查登录状态 ``` browser action=start profile=openclaw browser action=navigate targetUrl=https://editor.csdn.net/md browser action=snapshot ``` 检查 snapshot 结果： - 看到 `textbox "请输入文章标题"` → **已登录** ✅ - 看到 `登录` 或 `扫码` → **需要扫码登录** #### Step 2: 扫码登录（仅首次或 Cookie 过期时） ``` browser action=navigate targetUrl=https://passport.csdn.net/login browser action=screenshot # 截取二维码发给用户 ``` #### Step 3: 注入标题 使用 browser 工具的 `type` 操作： ``` browser action=snapshot → 找到标题输入框的 ref（通常是 textbox "请输入文章标题"） browser action=act request={kind: "click", ref: "<标题ref>"} browser action=act request={kind: "type", ref: "<标题ref>", text: "你的标题"} ``` #### Step 4: 注入内容（⚠️ 关键步骤） CSDN 使用 cledit 编辑器（contentEditable），**不能**用以下方法： - ❌ `browser evaluate` 嵌入长字符串 → 参数长度限制 - ❌ `document.execCommand('insertText')` → 换行符不被 cledit 识别 - ❌ `navigator.clipboard` → headless Chrome 无权限 - ❌ HTTP server + fetch → CORS/混合内容拦截 **✅ 正确方案：使用 `scripts/inject-content.js` 通过 CDP 注入** ```bash # 前置：确保 ws 模块已安装 cd /root/.openclaw/workspace/skills/csdn-publisher npm install ws 2>/dev/null # 注入内容（自动跳过 frontmatter） node scripts/inject-content.js /tmp/csdn-article-YYYY-MM-DD.md ``` 脚本原理： 1. 通过 CDP `/json` 找到 CSDN 编辑器 tab 2. 用 `Runtime.evaluate` + `JSON.stringify(content)` 将内容存入 `window` 变量（绕过长度限制） 3. 用 `editor.textContent = content` + `dispatchEvent('input')` 注入（cledit 兼容） 4. 自动验证注入结果（字数、行数） **注意：** 运行脚本前必须先用 browser 工具打开 CSDN 编辑器页面。 #### Step 5: 发布 ``` browser action=snapshot → 找到"发布文章"按钮的 ref browser action=act request={kind: "click", ref: "<发布按钮ref>"} browser action=snapshot → 检查发布对话框 - 确认标签已添加（必填） - 文章类型选"原创" browser action=act request={kind: "click", ref: "<对话框中的发布按钮ref>"} browser action=snapshot → 验证"发布成功！正在审核中" ``` --- ## 🛡️ 容错与重试策略（v2.1 新增） 浏览器自动化容易因网络、服务中断等原因失败。以下策略确保内容不丢失、发布可重试。 ### 原则：先存内容，再发布 **内容创作是昂贵的（搜索+写作），发布是廉价的（浏览器操作）。必须先把内容落盘，再尝试发布。** ### Step 1: 内容落盘（发布前必做） 在尝试浏览器发布之前，**必须**将文章保存到本地文件： ``` /tmp/csdn-article-YYYY-MM-DD.md ``` 文件格式： ```markdown --- title: 文章标题 date: YYYY-MM-DD tags: [标签1, 标签2] status: draft | published csdn_url: (发布成功后回填) --- 文章正文 Markdown 内容... ``` 这样即使发布失败，文章内容也不会丢失，可以随时重试。 ### Step 2: 浏览器健康检查（发布前必做） 在打开 CSDN 编辑器之前，先确认浏览器服务可用： ``` 1. browser action=start profile=openclaw 2. browser action=snapshot profile=openclaw ``` 如果 `start` 或 `snapshot` 返回错误： - **不要继续发布流程** - 跳到 Step 4（兜底通知） ### Step 3: 失败后自动重试（最多 1 次） 如果发布过程中浏览器操作失败： ``` 1. browser action=stop profile=openclaw # 关闭浏览器 2. 等待 5 秒 3. browser action=start profile=openclaw # 重启浏览器 4. 重新执行发布流程（从打开编辑器开始） 5. 只重试 1 次，避免无限循环 ``` **注意：只重试发布步骤，不重跑内容创作。** 从本地文件 `/tmp/csdn-article-YYYY-MM-DD.md` 读取已保存的内容。 ### Step 4: 兜底通知 如果重试后仍然失败： 1. 更新本地文件的 `status: failed` 2. 向用户发送通知，包含： - 失败原因 - 文章标题 - 提示：文章已保存在 `/tmp/csdn-article-YYYY-MM-DD.md`，可以手动触发重新发布 ### 完整发布流程（含容错） ``` 内容创作完成 ↓ 保存到 /tmp/csdn-article-YYYY-MM-DD.md ← 落盘 ↓ browser start + snapshot ← 健康检查 ↓ (失败 → 跳到兜底通知) 打开编辑器 → 注入内容 → 发布 ↓ (失败) browser stop → 等 5s → browser start ← 重试 ↓ 重新打开编辑器 → 注入内容 → 发布 ↓ (仍然失败) 发送失败通知 + 文章保存路径 ← 兜底 ``` --- ## 📁 目录结构 ``` csdn-publisher/ ├── SKILL.md # 本文档 ├── style-guide-cn.md # 中文写作风格指南 ├── examples/ # 示例文章库 │ └── *.md # 示例文章（YYYY-MM-DD-slug.md） ├── scripts/ │ ├── login.py # 扫码登录脚本 │ ├── inject-content.js # CDP 内容注入脚本（核心） │ ├── notion-query-recent.sh # 查询最近N天已有新闻 │ └── notion-check-duplicate.sh # 精确去重检查 ``` --- ## 📚 示例文章管理 ### 保存终稿 当用户确认文章为**终稿**时，保存到 `examples/` 目录： ``` examples/YYYY-MM-DD-slug-title.md ``` 例如：`examples/2025-02-02-gui-agent-overview.md` ### 示例库维护 - 保持 10-20 篇示例文章 - 超过 20 篇时，询问用户是否删除最旧的 5 篇 - 示例文章用于校准写作风格 --- ## 🔗 依赖技能 本技能依赖 **blog-writer** 的写作方法论： ``` skills/blog-writer/ ├── SKILL.md # 写作工作流 ├── style-guide.md # 英文风格指南（参考） └── *.md # 示例文章 ``` 在撰写文章时，可参考 blog-writer 的： - 结构模式 - 开头/结尾技巧 - 个人经历融入方式 --- ## 踩坑记录 | 坑 | 原因 | 解决方案 | |----|------|----------| | Playwright 安装失败 | 国内镜像源没有 | `pip install playwright -i https://pypi.org/simple/` | | 进程被 kill | OpenClaw 超时机制 | 用 `nohup` 后台运行 | | 二维码定位失败 | 选择器不对 | 用 `img[src*="qrcode"]` | | 浏览器启动失败 | 服务器无显示器 | 配置 `headless: true, noSandbox: true` | | Cookie 注入无效 | domain 设置错误 | 必须设置 `domain=.csdn.net` | | 标签未添加 | 必填项 | 发布前必须添加至少一个标签 | --- ## Cookie 存储 ``` ~/.openclaw/workspace/credentials/csdn-cookie.json # Playwright storage_state 格式 ~/.openclaw/workspace/credentials/csdn-cookie.txt # 简单字符串格式（兼容） ~/.openclaw/workspace/credentials/csdn-qr.png # 登录二维码截图 ``` --- ## 登录状态说明 🔑 **browser 工具**使用 Chrome 的 user-data 目录，登录状态是**持久化**的： - 首次使用需要扫码登录 - 登录后状态自动保存到 `/root/.openclaw/browser/openclaw/user-data` - 下次启动 browser 工具会自动加载登录状态 - Cookie 过期后（通常几天到几周）需要重新扫码 --- ## 自动通知配置 🔔 ### 配置 Telegram 通知 ```bash python scripts/login.py setup-notify \ --bot-token "YOUR_BOT_TOKEN" \ --chat-id "YOUR_CHAT_ID" ``` ### 启动带通知的登录 ```bash nohup python scripts/login.py login --timeout 300 --notify > /tmp/csdn-login.log 2>&1 & ``` --- ## Changelog - **v2.3.0**: 新增新闻去重功能（notion-query-recent.sh + notion-check-duplicate.sh），支持 URL 精确匹配和语义重复判断 - **v2.2.0**: 固化 CDP 内容注入方案（scripts/inject-content.js），替换不可靠的 browser evaluate 方法 - **v2.1.0**: 添加容错与重试策略（内容落盘、健康检查、自动重试、兜底通知） - **v2.0.0**: 集成 blog-writer 写作方法论，添加中文风格指南，重构工作流 - **v1.3.0**: 添加登录成功自动 Telegram 通知功能 - **v1.2.0**: 完善 Telegram 二维码发送流程，添加完整工作流示例 - **v1.1.0**: 添加扫码登录脚本 `scripts/login.py` - **v1.0.0**: 初始版本，手动 Cookie 注入

path: README.md
content: # CSDN Publisher Skill

🐙 OpenClaw Skill - 写文章并发布到 CSDN

## 功能

- 浏览器自动化发布文章到 CSDN
- 扫码登录（支持通过 Telegram 发送二维码，无需 VNC）
- 集成 blog-writer 写作方法论，产出高质量技术文章

## 安装

将此 skill 放入 OpenClaw workspace 的 `skills/` 目录：

```bash
cd ~/.openclaw/workspace/skills
git clone https://github.com/c4chuan/csdn-publisher.git
```

## 使用

在 OpenClaw 中直接说：
- "帮我写一篇关于 XXX 的文章发到 CSDN"
- "发布这篇文章到 CSDN"

## 文件结构

- `SKILL.md` - Skill 主文件，包含完整的发布流程
- `style-guide-cn.md` - 中文写作风格指南
- `scripts/login.py` - 登录辅助脚本
- `examples/` - 示例文章

## License

MIT


path: examples/2025-02-02-gui-agent-overview.md
content: # GUI Agent 最新动态：从 Claude Computer Use 到 OpenAI Operator

> 最近 GUI Agent 领域动作不断，从 Anthropic 的 Computer Use 到 OpenAI 的 Operator，再到国内各大厂商的跟进，这个赛道正在快速升温。这篇文章整理了 2025 年以来 GUI Agent 的最新进展。

---

## Claude Computer Use：从演示到量产

Anthropic 在 2024 年底推出的 Claude 3.5 Sonnet with Computer Use 可以说是 GUI Agent 领域的里程碑。它不仅能看懂屏幕，还能实际操作电脑——点击、输入、拖拽，几乎像个真人坐在电脑前。

**关键能力：**
- 截图理解 + 坐标定位点击
- 支持多步骤任务拆解
- 失败后能自我纠正

不过说实话，实际用起来还是有不少坑。比如有时候找不到按钮会乱点，或者遇到弹窗就懵圈。但相比之前的方案，已经是质的飞跃。

---

## OpenAI Operator：浏览器里的智能助手

2025 年初，OpenAI 推出了 Operator，专注于浏览器自动化。和 Claude 的通用 Computer Use 不同，Operator 更像是一个专门训练过的"浏览器代理"。

**特点：**
- 基于 CUA（Computer Using Agent）模型
- 内置浏览器环境，安全隔离
- 可以预订机票、填表单、购物结账

OpenAI 特别强调安全性——Operator 在沙盒环境中运行，遇到敏感操作会暂停让用户确认。这种设计虽然慢一点，但至少不会乱花钱 😂

---

## OmniParser：微软的屏幕理解方案

微软研究院的 OmniParser 走了一条不同的路。他们不是训练端到端的 Agent，而是做了一个专门理解 UI 屏幕的模块。

**技术思路：**
- 把屏幕截图解析成结构化的 UI 元素
- 检测可交互区域（按钮、输入框等）
- 生成描述性标签

这种解耦设计的好处是，它可以和任何 LLM 配合，相当于给大模型配了一副"UI 识别眼镜"。

---

## 国内进展：通义千问、智谱跟进了

国内大厂也没闲着：

**阿里通义千问**：推出了自己的 Computer Use 能力，在魔搭社区有演示。能自动操作淘宝下单、查物流，本土化做得不错。

**智谱清言**：GLM-4 支持工具调用 + 视觉理解，虽然主打的不是 GUI Agent，但基础能力已经具备。

**字节跳动**：有传闻在研发类似的 Agent 能力，但还没正式发布。

---

## 开源社区：OSWorld、SeeClick 等

学术界和开源社区也有不少好东西：

- **OSWorld**：可复现的计算机操作环境，用于训练和评测
- **SeeClick**：专门做 UI 元素定位的视觉模型
- **CogAgent**：智源发布的 GUI Agent 模型，开源可玩

这些项目降低了进入门槛，让更多人能参与 GUI Agent 的研发。

---

## 写在最后

GUI Agent 正在从"实验室玩具"走向"实用工具"。虽然目前还谈不上完美，但进步速度惊人。

**值得关注的问题：**
- 安全边界怎么定？Agent 操作出错谁负责？
- 隐私保护——让 AI 看屏幕，敏感信息怎么办？
- 通用性 vs 专用性——做全能型还是垂直场景？

不管怎样，2025 年 GUI Agent 肯定会有更多落地案例。保持关注吧。

---

*本文整理于 2025 年 2 月，信息来源包括官方博客、技术论文和实测体验。*


path: scripts/inject-content.js
content: #!/usr/bin/env node
/**
 * CSDN Content Injector
 * 
 * 通过 CDP (Chrome DevTools Protocol) 将 Markdown 内容注入 CSDN 编辑器。
 * 解决了 browser evaluate 参数长度限制、execCommand 换行符丢失、
 * clipboard API 无权限等问题。
 * 
 * 用法：
 *   node inject-content.js <markdown-file> [--cdp-port 18800]
 * 
 * 前置条件：
 *   - OpenClaw browser (profile=openclaw) 已启动
 *   - 已打开 CSDN 编辑器页面 (https://editor.csdn.net/md)
 *   - npm install ws (或全局安装)
 * 
 * 原理：
 *   1. 通过 CDP /json 接口找到 CSDN 编辑器 tab
 *   2. 用 Runtime.evaluate + JSON.stringify 将内容存入 window 变量（绕过长度限制）
 *   3. 用 editor.textContent = content + dispatchEvent('input') 注入（cledit 编辑器兼容）
 */

const fs = require('fs');
const http = require('http');
const path = require('path');

// Parse args
const args = process.argv.slice(2);
let filePath = null;
let cdpPort = 18800;

for (let i = 0; i < args.length; i++) {
  if (args[i] === '--cdp-port' && args[i + 1]) {
    cdpPort = parseInt(args[i + 1]);
    i++;
  } else if (!filePath) {
    filePath = args[i];
  }
}

if (!filePath) {
  console.error('Usage: node inject-content.js <markdown-file> [--cdp-port 18800]');
  process.exit(1);
}

// Read content, strip frontmatter if present
let content = fs.readFileSync(filePath, 'utf-8');
if (content.startsWith('---')) {
  const endIdx = content.indexOf('---', 3);
  if (endIdx !== -1) {
    content = content.slice(endIdx + 3).replace(/^\n+/, '');
  }
}
console.log(`Content: ${content.length} chars, ${content.split('\n').length} lines`);

// CDP helpers
const cdpUrl = `http://127.0.0.1:${cdpPort}`;

function getTargets() {
  return new Promise((resolve, reject) => {
    http.get(`${cdpUrl}/json`, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => resolve(JSON.parse(data)));
    }).on('error', reject);
  });
}

let ws = null;
let msgId = 0;

function connect(wsUrl) {
  const WebSocket = require('ws');
  return new Promise((resolve, reject) => {
    ws = new WebSocket(wsUrl);
    ws.on('open', resolve);
    ws.on('error', reject);
  });
}

function cdp(method, params = {}) {
  return new Promise((resolve, reject) => {
    const id = ++msgId;
    ws.send(JSON.stringify({ id, method, params }));
    const handler = (msg) => {
      const data = JSON.parse(msg.toString());
      if (data.id === id) {
        ws.removeListener('message', handler);
        resolve(data);
      }
    };
    ws.on('message', handler);
    setTimeout(() => { ws.removeListener('message', handler); reject(new Error('CDP timeout')); }, 15000);
  });
}

function evaluate(expression) {
  return cdp('Runtime.evaluate', { expression, returnByValue: true });
}

async function main() {
  // Find CSDN editor tab
  const targets = await getTargets();
  const page = targets.find(t => t.url.includes('editor.csdn.net'));
  if (!page) {
    console.error('ERROR: CSDN editor tab not found. Make sure https://editor.csdn.net/md is open.');
    process.exit(1);
  }
  console.log('Found tab:', page.url);

  await connect(page.webSocketDebuggerUrl);

  // Step 1: Store content in window variable via CDP
  // JSON.stringify handles all escaping (quotes, newlines, unicode)
  const escaped = JSON.stringify(content);
  let r = await evaluate(`window.__csdnContent = ${escaped}; 'stored ' + window.__csdnContent.length + ' chars'`);
  if (!r.result?.value) {
    console.error('ERROR: Failed to store content:', JSON.stringify(r));
    process.exit(1);
  }
  console.log('Store:', r.result.value);

  // Step 2: Clear editor and inject content
  r = await evaluate(`(function(){
    var editor = document.querySelector('pre.editor__inner');
    if (!editor) return 'ERROR: editor element not found';
    editor.focus();
    document.execCommand('selectAll');
    document.execCommand('delete');
    editor.textContent = window.__csdnContent;
    editor.dispatchEvent(new Event('input', {bubbles: true}));
    return 'injected ' + editor.textContent.length + ' chars';
  })()`);
  console.log('Inject:', r.result?.value || JSON.stringify(r));

  // Step 3: Wait for editor to process, then verify
  await new Promise(r => setTimeout(r, 1500));
  r = await evaluate(`(function(){
    var t = document.body.innerText;
    var m = t.match(/Markdown\\s+(\\d+)\\s+.+?(\\d+)\\s+行/);
    if (m) return 'OK: ' + m[1] + ' words, ' + m[2] + ' lines';
    var i = t.indexOf('Markdown');
    return 'Status: ' + (i >= 0 ? t.substring(i, i + 60) : 'not found');
  })()`);
  console.log('Verify:', r.result?.value || JSON.stringify(r));

  // Cleanup
  await evaluate('delete window.__csdnContent; "cleaned"');
  ws.close();
  console.log('Done.');
}

main().catch(e => {
  console.error('FATAL:', e.message);
  if (ws) ws.close();
  process.exit(1);
});


path: scripts/login.py
content: #!/usr/bin/env python3
"""
CSDN 扫码登录 - 获取 Cookie

使用 Playwright 打开 CSDN 登录页面，截取二维码图片，
用户扫码登录后自动保存 Cookie，并可选发送 Telegram 通知。

用法:
    python login.py login [--timeout 300] [--notify]
    python login.py check

依赖:
    pip install playwright requests
    playwright install chromium
"""

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

# 默认路径
DEFAULT_COOKIE_FILE = Path.home() / ".openclaw/workspace/credentials/csdn-cookie.json"
DEFAULT_QR_OUTPUT = Path.home() / ".openclaw/workspace/credentials/csdn-qr.png"
DEFAULT_CONFIG_FILE = Path.home() / ".openclaw/workspace/credentials/telegram-notify.json"
DEFAULT_PROXY = "http://127.0.0.1:20171"  # 默认代理


def send_telegram_notification(message: str, config_file: str = None):
    """发送 Telegram 通知"""
    import requests
    
    config_path = Path(config_file) if config_file else DEFAULT_CONFIG_FILE
    
    if not config_path.exists():
        print(f"⚠️ Telegram 配置文件不存在: {config_path}", file=sys.stderr)
        return False
    
    try:
        with open(config_path, 'r') as f:
            config = json.load(f)
        
        bot_token = config.get('bot_token')
        chat_id = config.get('chat_id')
        
        if not bot_token or not chat_id:
            print("⚠️ Telegram 配置不完整（需要 bot_token 和 chat_id）", file=sys.stderr)
            return False
        
        # 发送消息
        url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
        resp = requests.post(url, json={
            "chat_id": chat_id,
            "text": message,
            "parse_mode": "HTML"
        }, timeout=10)
        
        if resp.status_code == 200 and resp.json().get('ok'):
            print(f"📤 Telegram 通知已发送", file=sys.stderr)
            return True
        else:
            print(f"⚠️ Telegram 发送失败: {resp.text}", file=sys.stderr)
            return False
    except Exception as e:
        print(f"⚠️ Telegram 通知失败: {e}", file=sys.stderr)
        return False


async def csdn_login(cookie_file: str = None, qr_output: str = None, headless: bool = True, 
                     timeout: int = 120, notify: bool = False, config_file: str = None,
                     proxy: str = None):
    """
    打开 CSDN 登录页面，截取二维码，等待用户扫码登录，保存 Cookie
    
    Args:
        cookie_file: Cookie 保存路径
        qr_output: 二维码图片保存路径
        headless: 是否无头模式
        timeout: 等待登录的超时时间（秒）
        notify: 登录成功后是否发送 Telegram 通知
        config_file: Telegram 配置文件路径
        proxy: 代理服务器地址
    
    Returns:
        dict: {"success": bool, "qr_path": str or None, "message": str}
    """
    from playwright.async_api import async_playwright
    
    cookie_path = Path(cookie_file) if cookie_file else DEFAULT_COOKIE_FILE
    qr_path = Path(qr_output) if qr_output else DEFAULT_QR_OUTPUT
    proxy_server = proxy or os.environ.get('https_proxy') or os.environ.get('HTTPS_PROXY') or DEFAULT_PROXY
    cookie_path.parent.mkdir(parents=True, exist_ok=True)
    qr_path.parent.mkdir(parents=True, exist_ok=True)
    
    print("🚀 启动浏览器...", file=sys.stderr)
    print(f"   代理: {proxy_server}", file=sys.stderr)
    
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=headless,
            args=['--no-sandbox', '--disable-setuid-sandbox'],
            proxy={"server": proxy_server} if proxy_server else None
        )
        context = await browser.new_context()
        page = await context.new_page()
        
        print("🌐 打开 CSDN 登录页面...", file=sys.stderr)
        await page.goto("https://passport.csdn.net/login", timeout=60000)
        await asyncio.sleep(3)
        
        # 尝试切换到扫码登录
        try:
            qr_tab = await page.query_selector('text=扫码登录')
            if qr_tab:
                await qr_tab.click()
                await asyncio.sleep(2)
                print("📱 已切换到扫码登录", file=sys.stderr)
        except Exception as e:
            print(f"⚠️ 切换扫码登录失败: {e}", file=sys.stderr)
        
        # 截取二维码图片
        qr_captured = False
        try:
            qr_selectors = [
                'img[src*="qrcode"]',
                'img[src*="qr"]',
                '.qr-img',
                '.qrcode',
                '[class*="qr"] img',
                'canvas',
            ]
            
            for selector in qr_selectors:
                qr_element = await page.query_selector(selector)
                if qr_element:
                    await qr_element.screenshot(path=str(qr_path))
                    qr_captured = True
                    print(f"📸 二维码已保存: {qr_path}", file=sys.stderr)
                    break
            
            if not qr_captured:
                login_box = await page.query_selector('.main-login, .login-box, [class*="login"]')
                if login_box:
                    await login_box.screenshot(path=str(qr_path))
                    qr_captured = True
                    print(f"📸 登录区域已截图: {qr_path}", file=sys.stderr)
                else:
                    await page.screenshot(path=str(qr_path))
                    qr_captured = True
                    print(f"📸 整页截图已保存: {qr_path}", file=sys.stderr)
        except Exception as e:
            print(f"⚠️ 截图失败: {e}", file=sys.stderr)
        
        if qr_captured:
            print(f"QR_PATH:{qr_path}")
        
        # 等待登录成功
        print(f"⏳ 等待扫码登录（最多 {timeout} 秒）...", file=sys.stderr)
        
        logged_in = False
        for i in range(timeout):
            current_url = page.url
            
            if "passport.csdn.net/login" not in current_url:
                logged_in = True
                break
            
            try:
                user_info = await page.query_selector('.user-info, .avatar, [class*="user"]')
                if user_info:
                    logged_in = True
                    break
            except:
                pass
            
            await asyncio.sleep(1)
            if (i + 1) % 15 == 0:
                print(f"   已等待 {i + 1} 秒...", file=sys.stderr)
        
        if not logged_in:
            print("❌ 登录超时", file=sys.stderr)
            if notify:
                send_telegram_notification("❌ CSDN 登录超时，请重试", config_file)
            await browser.close()
            return {"success": False, "qr_path": str(qr_path) if qr_captured else None, "message": "登录超时"}
        
        print("✅ 登录成功！", file=sys.stderr)
        
        # 访问 CSDN 主站以获取完整 Cookie
        await page.goto("https://www.csdn.net/")
        await asyncio.sleep(2)
        
        # 保存 Cookie
        cookies = await context.cookies()
        
        storage_state = await context.storage_state()
        with open(cookie_path, 'w', encoding='utf-8') as f:
            json.dump(storage_state, f, ensure_ascii=False, indent=2)
        
        print(f"💾 Cookie 已保存到: {cookie_path}", file=sys.stderr)
        
        cookie_str_path = cookie_path.with_suffix('.txt')
        cookie_str = '; '.join([f"{c['name']}={c['value']}" for c in cookies if 'csdn' in c.get('domain', '')])
        with open(cookie_str_path, 'w', encoding='utf-8') as f:
            f.write(cookie_str)
        print(f"💾 Cookie 字符串已保存到: {cookie_str_path}", file=sys.stderr)
        
        # 发送 Telegram 通知
        if notify:
            send_telegram_notification("✅ CSDN 登录成功！Cookie 已保存，可以继续发布文章了。", config_file)
        
        await browser.close()
        return {"success": True, "qr_path": str(qr_path) if qr_captured else None, "message": "登录成功"}


async def check_cookie(cookie_file: str = None):
    """检查 Cookie 是否有效"""
    from playwright.async_api import async_playwright
    
    cookie_path = Path(cookie_file) if cookie_file else DEFAULT_COOKIE_FILE
    
    if not cookie_path.exists():
        print(f"❌ Cookie 文件不存在: {cookie_path}", file=sys.stderr)
        return False
    
    print("🔍 检查 Cookie 有效性...", file=sys.stderr)
    
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=['--no-sandbox', '--disable-setuid-sandbox']
        )
        
        with open(cookie_path, 'r', encoding='utf-8') as f:
            storage_state = json.load(f)
        
        context = await browser.new_context(storage_state=storage_state)
        page = await context.new_page()
        
        await page.goto("https://editor.csdn.net/md")
        await asyncio.sleep(3)
        
        current_url = page.url
        page_content = await page.content()
        
        if "passport.csdn.net" in current_url or "login" in current_url.lower():
            print("❌ Cookie 已失效（被重定向到登录页）", file=sys.stderr)
            await browser.close()
            return False
        
        if "登录" in page_content and "扫码" in page_content:
            print("❌ Cookie 已失效（页面显示登录框）", file=sys.stderr)
            await browser.close()
            return False
        
        print("✅ Cookie 有效", file=sys.stderr)
        await browser.close()
        return True


def main():
    parser = argparse.ArgumentParser(description='CSDN 扫码登录工具')
    subparsers = parser.add_subparsers(dest='command')
    
    # login 命令
    login_parser = subparsers.add_parser('login', help='扫码登录获取 Cookie')
    login_parser.add_argument('--cookie-file', '-f', help='Cookie 保存路径')
    login_parser.add_argument('--qr-output', '-q', help='二维码图片保存路径')
    login_parser.add_argument('--no-headless', action='store_true', help='显示浏览器窗口')
    login_parser.add_argument('--timeout', '-t', type=int, default=120, help='登录超时时间（秒）')
    login_parser.add_argument('--notify', '-n', action='store_true', help='登录成功后发送 Telegram 通知')
    login_parser.add_argument('--config', '-c', help='Telegram 配置文件路径')
    login_parser.add_argument('--proxy', '-p', help='代理服务器地址')
    
    # check 命令
    check_parser = subparsers.add_parser('check', help='检查 Cookie 是否有效')
    check_parser.add_argument('--cookie-file', '-f', help='Cookie 文件路径')
    
    # setup-notify 命令
    setup_parser = subparsers.add_parser('setup-notify', help='配置 Telegram 通知')
    setup_parser.add_argument('--bot-token', required=True, help='Telegram Bot Token')
    setup_parser.add_argument('--chat-id', required=True, help='Telegram Chat ID')
    setup_parser.add_argument('--config', '-c', help='配置文件保存路径')
    
    args = parser.parse_args()
    
    if args.command == 'login':
        result = asyncio.run(csdn_login(
            cookie_file=args.cookie_file,
            qr_output=args.qr_output,
            headless=not args.no_headless,
            timeout=args.timeout,
            notify=args.notify,
            config_file=args.config,
            proxy=args.proxy
        ))
        if result["success"]:
            print("LOGIN_SUCCESS")
        else:
            print(f"LOGIN_FAILED:{result['message']}")
        sys.exit(0 if result["success"] else 1)
    
    elif args.command == 'check':
        valid = asyncio.run(check_cookie(cookie_file=args.cookie_file))
        sys.exit(0 if valid else 1)
    
    elif args.command == 'setup-notify':
        config_path = Path(args.config) if args.config else DEFAULT_CONFIG_FILE
        config_path.parent.mkdir(parents=True, exist_ok=True)
        
        config = {
            "bot_token": args.bot_token,
            "chat_id": args.chat_id
        }
        
        with open(config_path, 'w') as f:
            json.dump(config, f, indent=2)
        
        print(f"✅ Telegram 配置已保存到: {config_path}")
        
        # 测试发送
        if send_telegram_notification("🔔 CSDN Publisher 通知已配置成功！", str(config_path)):
            print("✅ 测试消息发送成功")
        else:
            print("⚠️ 测试消息发送失败，请检查配置")
    
    else:
        parser.print_help()


if __name__ == '__main__':
    main()


path: scripts/notion-check-duplicate.sh
content: #!/bin/bash
# Check if news already exists in Notion database (by title or URL)
# Usage: notion-check-duplicate.sh "标题" ["来源URL"]
# Returns: "duplicate" if found, "new" if not found

export https_proxy=http://127.0.0.1:20171
export http_proxy=http://127.0.0.1:20171

NOTION_KEY="${NOTION_KEY:-ntn_YOUR_KEY_HERE}"
DATABASE_ID="${NOTION_DATABASE_ID:-YOUR_DATABASE_ID_HERE}"

TITLE="$1"
SOURCE_URL="$2"

# Build filter: match by title OR by URL
if [ -n "$SOURCE_URL" ]; then
  FILTER=$(cat <<EOF
{
  "filter": {
    "or": [
      {"property": "名称", "title": {"equals": "$TITLE"}},
      {"property": "来源", "url": {"equals": "$SOURCE_URL"}}
    ]
  },
  "page_size": 1
}
EOF
)
else
  FILTER=$(cat <<EOF
{
  "filter": {
    "property": "名称",
    "title": {"equals": "$TITLE"}
  },
  "page_size": 1
}
EOF
)
fi

RESULT=$(curl -s -X POST "https://api.notion.com/v1/databases/$DATABASE_ID/query" \
  -H "Authorization: Bearer $NOTION_KEY" \
  -H "Notion-Version: 2022-06-28" \
  -H "Content-Type: application/json" \
  -d "$FILTER")

COUNT=$(echo "$RESULT" | jq -r '.results | length')

if [ "$COUNT" -gt 0 ]; then
  echo "duplicate"
else
  echo "new"
fi


path: scripts/notion-query-recent.sh
content: #!/bin/bash
# Query recent news from Notion database
# Usage: notion-query-recent.sh [days] 
# Default: last 7 days. Output: title | url | date (one per line)

export https_proxy=http://127.0.0.1:20171
export http_proxy=http://127.0.0.1:20171

NOTION_KEY="${NOTION_KEY:-ntn_YOUR_KEY_HERE}"
DATABASE_ID="${NOTION_DATABASE_ID:-YOUR_DATABASE_ID_HERE}"

DAYS="${1:-7}"
SINCE=$(date -d "-${DAYS} days" +%Y-%m-%d 2>/dev/null || date -v-${DAYS}d +%Y-%m-%d)

FILTER=$(cat <<EOF
{
  "filter": {
    "property": "日期",
    "date": {"on_or_after": "$SINCE"}
  },
  "page_size": 100,
  "sorts": [{"property": "日期", "direction": "descending"}]
}
EOF
)

curl -s -X POST "https://api.notion.com/v1/databases/$DATABASE_ID/query" \
  -H "Authorization: Bearer $NOTION_KEY" \
  -H "Notion-Version: 2022-06-28" \
  -H "Content-Type: application/json" \
  -d "$FILTER" | jq -r '.results[] | "\(.properties["名称"].title[0].text.content) | \(.properties["来源"].url // "无") | \(.properties["日期"].date.start)"'


path: style-guide-cn.md
content: # 中文技术博客写作风格指南

> 本指南基于 blog-writer skill 的写作方法论，针对中文技术博客进行本地化。

---

## 核心原则

### 1. 不要 AI 味

AI 生成的文章有明显的"味道"：
- 过度使用连接词（首先、其次、最后）
- 每段都有总结
- 语言过于正式和书面化
- 缺乏个人观点和情绪

**我们要的是**：像跟朋友聊天一样写技术文章。

### 2. 有观点、有态度

不要写"客观中立"的流水账。好的技术文章应该有：
- 明确的立场（这个工具好用/不好用）
- 个人判断（我推荐/不推荐）
- 真实感受（踩坑的痛苦、解决后的爽感）

### 3. 具体胜过抽象

❌ "这个工具可以提高开发效率"
✅ "用了这个工具后，我每天能省出 30 分钟摸鱼时间"

---

## 开头模式

### 好的开头示例

**个人经历型：**
- "搞了两个小时，终于把这个坑填上了。记录一下，免得下次又踩。"
- "说实话，我一开始是拒绝用 Docker 的。直到上周部署炸了..."
- "作为一个写了 5 年 Python 的人，我可以负责任地说：这个库真的香。"

**观点输出型：**
- "我知道这个观点可能有争议，但我还是要说：XXX 被高估了。"
- "用了三个月 XXX 之后，我想聊聊它的真实体验。"
- "很多教程都没告诉你的一件事是..."

**问题驱动型：**
- "你有没有遇到过这种情况：XXX？我有，而且被折腾得够呛。"
- "如果你也在纠结 A 和 B 怎么选，这篇文章可能对你有帮助。"

### 避免的开头

❌ "随着人工智能的快速发展..."
❌ "在当今数字化时代..."
❌ "众所周知，XXX 是一个重要的技术..."
❌ "本文将介绍..."

---

## 结构模板

```markdown
# [直接、有态度的标题]

[开头：1-2句话，抛出核心观点或问题，吸引读者继续看]

### 背景 / 问题是什么

[简短说明遇到了什么问题，或者为什么要写这篇文章]
[2-3个短段落即可]

### 过程 / 怎么解决的

[具体的操作步骤、代码、截图]
[这部分可以长一些，但要分小节]

#### 小节1
[具体内容]

#### 小节2
[具体内容]

### 踩坑记录 / 注意事项

[遇到的问题和解决方案]
[这部分读者最爱看]

### 写在最后

[个人感想、总结、或者对读者的建议]
[不要写"综上所述"，写点真实的想法]
```

---

## 标题技巧

### 好标题的特点

1. **有数字**
   - "3 个坑让我搞了 2 小时"
   - "5 分钟搞定 XXX"
   - "我用 XXX 的 100 天"

2. **有冲突/转折**
   - "XXX 官方文档没告诉你的事"
   - "为什么我放弃了 XXX"
   - "从入门到放弃，再到真香"

3. **有悬念**
   - "没想到最后是这样解决的"
   - "一个让我困惑了三天的 Bug"
   - "这个功能藏得太深了"

4. **有态度**
   - "XXX 真的有必要吗？"
   - "别再用 XXX 了"
   - "XXX：被低估的神器"

### 避免的标题

❌ "浅谈 XXX"
❌ "XXX 技术详解"
❌ "关于 XXX 的一些思考"
❌ "XXX 入门教程"（太普通）

---

## 语言风格

### 推荐用词

| 场景 | 推荐 | 避免 |
|------|------|------|
| 表达观点 | "说实话"、"坦白讲"、"我觉得" | "笔者认为"、"不难发现" |
| 遇到问题 | "踩了个坑"、"折腾了半天"、"卡住了" | "遇到了一些困难" |
| 解决问题 | "搞定了"、"终于跑通了"、"真香" | "成功解决了该问题" |
| 推荐 | "强烈推荐"、"真的好用"、"谁用谁知道" | "值得一试" |
| 不推荐 | "别用"、"坑太多"、"不值得" | "不太建议使用" |
| 转折 | "但是"、"不过"、"话说回来" | "然而"、"但值得注意的是" |

### 避免的表达

❌ "首先...其次...最后..."
❌ "值得注意的是"
❌ "综上所述"
❌ "不难发现"
❌ "众所周知"
❌ "笔者"（直接用"我"）

### 可以用的口语化表达

- "说白了就是..."
- "简单来说..."
- "划重点："
- "敲黑板："
- "血泪教训："
- "真香警告："

---

## 段落和排版

### 段落长度

- **每段 2-4 句话**，最多不超过 5 句
- **单句成段**用于强调重点
- **每 150-250 字**加一个小标题

### 小标题使用

- 用 `###` 作为主要小标题
- 用 `####` 作为次级小标题
- 小标题要**具体**，不要太抽象

✅ "### 安装 Docker"
❌ "### 第一步"

✅ "### 为什么选择 FastAPI"
❌ "### 技术选型"

### 代码块

- 代码块前后要有说明
- 长代码要加注释
- 关键代码可以用 `行内代码` 高亮

### 列表使用

- 列表用于**真正的列举**，不要用于正文
- 3-7 项最佳，太长就分组
- 每项保持简短

---

## 个人经历融入

### 什么时候用

- 说明为什么要研究这个话题
- 分享踩坑经历
- 给出推荐/不推荐的理由

### 怎么写

**包含具体细节：**
- 时间："上周"、"用了三个月"
- 数字："试了 5 种方案"、"花了 2 小时"
- 场景："在部署到生产环境时"

**连接到主题：**
个人经历要服务于文章主题，不要跑题。

### 示例

> 上周部署一个 Python 项目，死活跑不起来。查了两个小时，最后发现是 Python 版本的问题。这件事让我意识到，环境管理真的太重要了。所以今天想聊聊 pyenv 这个工具。

---

## 结尾模式

### 好的结尾

**行动号召型：**
- "如果你也遇到类似的问题，不妨试试这个方案。"
- "有问题欢迎评论区交流。"

**个人感想型：**
- "折腾了这么久，总算搞明白了。希望这篇文章能帮到你。"
- "虽然过程很痛苦，但学到了不少东西。"

**前瞻型：**
- "下一篇打算写 XXX，感兴趣的可以关注一下。"
- "这个领域还有很多值得探索的地方。"

### 避免的结尾

❌ "综上所述，本文介绍了..."
❌ "希望本文对读者有所帮助。"
❌ "以上就是本文的全部内容。"

---

## 新闻资讯类文章

写新闻汇总、行业日报、资讯盘点等文章时，有额外要求：

### 必须附带原文链接

每条新闻都要有跳转链接，方便读者查看原文详情。

**格式选择：**

1. **标题即链接**（推荐）
   ```markdown
   ### [特斯拉宣布停产 Model S/X](https://example.com/news/123)
   ```

2. **末尾标注**
   ```markdown
   ### 特斯拉宣布停产 Model S/X
   
   新闻内容...
   
   👉 [查看原文](https://example.com/news/123)
   ```

3. **来源标注**（链接不可用时）
   ```markdown
   ### 特斯拉宣布停产 Model S/X
   
   新闻内容...（来源：36氪）
   ```

### 新闻摘要写法

- 用自己的话概括，不要直接复制原文
- 加入简短点评，体现个人观点
- 保持每条新闻 50-150 字

---

## 检查清单

发布前检查：

- [ ] 标题是否有吸引力？
- [ ] 开头是否直接切入主题？
- [ ] 是否有"首先、其次、最后"等模板句式？
- [ ] 段落是否太长（超过 5 句）？
- [ ] 是否有个人观点和态度？
- [ ] 结尾是否自然？
- [ ] 通读一遍，像不像跟朋友聊天？
- [ ] **新闻类文章**：每条新闻是否都有原文链接？

---

## 参考资源

- `examples/` 目录中的示例文章
- `skills/blog-writer/` 中的英文风格指南（可参考结构）
- 优秀的中文技术博客：阮一峰、酷壳、美团技术博客

---

*最后更新：2025-02-02*


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