diff --git a/.gitignore b/.gitignore index 92ebe8b..e981c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ dist/ .env server +Wechatsync +wechat-article-detection diff --git a/README.md b/README.md index a2fc013..5bf5935 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ https://code-box.fun/docs ## 介绍 -本浏览器插件可以用于CSDN/知乎/脚本之家/博客园/博客园/51CTO博客/php中文网/掘金/微信等网站,一键下载文章成html或markdown文件;实现无需登录一键复制代码;支持选中代码;或者代码右上角按钮的一键复制;解除关注博主即可阅读全文提示;去除登录弹窗;去除跳转APP弹窗. +本浏览器插件可以用于CSDN/知乎/脚本之家/博客园/博客园/51CTO博客/php中文网/掘金/微信等网站,一键下载文章成html或markdown文件;实现无需登录一键复制代码;支持选中代码;或者代码右上角按钮的一键复制;解除关注博主即可阅读全文提示;去除登录弹窗;去除跳转APP弹窗。 + +同时支持**文章同步**:一键把当前文章同步到微信公众号、知乎、微博、小红书、掘金、CSDN、头条号等 29+ 主流平台(默认保存为草稿),支持 WordPress、Typecho 等自建站。 ## 安装 @@ -68,6 +70,94 @@ https://code-box.fun/docs ![img-1](https://raw.githubusercontent.com/027xiguapi/code-box/main/public/config.jpg) +### 文章同步(29+ 主流平台)🆕 + +参考 [Wechatsync 文章同步助手](https://github.com/wechatsync/Wechatsync) 的适配器体系实现,一键把当前页面文章**同步到 29+ 主流平台**,默认保存为草稿,确认无误后再发布。 + +#### 特点 + +- **用自己的登录态**:直接使用浏览器中已登录的 Cookie,无需额外授权,无需输入密码 +- **调用平台官方接口**:与你在网页编辑器里手动发布完全等价 +- **数据不离开浏览器**:所有请求从你的浏览器直接发往各平台,没有中间服务器 +- **草稿优先**:默认只保存草稿,不会自动发布 +- **图片自动转存**:正文图片自动上传到目标平台 +- **按平台适配内容**:不同平台对 HTML 结构要求不同(是否保留链接、标签是否压缩、代码块格式等),同步前会为每个平台分别预处理 +- **自建站支持**:WordPress、Typecho、MetaWeblog + +#### 使用方式 + +1. 打开任意文章页面(微信公众号文章、知乎、掘金、CSDN、博客园等都可以) +2. 三个入口任选其一: + - 点击扩展图标 → `文章同步` + - 文章页左侧悬浮面板 → `文章同步` + - 微信公众号文章页左侧面板 → `文章同步` +3. 在同步面板中确认提取到的文章 → 勾选目标平台(面板会显示各平台登录状态)→ `开始同步` +4. 同步完成后可在面板中查看每个平台的状态、草稿链接和同步历史 + +#### 支持平台 + +| 平台 | ID | 类型 | 同步方式 | +|-----|-----|-----|-----| +| 微信公众号 | weixin | 主流自媒体 | 接口(草稿) | +| 知乎 | zhihu | 主流自媒体 | 接口(草稿) | +| 微博 | weibo | 主流自媒体 | 接口(草稿) | +| 小红书 | xiaohongshu | 主流自媒体 | 编辑器辅助(实验) | +| 掘金 | juejin | 技术社区 | 接口(草稿) | +| CSDN | csdn | 技术社区 | 接口(草稿) | +| 简书 | jianshu | 通用 | 接口(草稿) | +| 头条号 | toutiao | 通用 | 接口(草稿) | +| 抖音图文 | douyin | 主流自媒体 | 编辑器辅助(实验) | +| B站专栏 | bilibili | 通用 | 接口(草稿) | +| 百家号 | baijiahao | 通用 | 接口(草稿) | +| 语雀 | yuque | 技术社区 | 接口(草稿) | +| 豆瓣 | douban | 通用 | 接口(草稿) | +| 搜狐号 | sohu | 通用 | 接口(草稿) | +| 雪球 | xueqiu | 财经 | 接口(草稿) | +| 人人都是产品经理 | woshipm | 产品 | 接口(草稿) | +| 大鱼号 | dayu | 通用 | 接口(草稿) | +| 一点号 | yidian | 通用 | 接口(草稿) | +| 51CTO | 51cto | 技术社区 | 接口(草稿) | +| 慕课网 | imooc | 技术社区 | 接口(草稿) | +| 开源中国 | oschina | 技术社区 | 接口(草稿) | +| SegmentFault | segmentfault | 技术社区 | 接口(草稿) | +| 博客园 | cnblogs | 技术社区 | 接口(草稿) | +| 搜狐焦点 | sohufocus | 房产 | 接口(草稿) | +| X (Twitter) | x | 海外 | 编辑器辅助(实验) | +| 东方财富 | eastmoney | 财经 | 接口(草稿) | +| 什么值得买 | smzdm | 通用 | 编辑器辅助(实验) | +| 网易号 | netease | 通用 | 编辑器辅助(实验) | +| WordPress | wordpress | 建站/CMS | 接口(草稿) | +| Typecho | typecho | 建站/CMS | 接口(草稿) | +| Hexo | zip-download | 建站/CMS | 通过 Markdown 打包下载 | +| Hugo | zip-download | 建站/CMS | 通过 Markdown 打包下载 | + +> 说明:「编辑器辅助」表示该平台未公开草稿接口(或接口需要浏览器端签名 / 风控校验),扩展会打开该平台的官方编辑器并自动填充标题与正文,你确认后保存草稿或发布即可。 + +#### 自建站(WordPress / Typecho / MetaWeblog) + +在同步面板的「自建站账号」中填写站点地址、用户名和密码(密码仅保存在本地浏览器中),添加后即可作为同步目标。WordPress 建议使用「应用密码」。 + +#### 代码结构(开发者) + +``` +sync/ +├── types.ts # 公共类型(Article / SyncResult / PlatformMeta) +├── lib/ # HTML→Markdown、图片解析、CSS 内联、AWS4 签名、日志 +├── runtime/ # 运行时抽象 + Chrome 扩展实现(fetch / cookies / headerRules) +├── adapters/ +│ ├── code-adapter.ts # 适配器基类(请求、图片上传、Header 规则、HTML 清理) +│ ├── editor-assist.ts # 编辑器辅助基类(半自动同步) +│ ├── registry.ts # 适配器注册中心 +│ ├── platforms/ # 29 个平台适配器 +│ └── cms/ # WordPress / MetaWeblog(Typecho) +└── extension/ # 文章提取器、平台内容预处理、同步引擎 +contents/sync.ts # 内容脚本:文章提取 + 按平台预处理 +tabs/sync.tsx # 文章同步面板界面 +``` + +新增平台:在 `sync/adapters/platforms/` 下实现 `CodeAdapter` 子类(需要浏览器操作的平台继承 `EditorAssistAdapter`),然后在 `sync/adapters/platforms/index.ts` 的 `ALL_ADAPTERS` 中注册即可。 + + ### 自定义 - 插入自定义样式(css代码) @@ -176,6 +266,7 @@ https://code-box.fun/docs 4. [开箱即用的web打印和下载](https://juejin.cn/post/7412672713376497727) 5. [导出微信公众号文章为PDF](https://greasyfork.org/en/scripts/510683-%E5%AF%BC%E5%87%BA%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7%E6%96%87%E7%AB%A0%E4%B8%BApdf/code) 6. [微信公众号推文图片一键下载](https://greasyfork.org/zh-CN/scripts/40583-%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7%E6%8E%A8%E6%96%87%E5%9B%BE%E7%89%87%E4%B8%80%E9%94%AE%E4%B8%8B%E8%BD%BD/code) +7. [Wechatsync 文章同步助手](https://github.com/wechatsync/Wechatsync)(文章同步适配器体系参考) ## 隐私政策 diff --git a/README_EN.md b/README_EN.md index d0b3702..7b18278 100644 --- a/README_EN.md +++ b/README_EN.md @@ -10,6 +10,8 @@ https://www.code-box.fun/ This browser plugin can be used on websites like CSDN/Zhihu/Script Home/Blog Garden/51CTO Blog/PHP Chinese Network/Juejin/WeChat, etc., to download articles as HTML or Markdown files with one click; to copy code without logging in; to support selected code; or to copy code with one click from the button in the upper right corner of the code; to remove the prompt to follow the blogger to read the full text; to remove the login pop-up window; to remove the pop-up window to jump to the APP. +It also supports **Article Sync**: sync the current article to 29+ mainstream platforms (WeChat, Zhihu, Weibo, Xiaohongshu, Juejin, CSDN, Toutiao, ...) with one click, saved as a draft by default, plus self-hosted blogs such as WordPress and Typecho. + ## Installation ### Installation Method 1: Plugin Store (Recommended) @@ -64,6 +66,94 @@ Directly download and install: ![img-1](https://raw.githubusercontent.com/027xiguapi/code-box/main/public/config.jpg) +### Article Sync (29+ Platforms) 🆕 + +Ported from the adapter architecture of [Wechatsync](https://github.com/wechatsync/Wechatsync). One click to sync the current article to **29+ mainstream platforms**, saved as a draft by default. + +#### Features + +- **Uses your own sessions**: reads the cookies already in your browser, no extra authorization, no password +- **Calls the official web APIs**: exactly the same endpoints the platforms' own editors use +- **Your data never leaves the browser**: all requests go directly from your browser to the platform, no middle server +- **Draft first**: articles are saved as drafts by default, never auto-published +- **Automatic image transfer**: images in the article are re-uploaded to the target platform +- **Per-platform content adaptation**: platforms differ in what HTML they accept (links kept or stripped, whitespace compacted, code block format...), so the content is preprocessed separately for each target +- **Self-hosted blogs**: WordPress, Typecho, MetaWeblog + +#### Usage + +1. Open any article page (WeChat, Zhihu, Juejin, CSDN, Cnblogs, ...) +2. Open the sync panel from any of these entries: + - Click the extension icon → `Sync Articles` + - The floating panel on the article page → `文章同步` / `Sync Articles` + - The panel on WeChat article pages → `文章同步` +3. Check the extracted article, select the target platforms (the panel shows the login status of each one), then click `开始同步` (Start sync) +4. The panel reports per-platform status, draft links and keeps a sync history + +#### Supported platforms + +| Platform | ID | Type | Sync mode | +|-----|-----|-----|-----| +| WeChat Official Account | weixin | Mainstream media | API (draft) | +| Zhihu | zhihu | Mainstream media | API (draft) | +| Weibo | weibo | Mainstream media | API (draft) | +| Xiaohongshu | xiaohongshu | Mainstream media | Editor assist (beta) | +| Juejin | juejin | Tech community | API (draft) | +| CSDN | csdn | Tech community | API (draft) | +| Jianshu | jianshu | General | API (draft) | +| Toutiao | toutiao | General | API (draft) | +| Douyin (image-text) | douyin | Mainstream media | Editor assist (beta) | +| Bilibili column | bilibili | General | API (draft) | +| Baijiahao | baijiahao | General | API (draft) | +| Yuque | yuque | Tech community | API (draft) | +| Douban | douban | General | API (draft) | +| Sohu | sohu | General | API (draft) | +| Xueqiu | xueqiu | Finance | API (draft) | +| Woshipm | woshipm | Product | API (draft) | +| Dayu | dayu | General | API (draft) | +| Yidian | yidian | General | API (draft) | +| 51CTO | 51cto | Tech community | API (draft) | +| Imooc | imooc | Tech community | API (draft) | +| OSChina | oschina | Tech community | API (draft) | +| SegmentFault | segmentfault | Tech community | API (draft) | +| Cnblogs | cnblogs | Tech community | API (draft) | +| Sohu Focus | sohufocus | Real estate | API (draft) | +| X (Twitter) | x | Overseas | Editor assist (beta) | +| Eastmoney | eastmoney | Finance | API (draft) | +| SMZDM | smzdm | General | Editor assist (beta) | +| Netease | netease | General | Editor assist (beta) | +| WordPress | wordpress | CMS | API (draft) | +| Typecho | typecho | CMS | API (draft) | +| Hexo | zip-download | CMS | Markdown package download | +| Hugo | zip-download | CMS | Markdown package download | + +> Note: "Editor assist" means the platform does not expose a public draft API (or the API requires browser-side signing / risk-control checks). The extension opens the platform's official editor and prefills the title and content, then you confirm to save the draft or publish. + +#### Self-hosted blogs (WordPress / Typecho / MetaWeblog) + +Fill in the site URL, username and password under "self-hosted accounts" in the sync panel (the password is stored only in your local browser). WordPress users should use an Application Password. + +#### Code structure (developers) + +``` +sync/ +├── types.ts # shared types (Article / SyncResult / PlatformMeta) +├── lib/ # HTML→Markdown, image parsing, CSS inlining, AWS4 signing, logger +├── runtime/ # runtime abstraction + Chrome extension implementation +├── adapters/ +│ ├── code-adapter.ts # adapter base class (requests, image upload, header rules) +│ ├── editor-assist.ts # editor-assist base class (semi-automatic sync) +│ ├── registry.ts # adapter registry +│ ├── platforms/ # 29 platform adapters +│ └── cms/ # WordPress / MetaWeblog (Typecho) +└── extension/ # article extractor, per-platform preprocessing, sync engine +contents/sync.ts # content script: extraction + preprocessing +tabs/sync.tsx # the sync panel UI +``` + +To add a platform: implement a `CodeAdapter` subclass in `sync/adapters/platforms/` (or extend `EditorAssistAdapter` when the platform needs the browser editor), then register it in `ALL_ADAPTERS` in `sync/adapters/platforms/index.ts`. + + ### Customization - Insert custom styles (CSS code) @@ -172,6 +262,7 @@ Directly download and install: 4. [Out-of-the-box web printing and downloading](https://juejin.cn/post/7412672713376497727) 5. [Export WeChat Official Account Articles to PDF](https://greasyfork.org/en/scripts/510683-%E5%AF%BC%E5%87%BA%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7%E6%96%87%E7%AB%A0%E4%B8%BApdf/code) 6. [One-click Download of WeChat Official Account Article Images](https://greasyfork.org/zh-CN/scripts/40583-%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7%E6%8E%A8%E6%96%87%E5%9B%BE%E7%89%87%E4%B8%80%E9%94%AE%E4%B8%8B%E8%BD%BD/code) +7. [Wechatsync](https://github.com/wechatsync/Wechatsync) (reference for the article sync adapter system) ## Privacy Policy diff --git a/background/index.ts b/background/index.ts index 924a584..cd33eab 100644 --- a/background/index.ts +++ b/background/index.ts @@ -139,6 +139,15 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { console.log("[Background] 收到聊天响应:", request.data?.content?.slice(0, 80)) } + // 打开文章同步页面(带上来源标签页 ID,便于自动提取文章) + if (request.action === "openSyncPage") { + const tabId = sender.tab?.id + const query = tabId ? `?tabId=${tabId}` : "" + chrome.tabs.create({ url: chrome.runtime.getURL(`tabs/sync.html${query}`) }) + sendResponse({ success: true }) + return true + } + // RouterPark 聊天标签注册 if (request.action === "registerChatTab") { if (sender.tab?.id) { diff --git a/component/sync/CmsManager.tsx b/component/sync/CmsManager.tsx new file mode 100644 index 0000000..909a1f5 --- /dev/null +++ b/component/sync/CmsManager.tsx @@ -0,0 +1,217 @@ +import { DeleteOutlined, PlusOutlined, ApiOutlined } from "@ant-design/icons" +import { + Button, + Form, + Input, + message, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag +} from "antd" +import { useEffect, useState } from "react" + +import { + CMS_TYPES, + checkCmsAuth, + getCmsAccounts, + removeCmsAccount, + saveCmsAccount, + type CmsAccount +} from "~sync/extension/engine" + +export interface CmsManagerProps { + /** 账号列表变化时通知父组件 */ + onChange?: (accounts: CmsAccount[]) => void +} + +interface CmsFormValues { + type: CmsAccount["type"] + name: string + url: string + username: string + password: string +} + +/** + * 自建站(WordPress / Typecho / MetaWeblog)账号管理 + */ +export default function CmsManager({ onChange }: CmsManagerProps) { + const [accounts, setAccounts] = useState([]) + const [status, setStatus] = useState< + Record + >({}) + const [open, setOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [form] = Form.useForm() + + async function refresh() { + const list = await getCmsAccounts() + setAccounts(list) + onChange?.(list) + } + + useEffect(() => { + refresh() + }, []) + + async function handleAdd() { + try { + const values = await form.validateFields() + setSaving(true) + + const account = await saveCmsAccount( + { + type: values.type, + name: values.name || `${values.type} 站点`, + url: values.url.trim(), + username: values.username + }, + values.password + ) + + message.success("账号已保存") + setOpen(false) + form.resetFields() + await refresh() + + await check(account) + } catch (error) { + if ((error as { errorFields?: unknown }).errorFields) return + message.error((error as Error).message) + } finally { + setSaving(false) + } + } + + async function check(account: CmsAccount) { + setStatus((prev) => ({ ...prev, [account.id]: { checking: true } })) + const result = await checkCmsAuth(account) + setStatus((prev) => ({ + ...prev, + [account.id]: { checking: false, ok: result.isAuthenticated, error: result.error } + })) + } + + async function handleRemove(id: string) { + await removeCmsAccount(id) + message.success("账号已删除") + await refresh() + } + + return ( +
+ + + + 支持 WordPress、Typecho 及兼容 MetaWeblog 的博客,同步为草稿 + + + + {accounts.length > 0 && ( + + size="small" + rowKey="id" + pagination={false} + dataSource={accounts} + columns={[ + { title: "名称", dataIndex: "name", width: 160 }, + { + title: "类型", + dataIndex: "type", + width: 130, + render: (type: string) => + CMS_TYPES.find((t) => t.value === type)?.label || type + }, + { title: "站点", dataIndex: "url", ellipsis: true }, + { title: "用户名", dataIndex: "username", width: 120 }, + { + title: "状态", + width: 120, + render: (_, account) => { + const item = status[account.id] + if (!item || item.checking) return 未检测 + return item.ok ? ( + 连接正常 + ) : ( + + 连接失败 + + ) + } + }, + { + title: "操作", + width: 140, + render: (_, account) => ( + + + handleRemove(account.id)}> +
+ ) +} diff --git a/component/sync/PlatformPicker.tsx b/component/sync/PlatformPicker.tsx new file mode 100644 index 0000000..6674f53 --- /dev/null +++ b/component/sync/PlatformPicker.tsx @@ -0,0 +1,172 @@ +import { ReloadOutlined } from "@ant-design/icons" +import { Avatar, Button, Checkbox, Empty, Space, Tag, Tooltip, Typography } from "antd" + +import type { PlatformAuthItem } from "~sync/extension/engine" + +const { Text } = Typography + +export interface PlatformPickerProps { + platforms: PlatformAuthItem[] + selected: string[] + loading: boolean + onChange: (selected: string[]) => void + onRefresh: () => void +} + +/** + * 平台选择面板(按分类分组,展示登录状态) + */ +export default function PlatformPicker({ + platforms, + selected, + loading, + onChange, + onRefresh +}: PlatformPickerProps) { + const selectedSet = new Set(selected) + + function toggle(id: string, checked: boolean) { + const next = new Set(selectedSet) + if (checked) { + next.add(id) + } else { + next.delete(id) + } + onChange(Array.from(next)) + } + + function selectAll(authenticatedOnly: boolean) { + const next = platforms + .filter((p) => (authenticatedOnly ? p.isAuthenticated : true)) + .map((p) => p.id) + onChange(next) + } + + // 按分类分组 + const groups = new Map() + for (const platform of platforms) { + const category = platform.category || "其他" + if (!groups.has(category)) groups.set(category, []) + groups.get(category)!.push(platform) + } + + const authenticatedCount = platforms.filter((p) => p.isAuthenticated).length + + return ( +
+ + + + + + + 已选 {selected.length} 个平台 + + + + {platforms.length === 0 ? ( + + ) : ( + Array.from(groups.entries()).map(([category, items]) => ( +
+
+ {category} +
+
+ {items.map((platform) => ( + toggle(platform.id, checked)} + /> + ))} +
+
+ )) + )} +
+ ) +} + +function PlatformCard({ + platform, + checked, + onToggle +}: { + platform: PlatformAuthItem + checked: boolean + onToggle: (checked: boolean) => void +}) { + const statusTag = platform.isAuthenticated ? ( + + {platform.username || "已登录"} + + ) : ( + 未登录 + ) + + return ( +
onToggle(!checked)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "8px 10px", + border: checked ? "1px solid #52c41a" : "1px solid #f0f0f0", + borderRadius: 8, + cursor: "pointer", + background: checked ? "#f6ffed" : "#fff" + }}> + {/* 阻止冒泡,避免点击勾选框时与外层卡片点击重复触发 */} + e.stopPropagation()}> + onToggle(e.target.checked)} /> + + + {platform.name?.[0]} + +
+
+ + {platform.name} + + {platform.experimental && ( + + + 实验 + + + )} +
+
+ {platform.error && !platform.isAuthenticated ? ( + {statusTag} + ) : ( + statusTag + )} +
+
+
+ ) +} diff --git a/component/sync/SyncProgress.tsx b/component/sync/SyncProgress.tsx new file mode 100644 index 0000000..b5db467 --- /dev/null +++ b/component/sync/SyncProgress.tsx @@ -0,0 +1,113 @@ +import { CheckCircleTwoTone, CloseCircleTwoTone, LinkOutlined } from "@ant-design/icons" +import { Progress, Spin, Tag, Tooltip, Typography } from "antd" + +import type { SyncDetailProgress } from "~sync/extension/engine" +import type { SyncResult } from "~sync/types" + +const { Text } = Typography + +export interface SyncProgressProps { + /** 正在同步的平台数(用于计算整体进度) */ + total: number + progress: Record + results: SyncResult[] + platformNames: Record +} + +/** + * 同步进度与结果 + */ +export default function SyncProgress({ + total, + progress, + results, + platformNames +}: SyncProgressProps) { + const finished = results.length + const percent = total > 0 ? Math.round((finished / total) * 100) : 0 + + const platformIds = Object.keys(progress) + + return ( +
+ + +
+ {platformIds.map((platformId) => { + const item = progress[platformId] + const name = platformNames[platformId] || item.platformName || platformId + + return ( +
+ + {name} + + {item.result?.message && ( + + + {item.result.message.slice(0, 40)} + + + )} + {item.stage === "completed" && item.result?.postUrl && ( + + 打开 + + )} +
+ ) + })} +
+
+ ) +} + +function StageIcon({ stage }: { stage: SyncDetailProgress["stage"] }) { + if (stage === "completed") { + return + } + if (stage === "failed") { + return + } + return +} + +function StageText({ + stage, + imageProgress +}: { + stage: SyncDetailProgress["stage"] + imageProgress?: { current: number; total: number } +}) { + switch (stage) { + case "starting": + return 准备中 + case "uploading_images": + return ( + + 上传图片 {imageProgress ? `${imageProgress.current}/${imageProgress.total}` : ""} + + ) + case "saving": + return 保存中 + case "completed": + return 已完成 + case "failed": + return 失败 + default: + return null + } +} diff --git a/component/ui/toolBox.tsx b/component/ui/toolBox.tsx index 3895099..2a4ae78 100644 --- a/component/ui/toolBox.tsx +++ b/component/ui/toolBox.tsx @@ -3,6 +3,7 @@ import React, { useState } from "react" import { i18n } from "~tools" import { editQRCodeImg } from "~utils/makerQRPost" +import { openSyncPage } from "~utils/syncArticle" const boxStyles = { box: { @@ -102,6 +103,9 @@ export default function ToolBox(props: any) { + 帮助 diff --git a/component/ui/weixinPanel.tsx b/component/ui/weixinPanel.tsx index f3d7af1..6e3caea 100644 --- a/component/ui/weixinPanel.tsx +++ b/component/ui/weixinPanel.tsx @@ -1,6 +1,7 @@ import React from "react" import { i18n } from "~tools" +import { openSyncPage } from "~utils/syncArticle" import qrcodeUrl from "raw:~/public/wx/gzh.jpg" @@ -133,6 +134,9 @@ export default function WeixinPanel(props: { × qrcodeUrl + diff --git a/contents/sync.ts b/contents/sync.ts new file mode 100644 index 0000000..1369623 --- /dev/null +++ b/contents/sync.ts @@ -0,0 +1,89 @@ +/** + * 文章同步 - 内容提取与预处理 Content Script + * + * 职责: + * 1. 响应同步面板的请求,提取当前页面的文章(标题 / HTML / Markdown / 封面) + * 2. 为每个目标平台分别预处理内容(不同平台需要不同的 HTML 结构) + * + * 说明:Markdown 转换与平台预处理都依赖真实 DOM, + * 因此放在页面环境执行,Service Worker / 扩展页面只负责调用平台接口。 + */ +import type { PlasmoCSConfig } from "plasmo" + +import { extractArticle } from "~sync/extension/extractor" +import { + preprocessForPlatform, + preprocessContentDOM +} from "~sync/extension/content-processor" +import { htmlToMarkdownNative } from "~sync/lib/turndown" + +export const config: PlasmoCSConfig = { + matches: [""], + run_at: "document_idle", + all_frames: false +} + +interface PreprocessPayload { + rawHtml: string + platforms: string[] + configs: Record> +} + +/** + * 为多个平台分别预处理内容 + */ +function preprocessForMultiplePlatforms(payload: PreprocessPayload) { + const { rawHtml, platforms, configs } = payload + const results: Record = {} + + for (const platformId of platforms) { + const config = configs?.[platformId] + + if (config) { + results[platformId] = preprocessForPlatform(rawHtml, config as any) + } else { + const tempDiv = document.createElement("div") + tempDiv.innerHTML = rawHtml + preprocessContentDOM(tempDiv) + const html = tempDiv.innerHTML + results[platformId] = { + html, + markdown: htmlToMarkdownNative(html) + } + } + } + + return results +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (!message || typeof message !== "object") return + + // 提取当前页面文章 + if (message.type === "CODEBOX_SYNC_EXTRACT") { + try { + const article = extractArticle() + sendResponse({ success: Boolean(article), article }) + } catch (error) { + sendResponse({ success: false, error: (error as Error).message }) + } + return true + } + + // 平台内容预处理 + if (message.type === "CODEBOX_SYNC_PREPROCESS") { + try { + const platformContents = preprocessForMultiplePlatforms(message.payload) + sendResponse({ success: true, platformContents }) + } catch (error) { + sendResponse({ success: false, error: (error as Error).message }) + } + return true + } + + // 探测页面是否可同步 + if (message.type === "CODEBOX_SYNC_PING") { + sendResponse({ success: true, url: window.location.href }) + return true + } +}) diff --git a/locales/ar/messages.json b/locales/ar/messages.json index 3860922..637f109 100644 --- a/locales/ar/messages.json +++ b/locales/ar/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-نسخ الكود بنقرة واحدة بدون تسجيل الدخول" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "نسخ الكود بنقرة واحدة بدون تسجيل الدخول" }, + "syncArticle": { + "message": "مزامنة المقالات" + }, "history": { "message": "السجلات التاريخية" }, diff --git a/locales/de/messages.json b/locales/de/messages.json index 5df9f4e..2c55213 100644 --- a/locales/de/messages.json +++ b/locales/de/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-Code mit einem Klick ohne Anmeldung kopieren" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "Code mit einem Klick ohne Anmeldung kopieren" }, + "syncArticle": { + "message": "Artikel synchronisieren" + }, "history": { "message": "Verlauf" }, diff --git a/locales/en/messages.json b/locales/en/messages.json index d6beb14..492b6f2 100644 --- a/locales/en/messages.json +++ b/locales/en/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-AI Download Article" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "one click code copying without login" }, + "syncArticle": { + "message": "Sync Articles" + }, "history": { "message": "Historical records" }, diff --git a/locales/es/messages.json b/locales/es/messages.json index 59a753e..b2b34b4 100644 --- a/locales/es/messages.json +++ b/locales/es/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-copia de código con un clic sin iniciar sesión" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "copia de código con un clic sin iniciar sesión" }, + "syncArticle": { + "message": "Sincronizar artículos" + }, "history": { "message": "Registros históricos" }, diff --git a/locales/fr/messages.json b/locales/fr/messages.json index a720eab..989b067 100644 --- a/locales/fr/messages.json +++ b/locales/fr/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-copie de code en un clic sans connexion" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "copie de code en un clic sans connexion" }, + "syncArticle": { + "message": "Synchroniser les articles" + }, "history": { "message": "Historique" }, diff --git a/locales/hi/messages.json b/locales/hi/messages.json index ff200c3..b454882 100644 --- a/locales/hi/messages.json +++ b/locales/hi/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-लॉगिन के बिना एक क्लिक कोड कॉपी" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "लॉगिन के बिना एक क्लिक कोड कॉपी" }, + "syncArticle": { + "message": "लेख सिंक करें" + }, "history": { "message": "ऐतिहासिक रिकॉर्ड" }, diff --git a/locales/it/messages.json b/locales/it/messages.json index 7ac86ae..0596eba 100644 --- a/locales/it/messages.json +++ b/locales/it/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-copia del codice con un clic senza accesso" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "copia del codice con un clic senza accesso" }, + "syncArticle": { + "message": "Sincronizza articoli" + }, "history": { "message": "Cronologia" }, diff --git a/locales/ja/messages.json b/locales/ja/messages.json index 63d37fd..9f227a1 100644 --- a/locales/ja/messages.json +++ b/locales/ja/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-ログイン不要でワンクリックコードコピー" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "ログイン不要でワンクリックコードコピー" }, + "syncArticle": { + "message": "記事を同期" + }, "history": { "message": "履歴記録" }, diff --git a/locales/ko/messages.json b/locales/ko/messages.json index 80da3f8..5bea259 100644 --- a/locales/ko/messages.json +++ b/locales/ko/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-로그인 없이 원클릭 코드 복사" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "로그인 없이 원클릭 코드 복사" }, + "syncArticle": { + "message": "글 동기화" + }, "history": { "message": "기록" }, diff --git a/locales/pt/messages.json b/locales/pt/messages.json index 7904673..13c18f8 100644 --- a/locales/pt/messages.json +++ b/locales/pt/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-cópia de código com um clique sem login" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "cópia de código com um clique sem login" }, + "syncArticle": { + "message": "Sincronizar artigos" + }, "history": { "message": "Histórico" }, diff --git a/locales/ru/messages.json b/locales/ru/messages.json index 8781009..689803b 100644 --- a/locales/ru/messages.json +++ b/locales/ru/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-копирование кода одним кликом без входа" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "копирование кода одним кликом без входа" }, + "syncArticle": { + "message": "Синхронизация статей" + }, "history": { "message": "История" }, diff --git a/locales/zh_CN/messages.json b/locales/zh_CN/messages.json index 5f03486..0fd0a59 100644 --- a/locales/zh_CN/messages.json +++ b/locales/zh_CN/messages.json @@ -1,4 +1,4 @@ -{ +{ "extensionName": { "message": "codebox-AI下载文章" }, @@ -8,6 +8,9 @@ "popupDescription": { "message": "免登录一键复制代码/下载文章" }, + "syncArticle": { + "message": "文章同步" + }, "history": { "message": "历史记录" }, diff --git a/package.json b/package.json index e7b7729..13fd8b1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-box", "displayName": "__MSG_extensionName__", - "version": "2.0.2", + "version": "2.1.0", "description": "__MSG_extensionDescription__", "author": "027xiguapi. <458813868@qq.com>", "scripts": { @@ -31,12 +31,17 @@ "dayjs": "^1.11.12", "file-saver": "^2.0.5", "html2canvas": "^1.4.1", + "js-md5": "^0.8.3", "jssha": "^3.3.1", "jszip": "^3.10.1", + "markdown-draft-js": "^2.4.0", + "marked": "^17.0.6", "plasmo": "0.90.5", "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3", + "remarkable": "^2.0.1", + "turndown": "^7.2.4", "uuid": "^10.0.0" }, "devDependencies": { @@ -50,6 +55,7 @@ "prettier": "3.2.4", "sass": "^1.99.0", "sharp": "^0.33.5", + "stream-browserify": "^3.0.0", "typescript": "5.3.3" }, "manifest": { @@ -63,9 +69,17 @@ "permissions": [ "activeTab", "downloads", - "contextMenus" + "contextMenus", + "storage", + "unlimitedStorage", + "cookies", + "scripting", + "tabs", + "declarativeNetRequest", + "declarativeNetRequestWithHostAccess" ], "host_permissions": [ + "http://*/*", "https://*/*" ], "omnibox": { diff --git a/popup/index.tsx b/popup/index.tsx index 1d9748c..831de9a 100644 --- a/popup/index.tsx +++ b/popup/index.tsx @@ -1,3 +1,6 @@ +import { CloudUploadOutlined } from "@ant-design/icons" +import { Button } from "antd" + import Content from "~component/options/content" import BatchDownload from "~component/items/batchDownload" import { ThemeProvider } from "~theme" @@ -25,6 +28,18 @@ export default function IndexPopup() { }) } + // 打开文章同步页面,并带上当前标签页 ID + async function openSyncPage() { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + const query = tab?.id ? `?tabId=${tab.id}` : "" + chrome.tabs.create({ url: chrome.runtime.getURL(`tabs/sync.html${query}`) }) + window.close() + } catch (error) { + console.error("打开文章同步页面失败", error) + } + } + return (
@@ -33,6 +48,14 @@ export default function IndexPopup() {

{i18n("popupDescription")}

+
diff --git a/public/app.md b/public/app.md index f8c7555..4b79c41 100644 --- a/public/app.md +++ b/public/app.md @@ -6,7 +6,17 @@ https://github.com/027xiguapi/code-box ## 描述 -本浏览器插件可以用于CSDN/知乎/脚本之家/博客园/博客园/51CTO博客/php中文网/掘金/微信等网站,一键下载文章成html或markdown文件;实现无需登录一键复制代码;支持选中代码;或者代码右上角按钮的一键复制;解除关注博主即可阅读全文提示;去除登录弹窗;去除跳转APP弹窗;页面自定义样式. +本浏览器插件可以用于CSDN/知乎/脚本之家/博客园/博客园/51CTO博客/php中文网/掘金/微信等网站,一键下载文章成html或markdown文件;实现无需登录一键复制代码;支持选中代码;或者代码右上角按钮的一键复制;解除关注博主即可阅读全文提示;去除登录弹窗;去除跳转APP弹窗;页面自定义样式;支持文章一键同步到29+主流平台. + +### 文章同步(29+ 主流平台) + +一键把当前页面文章同步到微信公众号、知乎、微博、小红书、掘金、CSDN、简书、头条号、抖音图文、B站专栏、百家号、语雀、豆瓣、搜狐号、雪球、人人都是产品经理、大鱼号、一点号、51CTO、慕课网、开源中国、SegmentFault、博客园、搜狐焦点、X (Twitter)、东方财富、什么值得买、网易号等 29+ 平台,以及 WordPress、Typecho、MetaWeblog 自建站;Hexo/Hugo 可通过 Markdown 打包下载。 + +1. 使用浏览器中已登录的账号同步,无需输入密码,数据不经过任何第三方服务器 +2. 默认保存为草稿,确认无误后再发布,不会自动发布 +3. 文章图片自动转存到目标平台 +4. 不同平台自动适配内容(是否保留链接、标签是否压缩、代码块格式等) +5. 同步面板显示各平台登录状态、同步进度、草稿链接和同步历史 ### 自定义 diff --git a/sync/adapters/base.ts b/sync/adapters/base.ts new file mode 100644 index 0000000..124f76b --- /dev/null +++ b/sync/adapters/base.ts @@ -0,0 +1,93 @@ +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../types' +import type { RuntimeInterface } from '../runtime/interface' +import type { PlatformAdapter } from './types' + +/** + * 适配器基类 + * 提供通用的请求处理和模板解析 + */ +export abstract class BaseAdapter implements PlatformAdapter { + abstract readonly meta: PlatformMeta + protected runtime!: RuntimeInterface + protected context: Record = {} + + async init(runtime: RuntimeInterface): Promise { + this.runtime = runtime + } + + abstract checkAuth(): Promise + abstract publish(article: Article): Promise + + /** + * 发送请求 + */ + protected async request( + url: string, + options: RequestInit = {} + ): Promise { + const response = await this.runtime.fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const contentType = response.headers.get('content-type') + if (contentType?.includes('application/json')) { + return response.json() + } + + return response.text() as T + } + + /** + * 带重试的请求 + */ + protected async requestWithRetry( + url: string, + options: RequestInit = {}, + maxRetries = 3 + ): Promise { + let lastError: Error | null = null + + for (let i = 0; i < maxRetries; i++) { + try { + return await this.request(url, options) + } catch (error) { + lastError = error as Error + if (i < maxRetries - 1) { + await this.delay(1000 * (i + 1)) + } + } + } + + throw lastError + } + + /** + * 延迟 + */ + protected delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + /** + * 创建同步结果 + */ + protected createResult( + success: boolean, + data?: Partial + ): SyncResult { + return { + platform: this.meta.id, + success, + timestamp: Date.now(), + ...data, + } + } +} diff --git a/sync/adapters/cms/metaweblog.ts b/sync/adapters/cms/metaweblog.ts new file mode 100644 index 0000000..3f51e17 --- /dev/null +++ b/sync/adapters/cms/metaweblog.ts @@ -0,0 +1,734 @@ +/** + * MetaWeblog API 适配器 + * 支持 Typecho 等兼容 MetaWeblog 的博客系统 + */ +import { createLogger } from '../../lib/logger' +import { parseMarkdownImages } from '../../lib/markdown-images' + +const logger = createLogger('MetaWeblog') + +interface MetaWeblogCredentials { + url: string + username: string + password: string + // Typecho 等系统的 XML-RPC 端点可能不同 + endpoint?: string +} + +interface ImageUploadResult { + url: string +} + +/** + * 构建 XML-RPC 请求体 + * 注意:必须是紧凑格式,不能有多余空白,否则某些 XML-RPC 实现会解析失败 + */ +function buildXmlRpcRequest(method: string, params: unknown[]): string { + const paramXml = params.map(param => { + if (typeof param === 'string') { + return `${escapeXml(param)}` + } + if (typeof param === 'number') { + // 使用 i4 而不是 int,兼容性更好 + return `${param}` + } + if (typeof param === 'boolean') { + return `${param ? 1 : 0}` + } + if (typeof param === 'object' && param !== null) { + return `${objectToXmlRpcStruct(param as Record)}` + } + return `${String(param)}` + }).join('') + + // 紧凑格式,与 jQuery xmlrpc 插件一致 + return `${method}${paramXml}` +} + +function objectToXmlRpcStruct(obj: Record): string { + return Object.entries(obj).map(([key, value]) => { + let valueXml: string + if (typeof value === 'string') { + valueXml = `${escapeXml(value)}` + } else if (typeof value === 'number') { + // 使用 i4 而不是 int,兼容性更好 + valueXml = `${value}` + } else if (typeof value === 'boolean') { + valueXml = `${value ? 1 : 0}` + } else if (value instanceof Uint8Array) { + // Base64 编码的二进制数据 + valueXml = `${arrayBufferToBase64(value)}` + } else { + valueXml = `${escapeXml(String(value))}` + } + return `${key}${valueXml}` + }).join('') +} + +/** + * 将 Uint8Array 转换为 base64 字符串 + */ +function arrayBufferToBase64(buffer: Uint8Array): string { + let binary = '' + const len = buffer.byteLength + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(buffer[i]) + } + return btoa(binary) +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * 解析 XML-RPC 响应 + */ +function parseXmlRpcResponse(xml: string): { success: boolean; value?: unknown; error?: string } { + // 检查是否有 fault + if (xml.includes('')) { + const faultMatch = xml.match(/([^<]+)<\/string>/) + return { success: false, error: faultMatch?.[1] || 'XML-RPC 错误' } + } + + // 提取返回值 - 字符串或数字 + const stringMatch = xml.match(/([^<]*)<\/string>/) + if (stringMatch) { + return { success: true, value: stringMatch[1] } + } + + const intMatch = xml.match(/([^<]*)<\/int>/) + if (intMatch) { + return { success: true, value: intMatch[1] } + } + + // i4 类型(与 int 等价) + const i4Match = xml.match(/([^<]*)<\/i4>/) + if (i4Match) { + return { success: true, value: i4Match[1] } + } + + // 检查数组返回 (getUsersBlogs) + if (xml.includes('') || xml.includes('')) { + return { success: true, value: {} } + } + + return { success: true } +} + +/** + * 从 XML 响应中提取最新文章的 postid + */ +function extractLatestPostId(xml: string): string | null { + // 匹配第一个 postid 字段 + const postIdMatch = xml.match(/postid<\/name>\s*(?:)?([^<]+)(?:<\/string>)?<\/value>/) + return postIdMatch ? postIdMatch[1] : null +} + +/** + * 获取最新文章 ID(用于 Typecho 返回 0 的情况) + */ +async function getLatestPostId( + credentials: MetaWeblogCredentials, + endpoint: string +): Promise { + try { + const body = buildXmlRpcRequest('metaWeblog.getRecentPosts', [ + 0, // blogId + credentials.username, + credentials.password, + 1, // 只获取最新 1 篇 + ]) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return null + } + + const xml = await response.text() + return extractLatestPostId(xml) + } catch { + return null + } +} + +/** + * 获取 XML-RPC 端点 + */ +function getEndpoint(credentials: MetaWeblogCredentials): string { + if (credentials.endpoint) { + return credentials.endpoint + } + // 默认端点 + return credentials.url.replace(/\/$/, '') + '/xmlrpc.php' +} + +/** + * 测试 MetaWeblog 连接 + */ +export async function testConnection(credentials: MetaWeblogCredentials): Promise<{ success: boolean; error?: string }> { + const endpoint = getEndpoint(credentials) + + try { + // 使用 blogger.getUsersBlogs 测试连接 + const body = buildXmlRpcRequest('blogger.getUsersBlogs', [ + '', // appKey (not used) + credentials.username, + credentials.password, + ]) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + const result = parseXmlRpcResponse(xml) + + if (!result.success) { + return { success: false, error: result.error } + } + + return { success: true } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} + +/** + * 上传图片到 MetaWeblog 兼容博客 + */ +export async function uploadImage( + credentials: MetaWeblogCredentials, + imageData: Uint8Array, + filename: string, + mimeType: string +): Promise<{ success: boolean; url?: string; error?: string }> { + const endpoint = getEndpoint(credentials) + + try { + const mediaObject: Record = { + name: filename, + type: mimeType, + // 同时发送 bits 和 bytes,提高兼容性 + // - 标准 MetaWeblog 使用 bits + // - Typecho 使用 bytes + bits: imageData, + bytes: imageData, + } + + // 统一使用 metaWeblog.newMediaObject(标准 MetaWeblog API) + // WordPress 也支持此 API,兼容性更好 + const methodName = 'metaWeblog.newMediaObject' + + const body = buildXmlRpcRequest(methodName, [ + 0, // blogId + credentials.username, + credentials.password, + mediaObject, + ]) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + + // 解析上传结果,提取 URL + const urlMatch = xml.match(/url<\/name>\s*(?:)?([^<]+)(?:<\/string>)?<\/value>/) + if (urlMatch) { + return { success: true, url: urlMatch[1] } + } + + // 检查错误 + if (xml.includes('')) { + const faultMatch = xml.match(/([^<]+)<\/string>/) + return { success: false, error: faultMatch?.[1] || 'Upload failed' } + } + + return { success: false, error: '无法解析上传结果' } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} + +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', + 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp', + 'image/svg+xml': 'svg', +} + +/** + * 根据响应 MIME 类型生成文件名,默认 png + */ +function generateImageFilename(mimeType: string): string { + const ext = MIME_TO_EXT[mimeType] || 'png' + return `image_${Date.now()}.${ext}` +} + +const MAX_RETRY_ATTEMPTS = 10 +const RETRY_DELAY_MS = 1000 // 基础延迟 1 秒 + +/** + * 延迟函数 + */ +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * 从 URL 下载图片并上传(带重试机制) + */ +export async function uploadImageByUrl( + credentials: MetaWeblogCredentials, + imageUrl: string, + signal?: AbortSignal +): Promise { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + // 检查是否已取消 + if (signal?.aborted) { + logger.debug(` Upload aborted for: ${imageUrl}`) + return null + } + + try { + const result = await doUploadImageByUrl(credentials, imageUrl, signal) + if (result) { + if (attempt > 1) { + logger.debug(` Upload succeeded on attempt ${attempt}: ${imageUrl}`) + } + return result + } + // result 为 null 表示失败,继续重试 + logger.warn(` Upload attempt ${attempt}/${MAX_RETRY_ATTEMPTS} failed for: ${imageUrl}`) + } catch (error) { + lastError = error as Error + logger.warn(` Upload attempt ${attempt}/${MAX_RETRY_ATTEMPTS} error:`, error) + } + + // 如果不是最后一次尝试,等待后重试 + if (attempt < MAX_RETRY_ATTEMPTS) { + const delayMs = RETRY_DELAY_MS * attempt // 递增延迟 + logger.debug(` Retrying in ${delayMs}ms...`) + await delay(delayMs) + } + } + + logger.error(` All ${MAX_RETRY_ATTEMPTS} upload attempts failed for: ${imageUrl}`, lastError) + return null +} + +/** + * 实际执行图片下载和上传 + */ +async function doUploadImageByUrl( + credentials: MetaWeblogCredentials, + imageUrl: string, + signal?: AbortSignal +): Promise { + // 下载图片 (带超时) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30秒超时 + + // 如果有外部 signal,监听它 + if (signal) { + signal.addEventListener('abort', () => controller.abort()) + } + + try { + const response = await fetch(imageUrl, { signal: controller.signal }) + clearTimeout(timeoutId) + + if (!response.ok) { + logger.error(` Failed to download image (HTTP ${response.status}): ${imageUrl}`) + return null + } + + const blob = await response.blob() + const arrayBuffer = await blob.arrayBuffer() + const imageData = new Uint8Array(arrayBuffer) + + // 获取 MIME 类型 + const mimeType = blob.type || 'image/jpeg' + + const filename = generateImageFilename(mimeType) + + logger.debug(` Uploading image: ${filename}, type: ${mimeType}`) + + const result = await uploadImage(credentials, imageData, filename, mimeType) + if (result.success && result.url) { + return { url: result.url } + } + + logger.error(` Failed to upload image: ${result.error}`) + return null + } catch (error) { + clearTimeout(timeoutId) + throw error // 向上抛出以触发重试 + } +} + +/** + * 处理文章中的图片 + */ +export async function processArticleImages( + credentials: MetaWeblogCredentials, + content: string, + onProgress?: (current: number, total: number) => void, + signal?: AbortSignal +): Promise<{ content: string; failedImages: number }> { + const imgRegex = /]+src="([^"]+)"[^>]*>/gi + const matches: { full: string; src: string }[] = [] + + let match + while ((match = imgRegex.exec(content)) !== null) { + matches.push({ full: match[0], src: match[1] }) + } + + for (const mdMatch of parseMarkdownImages(content)) { + matches.push({ full: mdMatch.full, src: mdMatch.src }) + } + + if (matches.length === 0) { + return { content, failedImages: 0 } + } + + logger.debug(` Found ${matches.length} images to process`) + + let result = content + const uploadedMap = new Map() + let processed = 0 + let failedImages = 0 + + for (const { full, src } of matches) { + // 检查是否已取消 + if (signal?.aborted) { + throw new Error('操作已取消') + } + + if (!src || src.startsWith('data:')) continue + + const siteDomain = new URL(credentials.url).hostname + try { + const imgDomain = new URL(src).hostname + if (imgDomain === siteDomain) { + logger.debug(` Skipping same-domain image: ${src}`) + continue + } + } catch { + // URL 解析失败,继续处理 + } + + processed++ + onProgress?.(processed, matches.length) + + // 检查是否已上传过 + let newUrl = uploadedMap.get(src) + + if (!newUrl) { + logger.debug(` Uploading image ${processed}/${matches.length}: ${src}`) + const uploadResult = await uploadImageByUrl(credentials, src, signal) + if (uploadResult?.url) { + newUrl = uploadResult.url + uploadedMap.set(src, newUrl) + } else { + // 上传失败,跳过该图片,保留原始 URL,继续处理其他图片 + failedImages++ + logger.warn(`图片上传失败,跳过: ${src.substring(0, 100)}...`) + continue + } + } + + if (newUrl) { + const newTag = full.replace(src, newUrl) + result = result.replace(full, newTag) + logger.debug(` Image uploaded: ${newUrl}`) + } + + await new Promise(resolve => setTimeout(resolve, 300)) + } + + return { content: result, failedImages } +} + +/** + * 发布文章 + */ +export async function publish( + credentials: MetaWeblogCredentials, + article: { title: string; content: string }, + options?: { draftOnly?: boolean; processImages?: boolean; onImageProgress?: (current: number, total: number) => void } +): Promise<{ success: boolean; postId?: string; postUrl?: string; message?: string; error?: string }> { + const endpoint = getEndpoint(credentials) + + try { + // 如果启用图片处理,先处理文章中的图片 + let content = article.content + let failedImages = 0 + if (options?.processImages !== false) { + logger.debug(' Processing images before publish...') + const imageResult = await processArticleImages(credentials, content, options?.onImageProgress) + content = imageResult.content + failedImages = imageResult.failedImages + } + + const post = { + title: article.title, + description: content, + categories: [], + } + + const body = buildXmlRpcRequest('metaWeblog.newPost', [ + '0', // blogId + credentials.username, + credentials.password, + post, + !options?.draftOnly, // publish flag + ]) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + const result = parseXmlRpcResponse(xml) + + if (!result.success) { + return { success: false, error: result.error } + } + + const postId = String(result.value) + const baseUrl = credentials.url.replace(/\/$/, '') + const postUrl = options?.draftOnly + ? `${baseUrl}/admin/manage-posts.php?cid=${postId}` + : `${baseUrl}/archives/${postId}/` + + const message = failedImages > 0 ? `${failedImages} 张图片上传失败` : undefined + return { success: true, postId, postUrl, message } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} + +/** + * Typecho 专用上传图片 + */ +export async function uploadTypechoImage( + credentials: MetaWeblogCredentials, + imageData: Uint8Array, + filename: string, + mimeType: string +): Promise<{ success: boolean; url?: string; error?: string }> { + const endpoint = credentials.url.replace(/\/$/, '') + '/action/xmlrpc' + + try { + // 同时发送 bits 和 bytes,提高兼容性 + const mediaObject = { + name: filename, + type: mimeType, + bits: imageData, + bytes: imageData, + } + + const body = buildXmlRpcRequest('metaWeblog.newMediaObject', [ + 0, // blogId + credentials.username, + credentials.password, + mediaObject, + ]) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + + // 解析上传结果,提取 URL + const urlMatch = xml.match(/url<\/name>\s*(?:)?([^<]+)(?:<\/string>)?<\/value>/) + if (urlMatch) { + return { success: true, url: urlMatch[1] } + } + + // 检查错误 + if (xml.includes('')) { + const faultMatch = xml.match(/([^<]+)<\/string>/) + return { success: false, error: faultMatch?.[1] || 'Upload failed' } + } + + return { success: false, error: '无法解析上传结果' } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} + +/** + * Typecho 专用测试连接 + */ +export async function testTypechoConnection(credentials: MetaWeblogCredentials): Promise<{ success: boolean; error?: string }> { + // Typecho 默认使用 /action/xmlrpc 端点 + const endpoint = credentials.url.replace(/\/$/, '') + '/action/xmlrpc' + + try { + // 使用 metaWeblog.getUsersBlogs 测试连接(MetaWeblog 标准 API) + const body = buildXmlRpcRequest('metaWeblog.getUsersBlogs', [ + '', // appKey (MetaWeblog 标准参数,通常为空) + credentials.username, + credentials.password, + ]) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + const result = parseXmlRpcResponse(xml) + + if (!result.success) { + return { success: false, error: result.error } + } + + return { success: true } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} + +/** + * Typecho 专用发布 + */ +export async function publishToTypecho( + credentials: MetaWeblogCredentials, + article: { title: string; content: string }, + options?: { draftOnly?: boolean; processImages?: boolean; onImageProgress?: (current: number, total: number) => void; signal?: AbortSignal } +): Promise<{ success: boolean; postId?: string; postUrl?: string; message?: string; error?: string }> { + const endpoint = credentials.url.replace(/\/$/, '') + '/action/xmlrpc' + + // Typecho 使用 /action/xmlrpc 端点,设置到 credentials 供图片上传使用 + const typechoCredentials = { ...credentials, endpoint } + + try { + // 如果启用图片处理,先处理文章中的图片 + let content = article.content + let failedImages = 0 + if (options?.processImages !== false) { + logger.debug(' Processing images before publish...') + const imageResult = await processArticleImages(typechoCredentials, content, options?.onImageProgress, options?.signal) + content = imageResult.content + failedImages = imageResult.failedImages + } + + // Typecho 使用 metaWeblog.newPost,参数格式和旧版保持一致 + const post = { + title: article.title, + description: content.trim(), + } + + // 参数顺序:[blogId, username, password, post, publish] + const body = buildXmlRpcRequest('metaWeblog.newPost', [ + 0, // blogId (数字 0,和旧版一致) + credentials.username, + credentials.password, + post, + false, // publish flag,旧版固定为 false + ]) + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + const result = parseXmlRpcResponse(xml) + + if (!result.success) { + return { success: false, error: result.error } + } + + let postId = String(result.value) + const baseUrl = credentials.url.replace(/\/$/, '') + + // Typecho 的 metaWeblog.newPost 可能返回 0,尝试查询最新文章获取真实 ID + if (!postId || postId === '0') { + logger.debug('Typecho returned postId=0, fetching latest post...') + const latestId = await getLatestPostId(credentials, endpoint) + if (latestId) { + postId = latestId + logger.debug(`Got latest postId: ${postId}`) + } + } + + let postUrl: string + if (postId && postId !== '0') { + // Typecho 编辑页面 URL 格式 + postUrl = `${baseUrl}/admin/write-post.php?cid=${postId}` + } else { + // 回退到草稿管理页面 + postUrl = `${baseUrl}/admin/manage-posts.php` + } + + const message = failedImages > 0 ? `${failedImages} 张图片上传失败` : undefined + return { success: true, postId, postUrl, message } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} diff --git a/sync/adapters/cms/wordpress.ts b/sync/adapters/cms/wordpress.ts new file mode 100644 index 0000000..be76979 --- /dev/null +++ b/sync/adapters/cms/wordpress.ts @@ -0,0 +1,491 @@ +/** + * WordPress XML-RPC 适配器 + */ +import { createLogger } from '../../lib/logger' +import { parseMarkdownImages } from '../../lib/markdown-images' + +const logger = createLogger('WordPress') + +interface WordPressCredentials { + url: string + username: string + password: string +} + +interface WordPressPost { + post_title: string + post_content: string + post_status: 'draft' | 'publish' + post_type?: string +} + +interface ImageUploadResult { + url: string +} + +/** + * 构建 XML-RPC 请求体 + */ +function buildXmlRpcRequest(method: string, params: unknown[]): string { + const paramXml = params.map(param => { + if (typeof param === 'string') { + return `${escapeXml(param)}` + } + if (typeof param === 'number') { + return `${param}` + } + if (typeof param === 'object' && param !== null) { + return `${objectToXmlRpcStruct(param as Record)}` + } + return `${String(param)}` + }).join('') + + return ` + + ${method} + ${paramXml} +` +} + +function objectToXmlRpcStruct(obj: Record): string { + return Object.entries(obj).map(([key, value]) => { + let valueXml: string + if (typeof value === 'string') { + valueXml = `${escapeXml(value)}` + } else if (typeof value === 'number') { + valueXml = `${value}` + } else if (typeof value === 'boolean') { + valueXml = `${value ? 1 : 0}` + } else if (value instanceof Uint8Array) { + // Base64 编码的二进制数据 + valueXml = `${arrayBufferToBase64(value)}` + } else { + valueXml = `${escapeXml(String(value))}` + } + return `${key}${valueXml}` + }).join('') +} + +/** + * 将 Uint8Array 转换为 base64 字符串 + */ +function arrayBufferToBase64(buffer: Uint8Array): string { + let binary = '' + const len = buffer.byteLength + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(buffer[i]) + } + return btoa(binary) +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * 解析 XML-RPC 响应 + */ +function parseXmlRpcResponse(xml: string): { success: boolean; value?: unknown; error?: string } { + // 检查是否有 fault + if (xml.includes('')) { + const faultMatch = xml.match(/([^<]+)<\/string>/) + return { success: false, error: faultMatch?.[1] || 'XML-RPC 错误' } + } + + // 提取返回值 + const valueMatch = xml.match(/[\s\S]*?<(string|int|boolean)>([^<]*)<\/\1>/) + if (valueMatch) { + return { success: true, value: valueMatch[2] } + } + + // 检查 struct 返回 + if (xml.includes('')) { + return { success: true, value: {} } + } + + return { success: true } +} + +/** + * 测试 WordPress 连接 + */ +export async function testConnection(credentials: WordPressCredentials): Promise<{ success: boolean; error?: string }> { + const xmlrpcUrl = credentials.url.replace(/\/$/, '') + '/xmlrpc.php' + + try { + const body = buildXmlRpcRequest('wp.getUsersBlogs', [ + credentials.username, + credentials.password, + ]) + + const response = await fetch(xmlrpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + const result = parseXmlRpcResponse(xml) + + if (!result.success) { + return { success: false, error: result.error } + } + + return { success: true } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} + +/** + * 上传图片到 WordPress + */ +export async function uploadImage( + credentials: WordPressCredentials, + imageData: Uint8Array, + filename: string, + mimeType: string +): Promise<{ success: boolean; url?: string; error?: string }> { + const xmlrpcUrl = credentials.url.replace(/\/$/, '') + '/xmlrpc.php' + + try { + const fileData = { + name: filename, + type: mimeType, + bits: imageData, + overwrite: true, + } + + const body = buildXmlRpcRequest('wp.uploadFile', [ + 0, // blog_id + credentials.username, + credentials.password, + fileData, + ]) + + const response = await fetch(xmlrpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + + // 解析上传结果,提取 URL + const urlMatch = xml.match(/url<\/name>\s*(?:)?([^<]+)(?:<\/string>)?<\/value>/) + if (urlMatch) { + return { success: true, url: urlMatch[1] } + } + + // 检查错误 + if (xml.includes('')) { + const faultMatch = xml.match(/([^<]+)<\/string>/) + return { success: false, error: faultMatch?.[1] || 'Upload failed' } + } + + return { success: false, error: '无法解析上传结果' } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} + +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', + 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp', + 'image/svg+xml': 'svg', +} + +/** + * 根据响应 MIME 类型生成文件名,默认 png + */ +function generateImageFilename(mimeType: string): string { + const ext = MIME_TO_EXT[mimeType] || 'png' + return `image_${Date.now()}.${ext}` +} + +const MAX_RETRY_ATTEMPTS = 10 +const RETRY_DELAY_MS = 1000 // 基础延迟 1 秒 + +/** + * 延迟函数 + */ +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * 从 URL 下载图片并上传到 WordPress(带重试机制) + */ +export async function uploadImageByUrl( + credentials: WordPressCredentials, + imageUrl: string, + signal?: AbortSignal +): Promise { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + // 检查是否已取消 + if (signal?.aborted) { + logger.debug(` Upload aborted for: ${imageUrl}`) + return null + } + + try { + const result = await doUploadImageByUrl(credentials, imageUrl, signal) + if (result) { + if (attempt > 1) { + logger.debug(` Upload succeeded on attempt ${attempt}: ${imageUrl}`) + } + return result + } + // result 为 null 表示失败,继续重试 + logger.warn(` Upload attempt ${attempt}/${MAX_RETRY_ATTEMPTS} failed for: ${imageUrl}`) + } catch (error) { + lastError = error as Error + logger.warn(` Upload attempt ${attempt}/${MAX_RETRY_ATTEMPTS} error:`, error) + } + + // 如果不是最后一次尝试,等待后重试 + if (attempt < MAX_RETRY_ATTEMPTS) { + const delayMs = RETRY_DELAY_MS * attempt // 递增延迟 + logger.debug(` Retrying in ${delayMs}ms...`) + await delay(delayMs) + } + } + + logger.error(` All ${MAX_RETRY_ATTEMPTS} upload attempts failed for: ${imageUrl}`, lastError) + return null +} + +/** + * 实际执行图片下载和上传 + */ +async function doUploadImageByUrl( + credentials: WordPressCredentials, + imageUrl: string, + signal?: AbortSignal +): Promise { + // 下载图片 (带超时) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30秒超时 + + // 如果有外部 signal,监听它 + if (signal) { + signal.addEventListener('abort', () => controller.abort()) + } + + try { + const response = await fetch(imageUrl, { signal: controller.signal }) + clearTimeout(timeoutId) + + if (!response.ok) { + logger.error(` Failed to download image (HTTP ${response.status}): ${imageUrl}`) + return null + } + + const blob = await response.blob() + const arrayBuffer = await blob.arrayBuffer() + const imageData = new Uint8Array(arrayBuffer) + + // 获取 MIME 类型 + const mimeType = blob.type || 'image/jpeg' + + const filename = generateImageFilename(mimeType) + + logger.debug(` Uploading image: ${filename}, type: ${mimeType}`) + + // 上传到 WordPress + const result = await uploadImage(credentials, imageData, filename, mimeType) + if (result.success && result.url) { + return { url: result.url } + } + + logger.error(` Failed to upload image: ${result.error}`) + return null + } catch (error) { + clearTimeout(timeoutId) + throw error // 向上抛出以触发重试 + } +} + +/** + * 处理文章中的图片,上传到 WordPress 并替换 URL + */ +export async function processArticleImages( + credentials: WordPressCredentials, + content: string, + onProgress?: (current: number, total: number) => void, + signal?: AbortSignal +): Promise<{ content: string; failedImages: number }> { + // 提取所有图片 URL + const imgRegex = /]+src="([^"]+)"[^>]*>/gi + const matches: { full: string; src: string }[] = [] + + let match + while ((match = imgRegex.exec(content)) !== null) { + matches.push({ full: match[0], src: match[1] }) + } + + // 同时处理 Markdown 图片 + for (const mdMatch of parseMarkdownImages(content)) { + matches.push({ full: mdMatch.full, src: mdMatch.src }) + } + + if (matches.length === 0) { + return { content, failedImages: 0 } + } + + logger.debug(` Found ${matches.length} images to process`) + + let result = content + const uploadedMap = new Map() + let processed = 0 + let failedImages = 0 + + for (const { full, src } of matches) { + // 检查是否已取消 + if (signal?.aborted) { + throw new Error('操作已取消') + } + + // 跳过空 src 和 data URI + if (!src || src.startsWith('data:')) continue + + // 跳过已经是 WordPress 站点的图片 + const wpDomain = new URL(credentials.url).hostname + try { + const imgDomain = new URL(src).hostname + if (imgDomain === wpDomain) { + logger.debug(` Skipping same-domain image: ${src}`) + continue + } + } catch { + // URL 解析失败,继续处理 + } + + processed++ + onProgress?.(processed, matches.length) + + // 检查是否已上传过 + let newUrl = uploadedMap.get(src) + + if (!newUrl) { + logger.debug(` Uploading image ${processed}/${matches.length}: ${src}`) + const uploadResult = await uploadImageByUrl(credentials, src, signal) + if (uploadResult?.url) { + newUrl = uploadResult.url + uploadedMap.set(src, newUrl) + } else { + // 上传失败,跳过该图片,保留原始 URL,继续处理其他图片 + failedImages++ + logger.warn(`图片上传失败,跳过: ${src.substring(0, 100)}...`) + continue + } + } + + if (newUrl) { + // 替换图片 URL + const newTag = full.replace(src, newUrl) + result = result.replace(full, newTag) + logger.debug(` Image uploaded: ${newUrl}`) + } + + // 避免请求过快 + await new Promise(resolve => setTimeout(resolve, 300)) + } + + return { content: result, failedImages } +} + +/** + * 发布文章到 WordPress + */ +export async function publish( + credentials: WordPressCredentials, + article: { title: string; content: string }, + options?: { draftOnly?: boolean; processImages?: boolean; onImageProgress?: (current: number, total: number) => void; signal?: AbortSignal } +): Promise<{ success: boolean; postId?: string; postUrl?: string; message?: string; error?: string }> { + const xmlrpcUrl = credentials.url.replace(/\/$/, '') + '/xmlrpc.php' + + try { + // 检查是否已取消 + if (options?.signal?.aborted) { + return { success: false, error: '操作已取消' } + } + + // 如果启用图片处理,先处理文章中的图片 + let content = article.content + let failedImages = 0 + if (options?.processImages !== false) { + logger.debug(' Processing images before publish...') + const imageResult = await processArticleImages(credentials, content, options?.onImageProgress, options?.signal) + content = imageResult.content + failedImages = imageResult.failedImages + } + + // 再次检查是否已取消 + if (options?.signal?.aborted) { + return { success: false, error: '操作已取消' } + } + + const post: WordPressPost = { + post_title: article.title, + post_content: content, + post_status: options?.draftOnly ? 'draft' : 'publish', + post_type: 'post', + } + + const body = buildXmlRpcRequest('wp.newPost', [ + 0, // blog_id + credentials.username, + credentials.password, + post, + ]) + + const response = await fetch(xmlrpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml', + }, + body, + }) + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` } + } + + const xml = await response.text() + const result = parseXmlRpcResponse(xml) + + if (!result.success) { + return { success: false, error: result.error } + } + + const postId = String(result.value) + const postUrl = options?.draftOnly + ? `${credentials.url.replace(/\/$/, '')}/wp-admin/post.php?post=${postId}&action=edit` + : `${credentials.url.replace(/\/$/, '')}/?p=${postId}` + + const message = failedImages > 0 ? `${failedImages} 张图片上传失败` : undefined + return { success: true, postId, postUrl, message } + } catch (error) { + return { success: false, error: (error as Error).message } + } +} diff --git a/sync/adapters/code-adapter.ts b/sync/adapters/code-adapter.ts new file mode 100644 index 0000000..be8fa0e --- /dev/null +++ b/sync/adapters/code-adapter.ts @@ -0,0 +1,451 @@ +/** + * 代码适配器基类 + * + * 架构说明: + * - Content Script (有 DOM): 负责所有 HTML/DOM 处理 + * - 代码块处理 (使用 innerText) + * - 懒加载图片处理 + * - HTML 转 Markdown + * - Service Worker (无 DOM): 只负责 API 调用 + * - 接收已处理好的 html 和 markdown + * - 图片上传 (URL 替换,不需要 DOM) + * - 调用平台 API + * + * 适配器接收的 Article 对象: + * - article.html: 已预处理的 HTML (代码块已简化,图片已处理) + * - article.markdown: 已转换的 Markdown + * + * 适配器只需: + * 1. 选择使用 html 还是 markdown + * 2. 处理图片上传 (如果平台需要) + * 3. 调用平台 API + */ +import type { Article, AuthResult, SyncResult, PlatformMeta, HeaderRule } from '../types' +import type { RuntimeInterface } from '../runtime/interface' +import type { PlatformAdapter, PublishOptions } from './types' +import { createLogger } from '../lib/logger' +import { parseMarkdownImages } from '../lib/markdown-images' + +const logger = createLogger('CodeAdapter') + +/** + * 图片上传结果 + */ +export interface ImageUploadResult { + /** 新的图片 URL */ + url: string + /** 额外的 img 属性 */ + attrs?: Record +} + +/** + * 图片处理选项 + */ +export interface ImageProcessOptions { + /** 跳过匹配这些模式的图片 */ + skipPatterns?: string[] + /** 进度回调 */ + onProgress?: (current: number, total: number) => void +} + +/** + * HTML 清理选项 + */ +export interface CleanHtmlOptions { + /** 移除链接标签,保留文字 */ + removeLinks?: boolean + /** 移除 iframe */ + removeIframes?: boolean + /** 移除 SVG 图片 */ + removeSvgImages?: boolean + /** 移除指定标签 */ + removeTags?: string[] + /** 移除指定属性 */ + removeAttrs?: string[] +} + +/** + * 代码适配器基类 + */ +export abstract class CodeAdapter implements PlatformAdapter { + abstract readonly meta: PlatformMeta + protected runtime!: RuntimeInterface + + /** Header 规则 ID 列表(用于请求拦截) */ + protected headerRuleIds: string[] = [] + + async init(runtime: RuntimeInterface): Promise { + this.runtime = runtime + } + + // ============ 抽象方法,子类必须实现 ============ + + abstract checkAuth(): Promise + abstract publish(article: Article, options?: PublishOptions): Promise + + // ============ Header 规则管理 ============ + + /** + * 添加 Header 规则 + * @param rule 规则配置 + * @returns 规则 ID + */ + protected async addHeaderRule(rule: Omit): Promise { + if (!this.runtime.headerRules) return null + + const ruleId = await this.runtime.headerRules.add(rule) + this.headerRuleIds.push(ruleId) + return ruleId + } + + /** + * 批量添加 Header 规则 + * @param rules 规则配置列表 + */ + protected async addHeaderRules(rules: Array>): Promise { + for (const rule of rules) { + await this.addHeaderRule(rule) + } + if (this.headerRuleIds.length > 0) { + logger.debug(`[${this.meta.id}] Header rules added:`, this.headerRuleIds) + } + } + + /** + * 清除所有已添加的 Header 规则 + */ + protected async clearHeaderRules(): Promise { + if (!this.runtime.headerRules || this.headerRuleIds.length === 0) return + + for (const ruleId of this.headerRuleIds) { + await this.runtime.headerRules.remove(ruleId) + } + logger.debug(`[${this.meta.id}] Header rules cleared:`, this.headerRuleIds) + this.headerRuleIds = [] + } + + /** + * 在 Header 规则保护下执行操作 + * 自动设置规则,执行完成后自动清除 + * @param rules 规则配置列表 + * @param fn 要执行的操作 + */ + protected async withHeaderRules( + rules: Array>, + fn: () => Promise + ): Promise { + await this.addHeaderRules(rules) + try { + return await fn() + } finally { + await this.clearHeaderRules() + } + } + + // ============ HTTP 请求能力 ============ + + /** + * GET 请求 + */ + protected async get(url: string, headers?: Record): Promise { + const response = await this.runtime.fetch(url, { + method: 'GET', + credentials: 'include', + headers, + }) + return this.parseResponse(response) + } + + /** + * POST 请求 (JSON) + */ + protected async postJson( + url: string, + data: Record, + headers?: Record + ): Promise { + const response = await this.runtime.fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify(data), + }) + return this.parseResponse(response) + } + + /** + * POST 请求 (Form) + */ + protected async postForm( + url: string, + data: Record, + headers?: Record + ): Promise { + const response = await this.runtime.fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...headers, + }, + body: new URLSearchParams(data), + }) + return this.parseResponse(response) + } + + /** + * POST 请求 (Multipart) + */ + protected async postMultipart( + url: string, + formData: FormData, + headers?: Record + ): Promise { + const response = await this.runtime.fetch(url, { + method: 'POST', + credentials: 'include', + headers, + body: formData, + }) + return this.parseResponse(response) + } + + /** + * 解析响应 + */ + private async parseResponse(response: Response): Promise { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const text = await response.text() + + // 尝试解析 JSON + try { + return JSON.parse(text) as T + } catch { + return text as T + } + } + + // ============ 图片处理能力 ============ + + /** + * 处理文章图片 (使用正则提取 URL,兼容 Service Worker) + * 同时支持 HTML 和 Markdown 格式 + * - HTML: text + * - Markdown: ![text](url) + * + * 注意: 这个方法只做 URL 提取和替换,不涉及 DOM 解析 + */ + protected async processImages( + content: string, + uploadFn: (src: string) => Promise, + options?: ImageProcessOptions + ): Promise { + const { skipPatterns = [], onProgress } = options || {} + + // 提取所有图片(HTML + Markdown) + const matches: { full: string; src: string; alt?: string; type: 'html' | 'markdown' }[] = [] + + // 1. HTML 格式: + const htmlImgRegex = /]+src="([^"]+)"[^>]*>/gi + let match + while ((match = htmlImgRegex.exec(content)) !== null) { + matches.push({ full: match[0], src: match[1], type: 'html' }) + } + + // 2. Markdown 格式: ![alt](url) + for (const mdMatch of parseMarkdownImages(content)) { + matches.push({ full: mdMatch.full, src: mdMatch.src, alt: mdMatch.alt, type: 'markdown' }) + } + + if (matches.length === 0) { + return content + } + + logger.debug(`Found ${matches.length} images to process (HTML + Markdown)`) + + let result = content + const uploadedMap = new Map() + let processed = 0 + + for (const { full, src, alt, type } of matches) { + // 跳过空 src + if (!src) continue + + // 跳过匹配的模式(但不跳过 data URI) + if (!src.startsWith('data:')) { + const shouldSkip = skipPatterns.some(pattern => src.includes(pattern)) + if (shouldSkip) { + logger.debug(`Skipping matched pattern: ${src}`) + continue + } + } + + processed++ + onProgress?.(processed, matches.length) + + try { + // 检查是否已上传过 + let uploadResult = uploadedMap.get(src) + + if (!uploadResult) { + logger.debug(`Uploading image ${processed}/${matches.length}: ${src.startsWith('data:') ? 'data URI' : src}`) + // uploadFn 应该能处理 URL 和 data URI(通过 fetch) + uploadResult = await uploadFn(src) + uploadedMap.set(src, uploadResult) + } + + // 根据格式构建替换内容 + let replacement: string + if (type === 'html') { + // HTML 格式 + replacement = ` { + throw new Error('uploadImageByUrl not implemented') + } + + /** + * 通过 Blob 上传图片(公开方法,实现 PlatformAdapter 接口) + * 默认实现:转为 data URI,调用 uploadImageByUrl + * 子类可以覆盖以提供更优的实现 + */ + async uploadImage(file: Blob, _filename?: string): Promise { + const dataUri = await this.blobToDataUri(file) + const result = await this.uploadImageByUrl(dataUri) + return result.url + } + + /** + * Blob 转 data URI + */ + protected async blobToDataUri(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = reader.result + if (typeof result === 'string') { + resolve(result) + } else { + reject(new Error('Failed to read blob as data URI')) + } + } + reader.onerror = () => reject(new Error('FileReader error')) + reader.readAsDataURL(blob) + }) + } + + /** + * data URI 转 Blob + */ + protected async dataUriToBlob(dataUri: string): Promise { + const response = await fetch(dataUri) + return response.blob() + } + + // ============ 工具方法 ============ + + /** + * 延迟 + */ + protected delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + // ============ HTML 处理能力 ============ + + /** + * 清理 HTML 内容 (使用正则,兼容 Service Worker) + */ + protected cleanHtml(content: string, options: CleanHtmlOptions = {}): string { + let result = content + + // 移除链接标签,保留文字 + if (options.removeLinks) { + result = result.replace(/]*>([\s\S]*?)<\/a>/gi, '$1') + } + + // 移除 iframe + if (options.removeIframes) { + result = result.replace(/]*>[\s\S]*?<\/iframe>/gi, '') + result = result.replace(/]*\/>/gi, '') + } + + // 移除 SVG 图片 + if (options.removeSvgImages) { + result = result.replace(/]+src="[^"]*\.svg"[^>]*>/gi, '') + } + + // 移除指定标签 + if (options.removeTags) { + options.removeTags.forEach(tag => { + const regex = new RegExp(`<${tag}[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi') + result = result.replace(regex, '') + // 自闭合标签 + const selfClosing = new RegExp(`<${tag}[^>]*\\/?>`, 'gi') + result = result.replace(selfClosing, '') + }) + } + + // 移除指定属性 + if (options.removeAttrs) { + options.removeAttrs.forEach(attr => { + if (attr === 'data-*') { + result = result.replace(/\s*data-[\w-]+="[^"]*"/gi, '') + } else { + const regex = new RegExp(`\\s*${attr}="[^"]*"`, 'gi') + result = result.replace(regex, '') + } + }) + } + + return result + } + + /** + * 创建同步结果 + */ + protected createResult(success: boolean, data?: Partial): SyncResult { + return { + platform: this.meta.id, + success, + timestamp: Date.now(), + ...data, + } + } +} diff --git a/sync/adapters/editor-assist.ts b/sync/adapters/editor-assist.ts new file mode 100644 index 0000000..ad426f1 --- /dev/null +++ b/sync/adapters/editor-assist.ts @@ -0,0 +1,275 @@ +/** + * 编辑器辅助适配器基类(半自动同步) + * + * 适用场景: + * - 平台未公开发布接口,或接口需要浏览器端签名(x-s / x-t 等)与风控校验 + * - 社区常用的做法是「打开官方编辑器 + 自动填充内容」,再由用户确认发布 + * + * 流程: + * 1. 打开平台编辑器页面(新标签页) + * 2. 等待加载完成后注入填充脚本:写入标题、正文(富文本) + * 3. 返回结果,提示用户在编辑器中确认并保存草稿/发布 + * + * 注意:填充脚本通过 chrome.scripting.executeScript 注入, + * 因此必须是自包含的纯函数(不能引用模块作用域变量)。 + */ +import type { Article, SyncResult } from '../types' +import type { PublishOptions } from './types' +import { CodeAdapter } from './code-adapter' +import { createLogger } from '../lib/logger' + +const logger = createLogger('EditorAssist') + +/** + * 注入到编辑器的填充脚本(必须自包含) + */ +export function fillEditorScript(payload: { + title: string + html: string + text: string + titleSelectors: string[] + editorSelectors: string[] + removeSelectors: string[] +}): { + titleFilled: boolean + contentFilled: boolean + editorFound: boolean + hint?: string +} { + const { title, html, text, titleSelectors, editorSelectors, removeSelectors } = payload + + function isVisible(el: Element): boolean { + const rect = (el as HTMLElement).getBoundingClientRect?.() + if (!rect) return false + return rect.width > 0 && rect.height > 0 + } + + function queryAny(selectors: string[], root: ParentNode = document): HTMLElement | null { + for (const sel of selectors) { + const nodes = Array.from(root.querySelectorAll(sel)) + const visible = nodes.find(isVisible) || nodes[0] + if (visible) return visible + } + return null + } + + function setNativeValue(el: HTMLElement, value: string) { + const proto = + el instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : el instanceof HTMLInputElement + ? HTMLInputElement.prototype + : null + const descriptor = proto && Object.getOwnPropertyDescriptor(proto, 'value') + if (descriptor && descriptor.set) { + descriptor.set.call(el, value) + } else { + ;(el as HTMLInputElement).value = value + } + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + + function insertRichHtml(el: HTMLElement, content: string) { + el.focus() + + // 清空已有内容 + const selection = window.getSelection() + if (selection) { + const range = document.createRange() + range.selectNodeContents(el) + selection.removeAllRanges() + selection.addRange(range) + } + + let ok = false + try { + ok = document.execCommand('insertHTML', false, content) + } catch { + ok = false + } + + // execCommand 失败时退化为直接写入 innerHTML + if (!ok || !el.textContent || el.textContent.trim().length === 0) { + el.innerHTML = content + el.dispatchEvent( + new InputEvent('input', { bubbles: true, inputType: 'insertText' }) + ) + } + + el.dispatchEvent(new Event('change', { bubbles: true })) + } + + const result: { + titleFilled: boolean + contentFilled: boolean + editorFound: boolean + hint?: string + } = { titleFilled: false, contentFilled: false, editorFound: false } + + // 1. 填充标题 + if (title) { + const titleEl = queryAny(titleSelectors) + if (titleEl) { + if (titleEl instanceof HTMLInputElement || titleEl instanceof HTMLTextAreaElement) { + setNativeValue(titleEl, title) + result.titleFilled = true + } else if (titleEl.isContentEditable) { + insertRichHtml(titleEl, title.replace(/ child.remove()) + } catch { + // 忽略无效选择器 + } + } + + if (editorEl.isContentEditable) { + insertRichHtml(editorEl, html || text) + result.contentFilled = true + } else if ( + editorEl instanceof HTMLTextAreaElement || + editorEl instanceof HTMLInputElement + ) { + setNativeValue(editorEl, text || html) + result.contentFilled = true + } + + // 部分编辑器使用 ProseMirror,需要额外触发一次 input 让框架同步状态 + editorEl.dispatchEvent(new Event('input', { bubbles: true })) + } + + if (!result.editorFound) { + result.hint = '未找到编辑器容器,请手动粘贴内容' + } + + return result +} + +export interface EditorAssistConfig { + /** 编辑器页面地址 */ + editorUrl: string + /** 标题输入框选择器(按优先级) */ + titleSelectors?: string[] + /** 正文编辑器选择器(按优先级) */ + editorSelectors?: string[] + /** 填充前需要从编辑器里移除的元素 */ + removeSelectors?: string[] + /** 填入编辑器后给用户的提示 */ + hint?: string + /** 等待页面渲染的时间(毫秒,默认 3000) */ + waitBeforeFill?: number + /** 纯文本编辑器(如 X):内容以纯文本插入,不做富文本解析 */ + textOnly?: boolean + /** 内容最大长度(超出截断,0 表示不限制) */ + maxLength?: number +} + +/** + * 编辑器辅助适配器 + */ +export abstract class EditorAssistAdapter extends CodeAdapter { + abstract readonly editorConfig: EditorAssistConfig + + /** + * 打开编辑器并填充内容 + */ + protected async openEditorAndFill( + article: Article, + options?: PublishOptions + ): Promise { + const config = this.editorConfig + + if (!this.runtime.tabs) { + throw new Error('当前运行环境不支持打开编辑器,请在扩展中运行') + } + + logger.info('Opening editor:', config.editorUrl) + + const tab = await this.runtime.tabs.create(config.editorUrl, true) + await this.runtime.tabs.waitForLoad(tab.id) + await this.delay(config.waitBeforeFill ?? 3000) + + let text = article.markdown || article.html || '' + if (config.maxLength && text.length > config.maxLength) { + text = text.slice(0, config.maxLength) + } + + const html = config.textOnly ? plainTextToHtml(text) : article.html || '' + + const filled = await this.runtime.tabs.executeScript( + tab.id, + fillEditorScript, + [ + { + title: article.title, + html, + text, + titleSelectors: config.titleSelectors || DEFAULT_TITLE_SELECTORS, + editorSelectors: config.editorSelectors || DEFAULT_EDITOR_SELECTORS, + removeSelectors: config.removeSelectors || [] + } + ] + ) + + logger.debug('Fill result:', filled) + + const message = + filled?.hint || + config.hint || + '已打开编辑器并自动填充内容,请确认后保存草稿或发布' + + const success = Boolean(filled?.contentFilled || filled?.titleFilled) + + return this.createResult(success, { + postUrl: config.editorUrl, + draftOnly: options?.draftOnly ?? false, + message, + error: success ? undefined : '未能在编辑器中定位输入框,请手动粘贴内容' + }) + } +} + +/** 通用标题选择器 */ +const DEFAULT_TITLE_SELECTORS = [ + 'input[placeholder*="标题"]', + 'input[placeholder*="请输入标题"]', + 'textarea[placeholder*="标题"]', + 'input[class*="title"]', + 'textarea[class*="title"]', + '[contenteditable="true"][class*="title"]', + 'h1[contenteditable="true"]' +] + +/** 通用正文编辑器选择器 */ +const DEFAULT_EDITOR_SELECTORS = [ + '.ProseMirror[contenteditable="true"]', + '.tiptap[contenteditable="true"]', + '[contenteditable="true"][class*="editor"]', + '[contenteditable="true"][class*="content"]', + 'div[role="textbox"][contenteditable="true"]', + 'textarea[placeholder*="正文"]', + 'textarea[class*="editor"]' +] + +/** + * 纯文本转 HTML(保留换行的安全文本) + */ +function plainTextToHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
') +} diff --git a/sync/adapters/index.ts b/sync/adapters/index.ts new file mode 100644 index 0000000..db89929 --- /dev/null +++ b/sync/adapters/index.ts @@ -0,0 +1,10 @@ +export * from './types' +export * from './base' +export * from './code-adapter' +export * from './registry' + +// 编辑器辅助适配器基类(半自动同步) +export * from './editor-assist' + +// 平台适配器 +export * from './platforms' diff --git a/sync/adapters/platforms/baijiahao.ts b/sync/adapters/platforms/baijiahao.ts new file mode 100644 index 0000000..9f7f22d --- /dev/null +++ b/sync/adapters/platforms/baijiahao.ts @@ -0,0 +1,210 @@ +/** + * 百家号适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Baijiahao') + +interface BaijiahaoUserInfo { + userid: string + name: string + avatar: string +} + +export class BaijiahaoAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'baijiahao', + name: '百家号', + icon: 'https://www.baidu.com/favicon.ico', + homepage: 'https://baijiahao.baidu.com/', + capabilities: ['article', 'draft', 'image_upload'], + category: '通用', + syncMode: 'api', + } + + /** 预处理配置: 百家号使用 HTML 格式 */ + readonly preprocessConfig = { + outputFormat: 'html' as const, + } + + private userInfo: BaijiahaoUserInfo | null = null + private authToken: string = '' + + /** 百家号 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://baijiahao.baidu.com/*', + headers: { + 'Origin': 'https://baijiahao.baidu.com', + 'Referer': 'https://baijiahao.baidu.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + const res = await this.get<{ + errno: number + errmsg: string + data?: { user: BaijiahaoUserInfo } + }>(`https://baijiahao.baidu.com/builder/app/appinfo?_=${Date.now()}`) + + logger.debug('checkAuth response:', res) + + if (res.errmsg === 'success' && res.data?.user) { + this.userInfo = res.data.user + return { + isAuthenticated: true, + userId: res.data.user.userid, + username: res.data.user.name, + avatar: res.data.user.avatar, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + private async fetchAuthToken(): Promise { + const response = await this.runtime.fetch('https://baijiahao.baidu.com/builder/rc/edit', { + credentials: 'include', + }) + const html = await response.text() + + const match = html.match(/window\.__BJH__INIT__AUTH__\s*=\s*['"]([^'"]+)['"]/) + if (!match) { + throw new Error('登录失效,请重新登录百家号') + } + + const token = match[1] + logger.debug('Auth token obtained') + return token + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + if (!this.userInfo) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录百家号') + } + } + + this.authToken = await this.fetchAuthToken() + + // Use pre-processed HTML content directly + let content = article.html || '' + + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['baijiahao.baidu.com', 'bdstatic.com', 'bcebos.com'], + onProgress: options?.onImageProgress, + } + ) + + const response = await this.runtime.fetch( + 'https://baijiahao.baidu.com/pcui/article/save?callback=bjhdraft', + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'token': this.authToken, + }, + body: new URLSearchParams({ + title: article.title, + content: content, + feed_cat: '1', + len: String(content.length), + activity_list: JSON.stringify([{ id: 408, is_checked: 0 }]), + source_reprinted_allow: '0', + original_status: '0', + original_handler_status: '1', + isBeautify: 'false', + subtitle: '', + bjhtopic_id: '', + bjhtopic_info: '', + type: 'news', + }), + } + ) + + const text = await response.text() + const jsonStr = text.replace(/^bjhdraft\(/, '').replace(/\)$/, '') + const res = JSON.parse(jsonStr) as { + errno: number + errmsg: string + ret?: { article_id: string } + } + + logger.debug('Save response:', res) + + if (res.errmsg !== 'success' || !res.ret?.article_id) { + throw new Error(res.errmsg || '保存草稿失败') + } + + const postId = res.ret.article_id + const draftUrl = `https://baijiahao.baidu.com/builder/rc/edit?type=news&article_id=${postId}` + + return this.createResult(true, { + postId: postId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + protected async uploadImageByUrl(src: string): Promise { + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + const formData = new FormData() + formData.append('media', imageBlob, 'image.jpg') + formData.append('type', 'image') + formData.append('app_id', '1589639493090963') + formData.append('is_waterlog', '1') + formData.append('save_material', '1') + formData.append('no_compress', '0') + formData.append('is_events', '') + formData.append('article_type', 'news') + + const uploadUrl = 'https://baijiahao.baidu.com/pcui/picture/uploadproxy' + const uploadResponse = await this.runtime.fetch(uploadUrl, { + method: 'POST', + credentials: 'include', + body: formData, + }) + + const res = await uploadResponse.json() as { + errno: number + errmsg: string + ret?: { https_url: string } + } + + logger.debug('Image upload response:', res) + + if (res.errmsg !== 'success' || !res.ret?.https_url) { + throw new Error(res.errmsg || '图片上传失败') + } + + return { + url: res.ret.https_url, + } + } +} diff --git a/sync/adapters/platforms/bilibili.ts b/sync/adapters/platforms/bilibili.ts new file mode 100644 index 0000000..6a7b128 --- /dev/null +++ b/sync/adapters/platforms/bilibili.ts @@ -0,0 +1,195 @@ +/** + * B站适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Bilibili') + +interface BilibiliUserInfo { + mid: number + uname: string + face: string + isLogin: boolean +} + +export class BilibiliAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'bilibili', + name: '哔哩哔哩', + icon: 'https://www.bilibili.com/favicon.ico', + homepage: 'https://member.bilibili.com/platform/upload/text', + capabilities: ['article', 'draft', 'image_upload'], + category: '通用', + syncMode: 'api', + } + + /** 预处理配置: B站使用 HTML,移除外链 */ + readonly preprocessConfig = { + outputFormat: 'html' as const, + removeLinks: true, + } + + private userInfo: BilibiliUserInfo | null = null + private csrf: string = '' + + /** B站 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://api.bilibili.com/*', + headers: { + 'Origin': 'https://member.bilibili.com', + 'Referer': 'https://member.bilibili.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + const res = await this.get<{ + code: number + data?: BilibiliUserInfo + }>('https://api.bilibili.com/x/web-interface/nav?build=0&mobi_app=web') + + logger.debug('checkAuth response:', res) + + if (res.code === 0 && res.data?.isLogin) { + this.userInfo = res.data + await this.fetchCsrf() + + return { + isAuthenticated: true, + userId: String(res.data.mid), + username: res.data.uname, + avatar: res.data.face, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + private async fetchCsrf(): Promise { + try { + if (this.runtime.getCookie) { + const value = await this.runtime.getCookie('.bilibili.com', 'bili_jct') + this.csrf = value || '' + } + logger.debug('CSRF token:', this.csrf ? 'obtained' : 'not found') + } catch (e) { + logger.error('Failed to get CSRF:', e) + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + if (!this.userInfo) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录B站') + } + } + + if (!this.csrf) { + throw new Error('获取 CSRF token 失败,请刷新页面后重试') + } + + // Use pre-processed HTML content directly + let content = article.html || '' + + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['hdslb.com', 'bilibili.com', 'biliimg.com'], + onProgress: options?.onImageProgress, + } + ) + + const res = await this.postForm<{ + code: number + message?: string + data?: { aid: number } + }>( + 'https://api.bilibili.com/x/article/creative/draft/addupdate', + { + tid: '4', + title: article.title, + content: content, + csrf: this.csrf, + save: '0', + pgc_id: '0', + } + ) + + logger.debug('Draft response:', res) + + if (res.code !== 0 || !res.data?.aid) { + throw new Error(res.message || '保存草稿失败') + } + + const draftUrl = `https://member.bilibili.com/platform/upload/text/edit?aid=${res.data.aid}` + + return this.createResult(true, { + postId: String(res.data.aid), + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + protected async uploadImageByUrl(src: string): Promise { + if (!this.csrf) { + throw new Error('CSRF token 未获取') + } + + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + const formData = new FormData() + formData.append('binary', imageBlob, 'image.jpg') + formData.append('csrf', this.csrf) + + const uploadUrl = 'https://api.bilibili.com/x/article/creative/article/upcover' + const uploadResponse = await this.runtime.fetch(uploadUrl, { + method: 'POST', + credentials: 'include', + body: formData, + }) + + const res = await uploadResponse.json() as { + code: number + message?: string + data?: { + url: string + size: number + } + } + + logger.debug('Image upload response:', res) + + if (res.code !== 0 || !res.data?.url) { + throw new Error(res.message || '图片上传失败') + } + + return { + url: res.data.url, + attrs: { + size: String(res.data.size), + }, + } + } +} diff --git a/sync/adapters/platforms/cnblogs.ts b/sync/adapters/platforms/cnblogs.ts new file mode 100644 index 0000000..c744cf1 --- /dev/null +++ b/sync/adapters/platforms/cnblogs.ts @@ -0,0 +1,313 @@ +/** + * 博客园 (cnblogs.com) 适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Cnblogs') + +export class CnblogsAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'cnblogs', + name: '博客园', + icon: 'https://www.cnblogs.com/favicon.ico', + homepage: 'https://www.cnblogs.com', + capabilities: ['article', 'draft', 'image_upload'], + category: '技术社区', + syncMode: 'api', + } + + /** 预处理配置: 博客园使用 Markdown 格式 */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + } + + private xsrfToken: string | null = null + + /** 博客园 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://i.cnblogs.com/*', + headers: { + 'Origin': 'https://i.cnblogs.com', + 'Referer': 'https://i.cnblogs.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + { + urlFilter: '*://upload.cnblogs.com/*', + headers: { + 'Origin': 'https://i.cnblogs.com', + 'Referer': 'https://i.cnblogs.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + /** + * 从 cookie 中获取 XSRF-TOKEN + */ + private async getXsrfToken(): Promise { + if (this.xsrfToken) { + return this.xsrfToken + } + + try { + // 先访问页面以触发 cookie 设置 + await this.runtime.fetch('https://i.cnblogs.com/posts/edit', { + method: 'GET', + credentials: 'include', + }) + + // 使用 cookies API 获取 XSRF-TOKEN + if (this.runtime.getCookie) { + logger.debug('Trying to get XSRF-TOKEN via getCookie API...') + + // 尝试不同的域名格式 + const domains = ['i.cnblogs.com', '.cnblogs.com', 'cnblogs.com'] + for (const domain of domains) { + const value = await this.runtime.getCookie(domain, 'XSRF-TOKEN') + logger.debug(`getCookie ${domain} result:`, value ? `${value.substring(0, 30)}...` : 'null') + if (value) { + this.xsrfToken = value + logger.debug('Got XSRF-TOKEN from cookies API') + return this.xsrfToken + } + } + } else { + logger.warn('getCookie API not available') + } + + logger.warn('Could not find XSRF-TOKEN') + return null + } catch (error) { + logger.error('Failed to get XSRF-TOKEN:', error) + return null + } + } + + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch('https://home.cnblogs.com/user/CurrentUserInfo', { + method: 'GET', + credentials: 'include', + }) + + const text = await response.text() + + // 解析 HTML 响应获取用户信息 + // 页面结构: + const avatarMatch = text.match(/]+class="pfs"[^>]+src="([^"]+)"/) + const linkMatch = text.match(/href="\/u\/([^/]+)\/"/) + + if (!linkMatch) { + return { isAuthenticated: false } + } + + const uid = linkMatch[1] + const avatar = avatarMatch ? avatarMatch[1] : undefined + + return { + isAuthenticated: true, + userId: uid, + username: uid, + avatar, + } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish to cnblogs...') + + // 1. Get XSRF-TOKEN + const xsrfToken = await this.getXsrfToken() + logger.info('XSRF-TOKEN:', xsrfToken ? `${xsrfToken.substring(0, 20)}...` : 'null') + if (!xsrfToken) { + throw new Error('获取 XSRF-TOKEN 失败,请刷新页面后重试') + } + + // 保存 xsrfToken 供 uploadImageByUrl 使用 + this.xsrfToken = xsrfToken + + // 2. 处理图片上传 + let markdown = article.markdown || '' + logger.debug('Markdown before processImages:', markdown.substring(0, 200)) + + markdown = await this.processImages( + markdown, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['cnblogs.com', 'img2024.cnblogs.com', 'img2023.cnblogs.com'], + onProgress: options?.onImageProgress, + } + ) + + logger.debug('Markdown after processImages:', markdown.substring(0, 200)) + + // 3. Build request headers + const headers: Record = { + 'Content-Type': 'application/json', + 'x-xsrf-token': xsrfToken, + } + + logger.debug('Request headers:', JSON.stringify(headers)) + logger.debug('Markdown content length:', markdown.length) + + // 4. 创建草稿 + const response = await this.runtime.fetch('https://i.cnblogs.com/api/posts', { + method: 'POST', + credentials: 'include', + headers, + body: JSON.stringify({ + id: null, + postType: 2, // 2 = 文章, 1 = 随笔 + accessPermission: 0, + title: article.title, + url: null, + postBody: markdown, + categoryIds: null, + categories: null, + collectionIds: [], + inSiteCandidate: false, + inSiteHome: false, + siteCategoryId: null, + blogTeamIds: null, + isPublished: false, + displayOnHomePage: false, + isAllowComments: true, + includeInMainSyndication: false, + isPinned: false, + showBodyWhenPinned: false, + isOnlyForRegisterUser: false, + isUpdateDateAdded: false, + entryName: null, + description: null, + featuredImage: null, + tags: null, + password: null, + publishAt: null, + datePublished: new Date().toISOString(), + dateUpdated: null, + isMarkdown: true, + isDraft: true, + autoDesc: null, + changePostType: false, + blogId: 0, + author: null, + removeScript: false, + clientInfo: null, + changeCreatedTime: false, + canChangeCreatedTime: false, + isContributeToImpressiveBugActivity: false, + usingEditorId: 5, + sourceUrl: null, + }), + }) + + // 检查响应 + const responseText = await response.text() + logger.debug('Create post response:', response.status, responseText.substring(0, 300)) + + if (!response.ok) { + // 检查是否是认证错误 + if (response.status === 401 || response.status === 403) { + throw new Error('未登录或登录已过期,请重新登录博客园') + } + throw new Error(`创建草稿失败: ${response.status} - ${responseText}`) + } + + let responseData: { id?: number; blogId?: number; error?: string } + try { + responseData = JSON.parse(responseText) + } catch { + throw new Error(`创建草稿失败: 响应不是有效 JSON - ${responseText.substring(0, 100)}`) + } + + if (!responseData.id) { + throw new Error(responseData.error || '创建草稿失败: 无效响应') + } + + const postId = String(responseData.id) + const draftUrl = `https://i.cnblogs.com/articles/edit;postId=${postId}` + + logger.debug('Draft created:', postId) + + return this.createResult(true, { + postId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + /** + * 上传图片到博客园 + * 使用新版 CORS 上传接口 + */ + protected async uploadImageByUrl(src: string): Promise { + if (!this.xsrfToken) { + throw new Error('XSRF-TOKEN 未获取') + } + + // 下载图片 + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + // 构建 FormData + const formData = new FormData() + formData.append('image', imageBlob, 'image.png') + formData.append('app', 'blog') + formData.append('uploadType', 'Select') + + // 上传图片 + const uploadResponse = await this.runtime.fetch( + 'https://upload.cnblogs.com/v2/images/cors-upload', + { + method: 'POST', + credentials: 'include', + headers: { + 'x-xsrf-token': this.xsrfToken, + }, + body: formData, + } + ) + + const responseText = await uploadResponse.text() + logger.debug('Image upload raw response:', responseText) + + if (!uploadResponse.ok) { + throw new Error(`图片上传失败: ${uploadResponse.status} - ${responseText}`) + } + + let res: Record + try { + res = JSON.parse(responseText) + } catch { + throw new Error(`图片上传失败: 响应不是 JSON - ${responseText.substring(0, 100)}`) + } + + logger.debug('Image upload parsed response:', JSON.stringify(res)) + + // 尝试不同的响应格式 + const imageUrl = res.data || res.url || res.imageUrl || res.src + if (!imageUrl || typeof imageUrl !== 'string') { + throw new Error(`图片上传失败: 无法获取图片 URL - ${JSON.stringify(res)}`) + } + + logger.info('Image uploaded:', imageUrl) + return { + url: imageUrl, + } + } +} diff --git a/sync/adapters/platforms/csdn.ts b/sync/adapters/platforms/csdn.ts new file mode 100644 index 0000000..4ae7873 --- /dev/null +++ b/sync/adapters/platforms/csdn.ts @@ -0,0 +1,397 @@ +/** + * CSDN 适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('CSDN') + +interface CSDNUserInfo { + csdnid: string + username: string + avatarurl: string +} + +export class CSDNAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'csdn', + name: 'CSDN', + icon: 'https://g.csdnimg.cn/static/logo/favicon32.ico', + homepage: 'https://editor.csdn.net/md/', + capabilities: ['article', 'draft', 'image_upload'], + category: '技术社区', + syncMode: 'api', + } + + /** 预处理配置: CSDN 使用 Markdown 格式 */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + } + + private userInfo: CSDNUserInfo | null = null + + // CSDN API 签名密钥 + private readonly API_KEY = '203803574' + private readonly API_SECRET = '9znpamsyl2c7cdrr9sas0le9vbc3r6ba' + + /** CSDN API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://bizapi.csdn.net/*', + headers: { + 'Origin': 'https://editor.csdn.net', + 'Referer': 'https://editor.csdn.net/', + }, + resourceTypes: ['xmlhttprequest'], + }, + { + urlFilter: '*://imgservice.csdn.net/*', + headers: { + 'Origin': 'https://editor.csdn.net', + 'Referer': 'https://editor.csdn.net/', + }, + resourceTypes: ['xmlhttprequest'], + }, + { + urlFilter: '*://csdn-img-blog.obs.cn-north-4.myhuaweicloud.com/*', + headers: { + 'Origin': 'https://editor.csdn.net', + 'Referer': 'https://editor.csdn.net/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + // 使用带签名的 API + const apiPath = '/blog-console-api/v3/editor/getBaseInfo' + const headers = await this.signRequest(apiPath, 'GET') + + const response = await this.runtime.fetch( + `https://bizapi.csdn.net${apiPath}`, + { + method: 'GET', + credentials: 'include', + headers, + } + ) + + const res = await response.json() as { + code: number + data?: { + name: string + nickname: string + avatar: string + blog_url: string + } + } + + logger.debug('checkAuth response:', res) + + if (res.code === 200 && res.data?.name) { + this.userInfo = { + csdnid: res.data.name, + username: res.data.nickname || res.data.name, + avatarurl: res.data.avatar, + } + return { + isAuthenticated: true, + userId: res.data.name, + username: res.data.nickname || res.data.name, + avatar: res.data.avatar, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 生成 UUID + */ + private createUuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) + } + + /** + * HMAC-SHA256 签名 (使用 Web Crypto API) + */ + private async hmacSha256(message: string, secret: string): Promise { + const encoder = new TextEncoder() + const keyData = encoder.encode(secret) + const messageData = encoder.encode(message) + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + + const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData) + + // 转换为 Base64 + const bytes = new Uint8Array(signature) + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) + } + + /** + * 生成 CSDN API 签名 + * 签名格式: METHOD\nAccept\nContent-MD5\nContent-Type\n\nHeaders\nPath + */ + private async signRequest(apiPath: string, method: 'GET' | 'POST' = 'POST'): Promise> { + const nonce = this.createUuid() + + // GET: 没有 Content-Type,所以那一行为空 + // POST: Content-Type 为 application/json + const signStr = method === 'GET' + ? `GET\n*/*\n\n\n\nx-ca-key:${this.API_KEY}\nx-ca-nonce:${nonce}\n${apiPath}` + : `POST\n*/*\n\napplication/json\n\nx-ca-key:${this.API_KEY}\nx-ca-nonce:${nonce}\n${apiPath}` + + logger.debug('Sign string:', JSON.stringify(signStr)) + + const signature = await this.hmacSha256(signStr, this.API_SECRET) + + const headers: Record = { + 'accept': '*/*', + 'x-ca-key': this.API_KEY, + 'x-ca-nonce': nonce, + 'x-ca-signature': signature, + 'x-ca-signature-headers': 'x-ca-key,x-ca-nonce', + } + + if (method === 'POST') { + headers['content-type'] = 'application/json' + } + + return headers + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + // 1. 确保已登录 + if (!this.userInfo) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录 CSDN') + } + } + + // Use pre-processed markdown content directly + let markdown = article.markdown || '' + + // Process images in markdown + markdown = await this.processImages( + markdown, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['csdnimg.cn', 'csdn.net'], + onProgress: options?.onImageProgress, + } + ) + + // Get HTML content (CSDN API needs both markdown and HTML) + const htmlContent = article.html || '' + + // Generate signature and save article + const apiPath = '/blog-console-api/v3/mdeditor/saveArticle' + const headers = await this.signRequest(apiPath) + + const response = await this.runtime.fetch( + `https://bizapi.csdn.net${apiPath}`, + { + method: 'POST', + credentials: 'include', + headers, + body: JSON.stringify({ + title: article.title, + markdowncontent: markdown, + content: htmlContent, + readType: 'public', + level: 0, + tags: '', + status: 2, // 草稿 + categories: '', + type: 'original', + original_link: '', + authorized_status: false, + not_auto_saved: '1', + source: 'pc_mdeditor', + cover_images: [], + cover_type: 1, + is_new: 1, + vote_id: 0, + resource_id: '', + pubStatus: 'draft', + creator_activity_id: '', + }), + } + ) + + const res = await response.json() as { + code: number + message?: string + msg?: string + data?: { id: string } + } + + logger.debug('Save response:', res) + + if (res.code !== 200 || !res.data?.id) { + throw new Error(res.msg || res.message || '保存草稿失败') + } + + const postId = res.data.id + const draftUrl = `https://editor.csdn.net/md?articleId=${postId}` + + return this.createResult(true, { + postId: postId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + /** + * 通过 Blob 上传图片(覆盖基类方法) + * 需要设置动态请求头规则以支持 MCP 调用 + */ + async uploadImage(file: Blob, _filename?: string): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + // 转为 data URI 然后调用 uploadImageByUrl + const dataUri = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(file) + }) + const result = await this.uploadImageByUrl(dataUri) + return result.url + }) + } + + /** + * 通过 URL 上传图片 + */ + protected async uploadImageByUrl(src: string): Promise { + // 1. 下载图片 + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + // 2. 获取文件扩展名 + const ext = src.split('.').pop()?.toLowerCase()?.split('?')[0] || 'jpg' + const validExt = ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext) ? ext : 'jpg' + + // 3. 获取上传签名 (新 API: bizapi.csdn.net) + const apiPath = '/resource-api/v1/image/direct/upload/signature' + const headers = await this.signRequest(apiPath, 'POST') + + const signatureRes = await this.runtime.fetch( + `https://bizapi.csdn.net${apiPath}`, + { + method: 'POST', + credentials: 'include', + headers, + body: JSON.stringify({ + imageTemplate: '', + appName: 'direct_blog_markdown', + imageSuffix: validExt, + }), + } + ) + + const signatureData = await signatureRes.json() as { + code: number + data?: { + filePath: string + host: string + accessId: string + policy: string + signature: string + callbackUrl: string + callbackBody: string + callbackBodyType: string + customParam: { + rtype: string + filePath: string + isAudit: number + 'x-image-app': string + type: string + 'x-image-suffix': string + username: string + } + } + } + + logger.debug('Upload signature response:', signatureData) + + if (signatureData.code !== 200 || !signatureData.data) { + logger.warn('Failed to get upload signature, using original URL') + return { url: src } + } + + const uploadData = signatureData.data + const customParam = uploadData.customParam + + // 4. 上传到华为云 OBS + const formData = new FormData() + formData.append('key', uploadData.filePath) + formData.append('policy', uploadData.policy) + formData.append('signature', uploadData.signature) + formData.append('callbackBody', uploadData.callbackBody) + formData.append('callbackBodyType', uploadData.callbackBodyType) + formData.append('callbackUrl', uploadData.callbackUrl) + formData.append('AccessKeyId', uploadData.accessId) + formData.append('x:rtype', customParam.rtype) + formData.append('x:filePath', customParam.filePath) + formData.append('x:isAudit', String(customParam.isAudit)) + formData.append('x:x-image-app', customParam['x-image-app']) + formData.append('x:type', customParam.type) + formData.append('x:x-image-suffix', customParam['x-image-suffix']) + formData.append('x:username', customParam.username) + formData.append('file', imageBlob, `image.${validExt}`) + + const obsResponse = await this.runtime.fetch(uploadData.host, { + method: 'POST', + body: formData, + }) + + const obsRes = await obsResponse.json() as { + code: number + data?: { imageUrl: string } + } + + logger.debug('OBS upload response:', obsRes) + + if (obsRes.code !== 200 || !obsRes.data?.imageUrl) { + logger.warn('OBS upload failed, using original URL') + return { url: src } + } + + return { + url: obsRes.data.imageUrl, + } + } +} diff --git a/sync/adapters/platforms/cto51.ts b/sync/adapters/platforms/cto51.ts new file mode 100644 index 0000000..87ca7d1 --- /dev/null +++ b/sync/adapters/platforms/cto51.ts @@ -0,0 +1,300 @@ +/** + * 51CTO 适配器 + * https://blog.51cto.com + * + * 新版图片上传流程: + * 1. getUploadSign - 获取上传签名 + * 2. getUploadConfig - 获取腾讯云 COS 上传凭证 + * 3. 上传到腾讯云 COS + */ +import { CodeAdapter } from '../code-adapter' +import type { ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' + +interface UploadSignResponse { + code: number + msg: string + data: { + allows: string + sizeLimit: number + sizeLimitMessage: string + url: string + sign: string + } +} + +interface UploadConfigResponse { + code: number + msg: string + data: { + url: string + fields: { + key: string + policy: string + 'x-amz-algorithm': string + 'x-amz-signature': string + 'x-amz-credential': string + 'X-Amz-Date': string + } + } +} + +export class Cto51Adapter extends CodeAdapter { + meta: PlatformMeta = { + id: '51cto', + name: '51CTO', + icon: 'https://blog.51cto.com/favicon.ico', + homepage: 'https://blog.51cto.com/blogger/publish', + capabilities: ['article', 'draft', 'image_upload'], + category: '技术社区', + syncMode: 'api', + } + + /** 预处理配置: 51CTO 使用 Markdown 格式 */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + } + + private csrf: string | null = null + + /** 51CTO API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://blog.51cto.com/*', + headers: { + Origin: 'https://blog.51cto.com', + Referer: 'https://blog.51cto.com/blogger/publish', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + /** + * 检查登录状态 + */ + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch('https://blog.51cto.com/blogger/publish', { + credentials: 'include', + }) + const html = await response.text() + + // 解析页面获取用户信息 + const imgMatch = html.match(/
  • \s*]*href="([^"]+)"[^>]*>\s*]*src="([^"]+)"/) + if (!imgMatch) { + return { isAuthenticated: false, error: '未登录' } + } + + const userLink = imgMatch[1] + const avatar = imgMatch[2] + const uid = userLink.split('/').filter(Boolean).pop() || '' + + // 获取 csrf token + const csrfMatch = html.match(/ { + const response = await this.runtime.fetch('https://blog.51cto.com/getUploadSign', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Referer': 'https://blog.51cto.com/blogger/publish', + 'Origin': 'https://blog.51cto.com', + }, + body: 'upload_type=image', + }) + + const res: UploadSignResponse = await response.json() + if (res.code !== 0) { + throw new Error(res.msg || '获取上传签名失败') + } + return res.data + } + + /** + * 获取上传配置 (腾讯云 COS 凭证) + */ + private async getUploadConfig( + uploadSign: string, + ext: string, + filename: string + ): Promise { + const response = await this.runtime.fetch('https://blog.51cto.com/getUploadConfig', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new URLSearchParams({ + upload_type: 'image', + upload_sign: uploadSign, + ext: ext, + name: filename, + }).toString(), + }) + + const res: UploadConfigResponse = await response.json() + if (res.code !== 0) { + throw new Error(res.msg || '获取上传配置失败') + } + return res.data + } + + /** + * 上传图片到腾讯云 COS + */ + private async uploadToCOS( + cosUrl: string, + fields: UploadConfigResponse['data']['fields'], + file: File + ): Promise { + const formData = new FormData() + + // 按顺序添加字段 (顺序很重要) + formData.append('key', fields.key) + formData.append('policy', fields.policy) + formData.append('x-amz-algorithm', fields['x-amz-algorithm']) + formData.append('x-amz-signature', fields['x-amz-signature']) + formData.append('x-amz-credential', fields['x-amz-credential']) + formData.append('X-Amz-Date', fields['X-Amz-Date']) + formData.append('Content-Type', file.type) + formData.append('file', file) + + const response = await this.runtime.fetch(cosUrl, { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + throw new Error(`上传到 COS 失败: ${response.status}`) + } + + // 返回图片 URL (通过 51cto CDN) + return `https://s2.51cto.com/${fields.key}` + } + + /** + * 上传图片 + */ + async uploadImageByUrl(url: string): Promise { + // 下载图片 + const imageResponse = await this.runtime.fetch(url) + const blob = await imageResponse.blob() + + // 确定文件扩展名和 MIME 类型 + const mimeType = blob.type || 'image/jpeg' + const ext = mimeType.split('/')[1] || 'jpeg' + const filename = `${Date.now()}.${ext}` + const file = new File([blob], filename, { type: mimeType }) + + // Step 1: 获取上传签名 + const signData = await this.getUploadSign() + + // Step 2: 获取上传配置 + const configData = await this.getUploadConfig(signData.sign, mimeType, filename) + + // Step 3: 上传到腾讯云 COS + const imageUrl = await this.uploadToCOS(configData.url, configData.fields, file) + + return { url: imageUrl } + } + + /** + * 发布文章 + */ + async publish(article: Article): Promise { + const now = Date.now() + return this.withHeaderRules(this.HEADER_RULES, async () => { + // 确保已获取 csrf + if (!this.csrf) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('未登录') + } + } + + // 优先使用 markdown,处理图片 + const hasMarkdown = !!article.markdown + let content = article.markdown || article.html || '' + content = await this.processImages(content, (src) => this.uploadImageByUrl(src)) + + // 构建请求数据 + const postData: Record = { + title: article.title, + content: content, + pid: '', + cate_id: '', + custom_id: '0', + tag: '', + abstract: '', + banner_type: '0', + blog_type: '1', + copy_code: '1', + is_hide: '0', + top_time: '0', + is_comment: '0', + is_old: hasMarkdown ? '0' : '2', + blog_id: '', + did: '', + work_id: '', + class_id: '', + subjectId: '', + import_type: '-1', + invite_code: '', + raffle: '', + orig: '', + _csrf: this.csrf || '', + } + + const response = await this.runtime.fetch('https://blog.51cto.com/blogger/draft', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json, text/javascript, */*; q=0.01', + }, + body: new URLSearchParams(postData).toString(), + }) + + const res = await response.json() + + if (res.status !== 1 || !res.data) { + throw new Error(res.msg || '发布失败') + } + + return { + platform: this.meta.id, + success: true, + postId: String(res.data.did), + postUrl: `https://blog.51cto.com/blogger/draft/${res.data.did}`, + draftOnly: true, + timestamp: now, + } + }).catch((error) => ({ + platform: this.meta.id, + success: false, + error: (error as Error).message, + timestamp: now, + })) + } +} diff --git a/sync/adapters/platforms/dayu.ts b/sync/adapters/platforms/dayu.ts new file mode 100644 index 0000000..95d4a8d --- /dev/null +++ b/sync/adapters/platforms/dayu.ts @@ -0,0 +1,286 @@ +/** + * 大鱼号适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import { markdownToHtml } from '../../lib/turndown' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('DaYu') + +interface DaYuMeta { + utoken: string + uploadSign: string + uid: string + title: string + avatar: string +} + +export class DaYuAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'dayu', + name: '大鱼号', + icon: 'https://image.uc.cn/s/uae/g/1v/images/index/favicon.ico', + homepage: 'https://mp.dayu.com/dashboard/account/profile', + capabilities: ['article', 'draft', 'image_upload'], + category: '通用', + syncMode: 'api', + } + + private cacheMeta: DaYuMeta | null = null + private uploadedImages: Array<{ org_url: string; url: string }> = [] + + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch( + 'https://mp.dayu.com/dashboard/index', + { + method: 'GET', + credentials: 'include', + } + ) + + const pageHtml = await response.text() + const markStr = 'var globalConfig = ' + const authIndex = pageHtml.indexOf(markStr) + + if (authIndex === -1) { + return { isAuthenticated: false } + } + + const authTokenStr = pageHtml.substring( + authIndex + markStr.length, + pageHtml.indexOf('var G = {', authIndex) + ) + + // 使用 JSON 解析代替 eval + const pageConfig = this.parseGlobalConfig(authTokenStr) + + if (!pageConfig || !pageConfig.utoken) { + return { isAuthenticated: false } + } + + this.cacheMeta = { + utoken: pageConfig.utoken, + uploadSign: pageConfig.nsImageUploadSign, + uid: pageConfig.wmid, + title: pageConfig.weMediaName, + avatar: pageConfig.wmAvator?.indexOf('http') > -1 + ? pageConfig.wmAvator + : pageConfig.wmAvator?.replace('//', 'https://') || '', + } + + return { + isAuthenticated: true, + userId: this.cacheMeta.uid, + username: this.cacheMeta.title, + avatar: this.cacheMeta.avatar, + } + } catch (error) { + logger.error('checkAuth error:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 解析 globalConfig JavaScript 对象 + */ + private parseGlobalConfig(configStr: string): Record | null { + try { + // 尝试清理并解析 JavaScript 对象字面量 + // 移除末尾的分号和空白 + let cleaned = configStr.trim() + if (cleaned.endsWith(';')) { + cleaned = cleaned.slice(0, -1) + } + + // 尝试用 JSON 解析(如果格式兼容) + // 将单引号替换为双引号,处理无引号的 key + const jsonStr = cleaned + .replace(/'/g, '"') + .replace(/(\w+):/g, '"$1":') + .replace(/,\s*}/g, '}') + .replace(/,\s*]/g, ']') + + return JSON.parse(jsonStr) + } catch { + // 如果 JSON 解析失败,使用正则提取关键字段 + const result: Record = {} + + const patterns: Record = { + utoken: /utoken['":\s]+['"]([^'"]+)['"]/, + nsImageUploadSign: /nsImageUploadSign['":\s]+['"]([^'"]+)['"]/, + wmid: /wmid['":\s]+['"]([^'"]+)['"]/, + weMediaName: /weMediaName['":\s]+['"]([^'"]+)['"]/, + wmAvator: /wmAvator['":\s]+['"]([^'"]+)['"]/, + } + + for (const [key, pattern] of Object.entries(patterns)) { + const match = configStr.match(pattern) + if (match) { + result[key] = match[1] + } + } + + return Object.keys(result).length > 0 ? result : null + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + try { + logger.info('Starting publish...') + + // 重置上传图片列表 + this.uploadedImages = [] + + // 1. 确保已登录 + if (!this.cacheMeta) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录大鱼号') + } + } + + // 2. 获取 HTML 内容 + const rawHtml = article.html || markdownToHtml(article.markdown) + + // 3. 清理内容 + let content = this.cleanHtml(rawHtml, { + removeIframes: true, + removeSvgImages: true, + removeTags: ['qqmusic'], + removeAttrs: ['data-reader-unique-id'], + }) + + // 4. 处理图片 + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['dayu.com', 'uc.cn'], + onProgress: options?.onImageProgress, + } + ) + + // 5. 获取封面图 + const coverImg = this.uploadedImages.length > 0 + ? this.uploadedImages[0].org_url + : '' + + // 6. 保存草稿 + const formData = new URLSearchParams() + formData.append('title', article.title) + formData.append('content', content) + formData.append('author', this.cacheMeta!.title) + formData.append('coverImg', coverImg) + formData.append('article_type', '1') + formData.append('utoken', this.cacheMeta!.utoken) + formData.append('cover_from', 'auto') + + const response = await this.runtime.fetch( + 'https://mp.dayu.com/dashboard/save-draft', + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'utoken': this.cacheMeta!.utoken, + }, + body: formData, + } + ) + + const res = await response.json() as { + error?: string + data?: { _id: string } + } + + logger.debug('Save response:', res) + + if (res.error) { + throw new Error(res.error) + } + + if (!res.data?._id) { + throw new Error('保存草稿失败') + } + + const postId = res.data._id + const draftUrl = `https://mp.dayu.com/dashboard/article/write?draft_id=${postId}` + + return this.createResult(true, { + postId: postId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + } catch (error) { + return this.createResult(false, { + error: (error as Error).message, + }) + } + } + + /** + * 通过 URL 上传图片 + */ + protected async uploadImageByUrl(src: string): Promise { + if (!this.cacheMeta) { + throw new Error('未登录') + } + + // 1. 下载图片 + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + // 2. 构建上传 URL + const uploadUrl = `https://ns.dayu.com/article/imageUpload?appid=website&fromMaterial=0&wmid=${this.cacheMeta.uid}&wmname=${encodeURIComponent(this.cacheMeta.title)}&sign=${this.cacheMeta.uploadSign}` + + // 3. 上传图片 + const formData = new FormData() + const fileName = `${Date.now()}.jpg` + formData.append('upfile', imageBlob, fileName) + formData.append('type', imageBlob.type || 'image/jpeg') + formData.append('id', 'WU_FILE_1') + formData.append('fileid', `uploadm-${Math.floor(Math.random() * 1000000)}`) + formData.append('name', fileName) + formData.append('lastModifiedDate', new Date().toString()) + formData.append('size', String(imageBlob.size)) + + const uploadResponse = await this.runtime.fetch(uploadUrl, { + method: 'POST', + credentials: 'include', + body: formData, + }) + + const res = await uploadResponse.json() as { + data?: { + imgInfo?: { + org_url: string + url: string + } + } + } + + logger.debug('Image upload response:', res) + + if (!res.data?.imgInfo?.url) { + throw new Error('图片上传失败') + } + + const image = { + org_url: res.data.imgInfo.org_url, + url: res.data.imgInfo.url, + } + + // 保存上传的图片信息(用于封面) + this.uploadedImages.push(image) + + return { + url: image.url, + } + } +} diff --git a/sync/adapters/platforms/douban.ts b/sync/adapters/platforms/douban.ts new file mode 100644 index 0000000..67ebf17 --- /dev/null +++ b/sync/adapters/platforms/douban.ts @@ -0,0 +1,263 @@ +/** + * 豆瓣适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { DoubanImageData } from '../../lib' +import type { PublishOptions } from '../types' +import { markdownToDraft } from '../../lib' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Douban') + +interface DoubanFormData { + note_id: string + ck: string +} + +interface DoubanPostParams { + siteCookie: { + value: string + } +} + +export class DoubanAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'douban', + name: '豆瓣', + icon: 'https://www.douban.com/favicon.ico', + homepage: 'https://www.douban.com/note/create', + capabilities: ['article', 'draft', 'image_upload'], + category: '通用', + syncMode: 'api', + } + + /** 预处理配置: 豆瓣使用 Markdown 格式 (转换为 Draft.js) */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + } + + private username: string = '' + private avatar: string = '' + private formData: DoubanFormData | null = null + private postParams: DoubanPostParams | null = null + + /** 豆瓣 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://www.douban.com/*', + headers: { + 'Origin': 'https://www.douban.com', + 'Referer': 'https://www.douban.com', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch( + 'https://www.douban.com/note/create', + { + method: 'GET', + credentials: 'include', + } + ) + + const html = await response.text() + + // 解析页面中的 JavaScript 变量 + const userNameMatch = html.match(/_USER_NAME\s*=\s*['"]([^'"]+)['"]/) + const userAvatarMatch = html.match(/_USER_AVATAR\s*=\s*['"]([^'"]+)['"]/) + const noteIdMatch = html.match(/name="note_id"\s+value="(\d+)"/) + const ckMatch = html.match(/name="ck"\s+value="([^"]+)"/) + + // 解析 _POST_PARAMS + const postParamsMatch = html.match(/_POST_PARAMS\s*=\s*(\{[\s\S]*?\});/) + + if (!userNameMatch || !noteIdMatch || !ckMatch) { + return { isAuthenticated: false } + } + + this.username = userNameMatch[1] + this.avatar = userAvatarMatch ? userAvatarMatch[1] : '' + this.formData = { + note_id: noteIdMatch[1], + ck: ckMatch[1], + } + + // 解析 _POST_PARAMS 获取 upload_auth_token + if (postParamsMatch) { + try { + // 简化解析,只提取 siteCookie.value + const siteCookieMatch = postParamsMatch[1].match(/siteCookie[^}]*value\s*:\s*['"]([^'"]+)['"]/) + if (siteCookieMatch) { + this.postParams = { + siteCookie: { value: siteCookieMatch[1] } + } + } + } catch (e) { + logger.warn('Failed to parse _POST_PARAMS:', e) + } + } + + logger.debug('Auth info:', { + username: this.username, + noteId: this.formData.note_id, + hasPostParams: !!this.postParams, + }) + + return { + isAuthenticated: true, + userId: this.username, + username: this.username, + avatar: this.avatar, + } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + // 1. 确保已登录 + if (!this.formData) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录豆瓣') + } + } + + // Use pre-processed markdown content directly + let content = article.markdown || '' + + // Process images - collect full image data + const imageDataMap = new Map() + content = await this.processImages( + content, + async (src) => { + const result = await this.uploadImageWithFullData(src) + // 保存完整图片数据,用 newUrl 作为 key + imageDataMap.set(result.url, result.imageData) + return result + }, + { + skipPatterns: ['doubanio.com', 'douban.com'], + onProgress: options?.onImageProgress, + } + ) + + // Markdown to Draft.js format (pass in image data) + const draftContent = markdownToDraft(content, imageDataMap) + + // 6. 保存草稿 + const response = await this.runtime.fetch( + 'https://www.douban.com/j/note/autosave', + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + is_rich: '1', + note_id: this.formData!.note_id, + note_title: article.title, + note_text: draftContent, + introduction: '', + note_privacy: 'P', + cannot_reply: '', + author_tags: '', + accept_donation: '', + donation_notice: '', + is_original: '', + ck: this.formData!.ck, + }), + } + ) + + const res = await response.json() as { url?: string; r?: number } + logger.debug('Save response:', res) + + // 豆瓣草稿只能在 /note/create 页面查看 + const draftUrl = 'https://www.douban.com/note/create' + + return this.createResult(true, { + postId: this.formData!.note_id, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + /** + * 上传图片并返回完整数据 + */ + private async uploadImageWithFullData(src: string): Promise { + if (!this.formData || !this.postParams) { + throw new Error('未获取上传凭证') + } + + // 1. 下载图片 + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + // 2. 上传到豆瓣 + const formData = new FormData() + formData.append('note_id', this.formData.note_id) + formData.append('image_file', imageBlob, 'image.jpg') + formData.append('ck', this.formData.ck) + formData.append('upload_auth_token', this.postParams.siteCookie.value) + + const uploadResponse = await this.runtime.fetch( + 'https://www.douban.com/j/note/add_photo', + { + method: 'POST', + credentials: 'include', + body: formData, + } + ) + + const res = await uploadResponse.json() as { + photo?: { + id: string + url: string + thumb: string + width: number + height: number + file_name: string + file_size: number + } + } + + logger.debug('Image upload response:', res) + + if (!res.photo?.url) { + throw new Error('图片上传失败') + } + + const photo = res.photo + + // 返回带完整图片数据 + return { + url: photo.url, + imageData: { + id: photo.id, + url: photo.url, + thumb: photo.thumb, + width: photo.width, + height: photo.height, + file_name: photo.file_name, + file_size: photo.file_size, + } + } + } +} diff --git a/sync/adapters/platforms/douyin.ts b/sync/adapters/platforms/douyin.ts new file mode 100644 index 0000000..67da2ae --- /dev/null +++ b/sync/adapters/platforms/douyin.ts @@ -0,0 +1,86 @@ +/** + * 抖音图文适配器 + * + * 抖音创作者中心的图文/文章发布接口未公开且带风控校验(csrf_token + 风控参数), + * 因此采用「编辑器辅助」方式:打开「发布文章」编辑器并自动填充标题与正文, + * 由用户确认后保存草稿或发布。 + * + * 登录检测:GET https://creator.douyin.com/web/api/media/user/info/ + */ +import { EditorAssistAdapter, type EditorAssistConfig } from '../editor-assist' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Douyin') + +interface DouyinUserInfo { + uid?: string + nick_name?: string + nickname?: string + avatar_uri?: string + avatar_url?: string +} + +export class DouyinAdapter extends EditorAssistAdapter { + readonly meta: PlatformMeta = { + id: 'douyin', + name: '抖音图文', + icon: 'https://www.douyin.com/favicon.ico', + homepage: 'https://creator.douyin.com', + capabilities: ['article'], + category: '主流自媒体', + experimental: true, + syncMode: 'editor', + } + + readonly editorConfig: EditorAssistConfig = { + // 文章编辑器(长文),default-tab=5 直达「发布文章」 + editorUrl: + 'https://creator.douyin.com/creator-micro/content/post/article?default-tab=5&enter_from=publish_page&media_type=article&type=new', + titleSelectors: [ + 'input[placeholder*="标题"]', + 'textarea[placeholder*="标题"]', + 'input[placeholder*="请输入文章标题"]', + 'input[class*="title"]' + ], + editorSelectors: [ + '.ProseMirror[contenteditable="true"]', + '[contenteditable="true"][class*="editor"]', + '[contenteditable="true"][class*="content"]', + 'div[contenteditable="true"]' + ], + hint: '已打开抖音「发布文章」编辑器并填充内容,请在草稿箱确认后发布', + waitBeforeFill: 5000, + } + + async checkAuth(): Promise { + try { + const res = await this.get<{ + status_code?: number + user_info?: DouyinUserInfo + data?: DouyinUserInfo + }>('https://creator.douyin.com/web/api/media/user/info/') + + const user = res?.user_info || res?.data + + if (!user || (!user.uid && !user.nick_name && !user.nickname)) { + return { isAuthenticated: false } + } + + return { + isAuthenticated: true, + userId: user.uid ? String(user.uid) : undefined, + username: user.nick_name || user.nickname, + avatar: user.avatar_url || user.avatar_uri, + } + } catch (error) { + logger.debug('checkAuth failed:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.openEditorAndFill(article, options) + } +} diff --git a/sync/adapters/platforms/eastmoney.ts b/sync/adapters/platforms/eastmoney.ts new file mode 100644 index 0000000..2afadd0 --- /dev/null +++ b/sync/adapters/platforms/eastmoney.ts @@ -0,0 +1,375 @@ +/** + * 东方财富适配器 + */ +import { CodeAdapter, type ImageUploadResult } from "../code-adapter"; +import type { + Article, + AuthResult, + SyncResult, + PlatformMeta, +} from "../../types"; +import type { PublishOptions } from "../types"; +import { createLogger } from "../../lib/logger"; + +const logger = createLogger("Eastmoney"); + +interface UploadResponse { + code: number; + message: string; + data?: { + url: string; + id: string; + }; +} + +interface DraftApiResponse { + RRquestSuccess: boolean; + RCode: number; + RMsg?: string; + RData: string; +} + +interface DraftResult { + error_code?: number; + draft_id?: string; + me?: string; +} + +export class EastmoneyAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: "eastmoney", + name: "东方财富", + icon: "https://mp.eastmoney.com/collect/pc_article/favicon.ico", + homepage: "https://mp.eastmoney.com", + capabilities: ["article", "draft", "image_upload", "cover"], + category: '财经', + syncMode: 'api', + }; + + /** 预处理配置 */ + readonly preprocessConfig = { + outputFormat: "html" as const, + removeComments: true, + removeSpecialTags: true, + processCodeBlocks: true, + convertSectionToDiv: true, + removeEmptyLines: true, + removeEmptyDivs: true, + removeNestedEmptyContainers: true, + unwrapSingleChildContainers: true, + unwrapNestedFigures: true, + removeTrailingBr: true, + removeDataAttributes: true, + removeSrcset: true, + removeSizes: true, + compactHtml: true, + }; + + private ctoken: string = ""; + private utoken: string = ""; + private deviceId: string = ""; + + /** 获取或生成持久化 deviceId(32位大写 hex) */ + private async getDeviceId(): Promise { + if (this.deviceId) return this.deviceId; + const stored = await this.runtime.storage.get("eastmoney_deviceId"); + if (stored) { + this.deviceId = stored; + return this.deviceId; + } + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + this.deviceId = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + await this.runtime.storage.set("eastmoney_deviceId", this.deviceId); + return this.deviceId; + } + + /** API Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: "*://mp.eastmoney.com/*", + headers: { + Origin: "https://mp.eastmoney.com", + HOST: "emfront.eastmoney.com", + }, + resourceTypes: ["xmlhttprequest"], + }, + ]; + + async checkAuth(): Promise { + try { + await this.fetchToken(); + + const response = await this.runtime.fetch( + `https://caifuhaoapi.eastmoney.com/api/v2/getauthorinfo?platform=&ctoken=${this.ctoken}&utoken=${this.utoken}`, + { + method: "GET", + credentials: "include", + headers: { "x-requested-with": "fetch" }, + }, + ); + + const data = (await response.json()) as { + Success: number; + Result?: { + accountId?: string; + accountName?: string; + portrait?: string; + }; + }; + + if (data.Success === 1 && data.Result?.accountId) { + return { + isAuthenticated: true, + userId: data.Result.accountId, + username: data.Result.accountName, + avatar: data.Result.portrait, + }; + } + + return { isAuthenticated: false }; + } catch (error) { + logger.debug("checkAuth: not logged in -", error); + return { isAuthenticated: false, error: (error as Error).message }; + } + } + + /** 从 cookie 读取 token */ + private async fetchToken(): Promise { + if (!this.runtime.getCookie) { + throw new Error("Cookie API 不可用,请先登录东方财富"); + } + + const ctoken = await this.runtime.getCookie(".eastmoney.com", "ct"); + const utoken = await this.runtime.getCookie(".eastmoney.com", "ut"); + + if (!ctoken || !utoken) { + throw new Error("未检测到登录信息,请先登录东方财富"); + } + + this.ctoken = ctoken; + this.utoken = utoken; + } + + async publish( + article: Article, + options?: PublishOptions, + ): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + await this.fetchToken(); + logger.info("Starting publish to eastmoney..."); + + // 1. 创建空草稿,获取 draft_id + const draftId = await this.createDraft(article.title); + logger.debug("Draft created:", draftId); + + // 2. 处理图片(基于预处理后的 HTML) + const content = await this.processImages( + article.html || "", + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ["gbres.dfcfw.com"], + onProgress: options?.onImageProgress, + }, + ); + + // 3. 更新草稿内容 + await this.updateDraft(draftId, article.title, content); + logger.debug("Draft updated"); + + const draftUrl = `https://mp.eastmoney.com/collect/pc_article/index.html#/?id=${draftId}`; + + return this.createResult(true, { + postId: draftId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }); + }).catch((error) => + this.createResult(false, { + error: (error as Error).message, + }), + ); + } + + /** 构造 API 参数 */ + private async buildParm(params: { + draftid?: string; + title: string; + text: string; + }): Promise { + const deviceid = await this.getDeviceId(); + return [ + { ip: "$IP$" }, + { deviceid }, + { version: "100" }, + { plat: "web" }, + { product: "CFH" }, + { ctoken: this.ctoken }, + { utoken: this.utoken }, + { draftid: params.draftid ?? "" }, + { drafttype: "0" }, + { type: "0" }, + { title: encodeURIComponent(params.title) }, + { text: encodeURIComponent(params.text) }, + { columns: "2" }, + { cover: "" }, + { issimplevideo: "0" }, + { videos: "" }, + { vods: "" }, + { isoriginal: "0" }, + { tgProduct: "" }, + { spcolumns: "" }, + { textsource: "0" }, + { replyauthority: "" }, + { modules: encodeURIComponent("[]") }, + ]; + } + + /** 调用草稿 API */ + private async callDraftApi(parm: object[], draftId?: string): Promise { + const pageUrl = draftId + ? `https://mp.eastmoney.com/collect/pc_article/index.html#/?id=${draftId}` + : "https://mp.eastmoney.com/collect/pc_article/index.html#/"; + + const body = JSON.stringify({ + pageUrl, + path: "draft/api/Article/SaveDraft", + parm: JSON.stringify(parm), + }); + + const response = await this.runtime.fetch( + "https://emfront.eastmoney.com/apifront/Tran/GetData?platform=", + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body, + }, + ); + + const responseText = await response.text(); + logger.debug( + "Draft API response:", + response.status, + responseText.substring(0, 200), + ); + + if (!response.ok) { + throw new Error(`草稿 API 请求失败: ${response.status}`); + } + + let rawData: DraftApiResponse; + try { + rawData = JSON.parse(responseText); + } catch { + throw new Error("草稿 API 响应不是有效 JSON"); + } + + if (!rawData.RRquestSuccess || rawData.RCode !== 200) { + throw new Error(`草稿 API 错误: ${rawData.RMsg || "未知错误"}`); + } + + let innerData: DraftResult; + try { + innerData = JSON.parse(rawData.RData); + } catch { + throw new Error("无法解析草稿响应数据"); + } + + if (innerData.error_code !== 0) { + throw new Error(`草稿业务错误: ${innerData.me || "未知错误"}`); + } + + return innerData; + } + + private async createDraft(title: string): Promise { + const parm = await this.buildParm({ + title, + text: '
    ', + }); + const result = await this.callDraftApi(parm); + if (!result.draft_id) { + throw new Error("创建草稿失败: 响应缺少 draft_id"); + } + return result.draft_id; + } + + private async updateDraft( + draftId: string, + title: string, + content: string, + ): Promise { + const parm = await this.buildParm({ + draftid: draftId, + title, + text: `
    ${content}
    `, + }); + await this.callDraftApi(parm, draftId); + } + + /** URL 上传图片 */ + protected async uploadImageByUrl(src: string): Promise { + // data URI 使用二进制上传 + if (src.startsWith("data:")) { + logger.debug("Detected data URI, using binary upload"); + const blob = await this.dataUriToBlob(src); + return this.uploadImageBlob(blob); + } + + // 远程 URL 使用链接上传接口 + const response = await this.runtime.fetch( + "https://gbapi.eastmoney.com/iimage/image/byLink?platform=", + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + noinlist: "1", + linkUrl: src, + ctoken: this.ctoken, + utoken: this.utoken, + }), + }, + ); + + const res = (await response.json()) as UploadResponse; + if (res.code === 200 && res.data?.url) { + return { url: res.data.url }; + } + throw new Error( + `图片上传失败: ${res.message || "未知错误"} (code: ${res.code})`, + ); + } + + /** 上传图片 Blob */ + private async uploadImageBlob(file: Blob): Promise { + const ext = file.type.split("/")[1] || "png"; + const filename = `${Date.now()}.${ext}`; + + const formData = new FormData(); + formData.append("file", file, filename); + formData.append("noinlist", "1"); + formData.append("utoken", this.utoken); + formData.append("ctoken", this.ctoken); + + const response = await this.runtime.fetch( + "https://gbapi.eastmoney.com/iimage/image?platform=", + { + method: "POST", + credentials: "include", + body: formData, + }, + ); + + const res = (await response.json()) as UploadResponse; + if (res.code === 200 && res.data?.url) { + return { url: res.data.url }; + } + throw new Error( + `图片上传失败: ${res.message || "未知错误"} (code: ${res.code})`, + ); + } +} diff --git a/sync/adapters/platforms/imooc.ts b/sync/adapters/platforms/imooc.ts new file mode 100644 index 0000000..d903f24 --- /dev/null +++ b/sync/adapters/platforms/imooc.ts @@ -0,0 +1,152 @@ +/** + * 慕课网手记适配器 + * https://www.imooc.com + */ +import { CodeAdapter } from '../code-adapter' +import type { ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +export class ImoocAdapter extends CodeAdapter { + meta: PlatformMeta = { + id: 'imooc', + name: '慕课手记', + icon: 'https://www.imooc.com/favicon.ico', + homepage: 'https://www.imooc.com/article', + capabilities: ['article', 'draft', 'image_upload'], + category: '技术社区', + syncMode: 'api', + } + + /** 预处理配置: 慕课网使用 Markdown 格式 */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + } + + /** 慕课网 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://www.imooc.com/article/*', + headers: { + Origin: 'https://www.imooc.com', + Referer: 'https://www.imooc.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + /** + * 检查登录状态 + */ + async checkAuth(): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + const response = await this.runtime.fetch('https://www.imooc.com/u/card', { + credentials: 'include', + }) + let text = await response.text() + + // 解析 JSONP 响应 + text = text.replace('jsonpcallback(', '').replace('})', '}') + const result = JSON.parse(text) + + if (result.result !== 0) { + return { isAuthenticated: false, error: result.msg || '未登录' } + } + + return { + isAuthenticated: true, + userId: result.data.uid, + username: result.data.nickname, + avatar: result.data.img, + } + }).catch((error) => ({ isAuthenticated: false, error: (error as Error).message })) + } + + /** + * 上传图片 + */ + async uploadImageByUrl(url: string): Promise { + // 下载图片 + const imageResponse = await this.runtime.fetch(url) + const blob = await imageResponse.blob() + + // 构建 FormData + const formData = new FormData() + const filename = `${Date.now()}.jpg` + const file = new File([blob], filename, { type: blob.type || 'image/jpeg' }) + + formData.append('photo', file, filename) + formData.append('type', file.type) + formData.append('id', 'WU_FILE_0') + formData.append('name', filename) + formData.append('lastModifiedDate', new Date().toString()) + formData.append('size', String(file.size)) + + const response = await this.runtime.fetch( + 'https://www.imooc.com/article/ajaxuploadimg', + { + method: 'POST', + credentials: 'include', + body: formData, + } + ) + + const res = await response.json() + + if (res.result !== 0) { + throw new Error(res.msg || '图片上传失败') + } + + // 处理协议相对 URL + let imgUrl = res.data.imgpath + if (imgUrl.startsWith('//')) { + imgUrl = 'https:' + imgUrl + } + + return { url: imgUrl } + } + + /** + * 发布文章 + */ + async publish(article: Article): Promise { + const now = Date.now() + return this.withHeaderRules(this.HEADER_RULES, async () => { + // 优先使用 markdown,处理图片 + let content = article.markdown || article.html || '' + content = await this.processImages(content, (src) => this.uploadImageByUrl(src)) + + const response = await this.runtime.fetch('https://www.imooc.com/article/savedraft', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + editor: '0', + draft_id: '0', + title: article.title, + content: content, + }), + }) + + const res = await response.json() + + if (!res.data) { + throw new Error('发布失败') + } + + return { + platform: this.meta.id, + success: true, + postId: res.data, + postUrl: `https://www.imooc.com/article/draft/id/${res.data}`, + draftOnly: true, + timestamp: now, + } + }).catch((error) => ({ + platform: this.meta.id, + success: false, + error: (error as Error).message, + timestamp: now, + })) + } +} diff --git a/sync/adapters/platforms/index.ts b/sync/adapters/platforms/index.ts new file mode 100644 index 0000000..e2276b1 --- /dev/null +++ b/sync/adapters/platforms/index.ts @@ -0,0 +1,116 @@ +/** + * 平台适配器导出与注册清单 + * + * 参考 wechatsync/Wechatsync 的适配器体系移植: + * - 主流自媒体:微信公众号、知乎、微博、小红书、抖音图文 + * - 技术社区:掘金、CSDN、51CTO、慕课网、开源中国、SegmentFault、博客园、语雀 + * - 通用平台:简书、头条号、百家号、搜狐号、豆瓣、B站、大鱼号、一点号、网易号、什么值得买 + * - 财经/产品/房产:雪球、东方财富、人人都是产品经理、搜狐焦点 + * - 海外:X (Twitter) + * - 建站/CMS:Hexo/Hugo(Markdown 打包下载) + */ +import { WeixinAdapter } from './weixin' +import { ZhihuAdapter } from './zhihu' +import { WeiboAdapter } from './weibo' +import { XiaohongshuAdapter } from './xiaohongshu' +import { DouyinAdapter } from './douyin' +import { JuejinAdapter } from './juejin' +import { CSDNAdapter } from './csdn' +import { Cto51Adapter } from './cto51' +import { ImoocAdapter } from './imooc' +import { OschinaAdapter } from './oschina' +import { SegmentfaultAdapter } from './segmentfault' +import { CnblogsAdapter } from './cnblogs' +import { YuqueAdapter } from './yuque' +import { JianshuAdapter } from './jianshu' +import { ToutiaoAdapter } from './toutiao' +import { BaijiahaoAdapter } from './baijiahao' +import { SohuAdapter } from './sohu' +import { DoubanAdapter } from './douban' +import { DaYuAdapter } from './dayu' +import { YidianAdapter } from './yidian' +import { NeteaseAdapter } from './netease' +import { SmzdmAdapter } from './smzdm' +import { XueqiuAdapter } from './xueqiu' +import { EastmoneyAdapter } from './eastmoney' +import { WoshipmAdapter } from './woshipm' +import { SohufocusAdapter } from './sohufocus' +import { XAdapter } from './x' +import { BilibiliAdapter } from './bilibili' +import { ZipDownloadAdapter } from './zip-download' +import type { PlatformAdapter } from '../types' + +export { + WeixinAdapter, + ZhihuAdapter, + WeiboAdapter, + XiaohongshuAdapter, + DouyinAdapter, + JuejinAdapter, + CSDNAdapter, + Cto51Adapter, + ImoocAdapter, + OschinaAdapter, + SegmentfaultAdapter, + CnblogsAdapter, + YuqueAdapter, + JianshuAdapter, + ToutiaoAdapter, + BaijiahaoAdapter, + SohuAdapter, + DoubanAdapter, + DaYuAdapter, + YidianAdapter, + NeteaseAdapter, + SmzdmAdapter, + XueqiuAdapter, + EastmoneyAdapter, + WoshipmAdapter, + SohufocusAdapter, + XAdapter, + BilibiliAdapter, + ZipDownloadAdapter +} + +export type AdapterConstructor = new () => PlatformAdapter + +/** + * 全部平台适配器构造函数(注册顺序即面板默认展示顺序) + */ +export const ALL_ADAPTERS: AdapterConstructor[] = [ + // 主流自媒体 + WeixinAdapter, + ZhihuAdapter, + WeiboAdapter, + XiaohongshuAdapter, + DouyinAdapter, + // 技术社区 + JuejinAdapter, + CSDNAdapter, + Cto51Adapter, + ImoocAdapter, + OschinaAdapter, + SegmentfaultAdapter, + CnblogsAdapter, + YuqueAdapter, + // 通用平台 + JianshuAdapter, + ToutiaoAdapter, + BaijiahaoAdapter, + SohuAdapter, + DoubanAdapter, + BilibiliAdapter, + DaYuAdapter, + YidianAdapter, + NeteaseAdapter, + SmzdmAdapter, + // 财经 / 产品 / 房产 + XueqiuAdapter, + EastmoneyAdapter, + WoshipmAdapter, + SohufocusAdapter, + // 海外 + XAdapter, + // 建站 / CMS + ZipDownloadAdapter +] diff --git a/sync/adapters/platforms/jianshu.ts b/sync/adapters/platforms/jianshu.ts new file mode 100644 index 0000000..8ce78a7 --- /dev/null +++ b/sync/adapters/platforms/jianshu.ts @@ -0,0 +1,262 @@ +/** + * 简书适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Jianshu') + +interface JianshuNotebook { + id: number + name: string +} + +export class JianshuAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'jianshu', + name: '简书', + icon: 'https://www.jianshu.com/favicon.ico', + homepage: 'https://www.jianshu.com', + capabilities: ['article', 'draft', 'image_upload', 'categories'], + category: '通用', + syncMode: 'api', + } + + private defaultNotebookId: number | null = null + + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch('https://www.jianshu.com/settings/basic.json', { + method: 'GET', + credentials: 'include', + }) + + const data = await response.json() as { + data?: { + nickname?: string + avatar?: string + } + } + + if (data.data?.nickname) { + return { + isAuthenticated: true, + username: data.data.nickname, + avatar: data.data.avatar, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.error('checkAuth error:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 获取文集列表(分类) + */ + async getNotebooks(): Promise { + const response = await this.runtime.fetch('https://www.jianshu.com/author/notebooks', { + method: 'GET', + credentials: 'include', + headers: { + 'Accept': 'application/json', + }, + }) + + return response.json() as Promise + } + + /** + * 获取默认文集 ID + */ + private async getDefaultNotebookId(): Promise { + if (this.defaultNotebookId) { + return this.defaultNotebookId + } + + const notebooks = await this.getNotebooks() + if (notebooks.length === 0) { + throw new Error('没有可用的文集') + } + + // 使用第一个文集作为默认 + this.defaultNotebookId = notebooks[0].id + return this.defaultNotebookId + } + + async publish(article: Article, options?: PublishOptions): Promise { + try { + logger.info('Starting publish...') + + // 1. 获取文集 ID + const notebookId = await this.getDefaultNotebookId() + + // 2. 创建文章草稿 + const createResponse = await this.runtime.fetch('https://www.jianshu.com/author/notes', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + at_bottom: false, + notebook_id: notebookId, + title: article.title, + }), + }) + + const createData = await createResponse.json() as { id?: number } + + if (!createData.id) { + throw new Error('创建草稿失败') + } + + const draftId = createData.id + logger.debug('Draft created:', draftId) + + // 3. 获取 HTML 内容 + const rawHtml = article.html || article.markdown + + // 4. 清理 HTML + let content = this.cleanHtml(rawHtml, { + removeIframes: true, + removeSvgImages: true, + removeTags: ['mpprofile', 'qqmusic'], + removeAttrs: ['data-reader-unique-id'], + }) + + // 5. 简书特定处理:移除空段落、移除尾部 br + content = content.replace(/

    \s*<\/p>/gi, '') + content = content.replace(/\s*$/gi, '') + + // 6. 处理图片 + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['jianshu.com', 'jianshuapi.com', 'upload-images.jianshu.io'], + onProgress: options?.onImageProgress, + } + ) + + // 7. 更新草稿内容 + const updateResponse = await this.runtime.fetch( + `https://www.jianshu.com/author/notes/${draftId}`, + { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: article.title, + content: content, + }), + } + ) + + const updateData = await updateResponse.json() as { id?: number } + + if (!updateData.id) { + throw new Error('更新草稿失败') + } + + logger.debug('Draft updated') + + const draftUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${draftId}` + + return this.createResult(true, { + postId: String(draftId), + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + } catch (error) { + return this.createResult(false, { + error: (error as Error).message, + }) + } + } + + /** + * 获取图片上传凭证 + */ + private async getUploadToken(): Promise<{ token: string; url: string }> { + const response = await this.runtime.fetch('https://www.jianshu.com/upload_images/token.json', { + method: 'GET', + credentials: 'include', + headers: { + 'Accept': 'application/json', + }, + }) + + return response.json() as Promise<{ token: string; url: string }> + } + + /** + * 通过 Blob 上传图片(覆盖基类方法) + */ + async uploadImage(file: Blob, _filename?: string): Promise { + return this.uploadImageBinaryInternal(file) + } + + /** + * 通过 URL 上传图片 + */ + protected async uploadImageByUrl(src: string): Promise { + try { + // 1. 下载图片 + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败') + } + const imageBlob = await imageResponse.blob() + + // 2. 上传图片 + const url = await this.uploadImageBinaryInternal(imageBlob) + return { url } + } catch (error) { + logger.warn('Failed to upload image:', src, error) + return { url: src } // 失败时返回原 URL + } + } + + /** + * 上传图片 (二进制方式) - 内部使用 + */ + private async uploadImageBinaryInternal(file: Blob): Promise { + // 1. 获取上传凭证 + const { token, url: uploadUrl } = await this.getUploadToken() + + // 2. 生成文件名 + const ext = file.type.split('/')[1] || 'png' + const filename = `${Date.now()}.${ext}` + + // 3. 上传到七牛云 + const formData = new FormData() + formData.append('file', file, filename) + formData.append('token', token) + + const response = await fetch(uploadUrl, { + method: 'POST', + body: formData, + }) + + const data = await response.json() as { url?: string; key?: string } + + if (data.url) { + return data.url + } + + // 七牛返回的是 key,需要拼接完整 URL + if (data.key) { + return `https://upload-images.jianshu.io/upload_images/${data.key}` + } + + throw new Error('图片上传失败') + } +} diff --git a/sync/adapters/platforms/juejin.ts b/sync/adapters/platforms/juejin.ts new file mode 100644 index 0000000..3473998 --- /dev/null +++ b/sync/adapters/platforms/juejin.ts @@ -0,0 +1,585 @@ +/** + * 掘金适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { signAWS4, crc32 } from '../../lib' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Juejin') + +// ImageX 服务常量 +const IMAGEX_AID = '2608' +const IMAGEX_SERVICE_ID = '73owjymdk6' + +// 生成 UUID (用于 ImageX API) +function generateUUID(): string { + return 'xxxxxxxxxxxxxxxx'.replace(/x/g, () => + Math.floor(Math.random() * 16).toString(16) + ) + Date.now().toString() +} + +// ImageX Token 响应类型 +interface ImageXTokenResponse { + data?: { + token: { + AccessKeyId: string + SecretAccessKey: string + SessionToken: string + ExpiredTime: string // ISO 日期字符串 "2026-01-14T00:15:24+08:00" + CurrentTime: string + } + } + err_no?: number + err_msg?: string +} + +// 解析后的 Token +interface ImageXToken { + AccessKeyId: string + SecretAccessKey: string + SessionToken: string + ExpiredTime: number // Unix 时间戳(毫秒) +} + +// ImageX ApplyUpload 响应类型 +interface ImageXApplyUploadResponse { + ResponseMetadata: { + RequestId: string + Action: string + Version: string + Service: string + Region: string + } + Result: { + RequestId: string + UploadAddress: { + StoreInfos: Array<{ + StoreUri: string + Auth: string + UploadID: string + }> + UploadHosts: string[] + SessionKey: string + } + } +} + +// ImageX CommitUpload 响应类型 +interface ImageXCommitUploadResponse { + ResponseMetadata: { + RequestId: string + Action: string + Version: string + Service: string + Region: string + } + Result: { + RequestId: string + Results: Array<{ + Uri: string + UriStatus: number + }> + PluginResult: Array<{ + FileName: string + ImageUri: string + ImageWidth: number + ImageHeight: number + ImageMd5: string + ImageFormat: string + ImageSize: number + }> + } +} + +export class JuejinAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'juejin', + name: '掘金', + icon: 'https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/static/favicons/favicon-32x32.png', + homepage: 'https://juejin.cn', + capabilities: ['article', 'draft', 'image_upload', 'categories', 'tags', 'cover'], + category: '技术社区', + syncMode: 'api', + } + + /** 预处理配置: 掘金使用 Markdown 格式 */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + } + + private cachedCsrfToken: string | null = null + private cachedImageXToken: ImageXToken | null = null + private imageXTokenExpiry: number = 0 + private uuid: string = generateUUID() + + /** 掘金 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://api.juejin.cn/*', + headers: { + 'Origin': 'https://juejin.cn', + 'Referer': 'https://juejin.cn/', + }, + resourceTypes: ['xmlhttprequest'], + }, + { + urlFilter: '*://imagex.bytedanceapi.com/*', + headers: { + 'Origin': 'https://juejin.cn', + 'Referer': 'https://juejin.cn/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch('https://api.juejin.cn/user_api/v1/user/get', { + method: 'GET', + credentials: 'include', + }) + + const data = await response.json() as { + data?: { + user_id?: string + user_name?: string + avatar_large?: string + } + } + + if (data.data?.user_id) { + return { + isAuthenticated: true, + userId: data.data.user_id, + username: data.data.user_name, + avatar: data.data.avatar_large, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 获取 CSRF Token (参考 DSL juejin.transform.ts) + */ + private async getCsrfToken(): Promise { + if (this.cachedCsrfToken) { + return this.cachedCsrfToken + } + + // 使用 runtime.fetch 以便 extension 能正确处理 + const response = await this.runtime.fetch('https://api.juejin.cn/user_api/v1/sys/token', { + method: 'HEAD', + headers: { + 'x-secsdk-csrf-request': '1', + 'x-secsdk-csrf-version': '1.2.10', + }, + credentials: 'include', + }) + + const wareToken = response.headers.get('x-ware-csrf-token') + if (!wareToken) { + logger.warn('CSRF token not found in response headers') + throw new Error('Failed to get CSRF token') + } + + // Token 格式: "0,{actual_token},86370000,success,{session_id}" + const parts = wareToken.split(',') + if (parts.length < 2) { + throw new Error('Invalid CSRF token format') + } + + this.cachedCsrfToken = parts[1] + logger.debug('Got CSRF token:', this.cachedCsrfToken.substring(0, 10) + '...') + return this.cachedCsrfToken + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + // 1. 获取 CSRF token + const csrfToken = await this.getCsrfToken() + + // 2. 使用预处理好的 markdown(Content Script 已转换) + // 掘金使用 Markdown 格式 + let markdown = article.markdown || '' + + // 3. 处理图片(上传到掘金图床) + markdown = await this.processImages( + markdown, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: [ + 'juejin.cn', 'p1-juejin', 'p3-juejin', + 'p6-juejin', 'p9-juejin', 'byteimg.com' + ], + onProgress: options?.onImageProgress, + } + ) + + // 6. 创建草稿 (参数来自 DSL juejin.yaml + juejin.transform.ts prepareBody) + const createResponse = await this.runtime.fetch( + 'https://api.juejin.cn/content_api/v1/article_draft/create', + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'x-secsdk-csrf-token': csrfToken, + }, + body: JSON.stringify({ + brief_content: '', + category_id: '0', + cover_image: '', + edit_type: 10, + html_content: 'deprecated', + link_url: '', + mark_content: markdown, + tag_ids: [], + title: article.title, + }), + } + ) + + // 检查响应状态和内容 + const responseText = await createResponse.text() + logger.debug('Create draft response:', createResponse.status, responseText.substring(0, 300)) + + if (!createResponse.ok) { + throw new Error(`创建草稿失败: ${createResponse.status} - ${responseText}`) + } + + let createData: { data?: { id?: string }; err_msg?: string; err_no?: number } + try { + createData = JSON.parse(responseText) + } catch { + throw new Error(`创建草稿失败: 响应不是有效 JSON - ${responseText.substring(0, 100)}`) + } + + // 检查业务错误 + if (createData.err_no && createData.err_no !== 0) { + throw new Error(createData.err_msg || `创建草稿失败: 错误码 ${createData.err_no}`) + } + + if (!createData.data?.id) { + throw new Error(createData.err_msg || '创建草稿失败: 无效响应') + } + + const draftId = createData.data.id + logger.debug('Draft created:', draftId) + + const draftUrl = `https://juejin.cn/editor/drafts/${draftId}` + + return this.createResult(true, { + postId: draftId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + /** + * 通过 Blob 上传图片(覆盖基类方法) + * 需要设置动态请求头规则以支持 MCP 调用 + */ + async uploadImage(file: Blob, _filename?: string): Promise { + return this.withHeaderRules(this.HEADER_RULES, () => this.uploadImageBinaryInternal(file)) + } + + /** + * 通过 URL 上传图片 + * 支持远程 URL 和 data URI,都使用 ImageX 流程 + */ + protected async uploadImageByUrl(src: string): Promise { + try { + let blob: Blob + + if (src.startsWith('data:')) { + // data URI 直接转 blob + logger.debug('Detected data URI, converting to blob') + blob = await fetch(src).then(r => r.blob()) + } else { + // 远程 URL:先下载再上传 + logger.debug('Downloading remote image:', src.substring(0, 80)) + const response = await this.runtime.fetch(src, { + method: 'GET', + }) + + if (!response.ok) { + logger.warn('Failed to download image:', response.status) + return { url: src } + } + + blob = await response.blob() + } + + // 使用 ImageX 流程上传 + const url = await this.uploadImageBinaryInternal(blob) + logger.debug('Uploaded image:', src.substring(0, 50), '->', url) + return { url } + } catch (error) { + logger.warn('Failed to upload image by URL:', src, error) + return { url: src } // 失败时返回原 URL + } + } + + /** + * 获取 ImageX 上传凭证 + */ + private async getImageXToken(): Promise { + // 检查缓存是否有效(提前 60 秒过期) + if (this.cachedImageXToken && Date.now() < this.imageXTokenExpiry - 60000) { + return this.cachedImageXToken + } + + const url = `https://api.juejin.cn/imagex/v2/gen_token?aid=${IMAGEX_AID}&uuid=${this.uuid}&client=web` + const response = await this.runtime.fetch(url, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + const responseText = await response.text() + logger.debug('gen_token response:', responseText.substring(0, 500)) + + let data: ImageXTokenResponse + try { + data = JSON.parse(responseText) + } catch { + throw new Error(`Invalid JSON response from gen_token: ${responseText.substring(0, 200)}`) + } + + if (data.err_no && data.err_no !== 0) { + throw new Error(data.err_msg || `Failed to get ImageX token: err_no=${data.err_no}`) + } + + const tokenData = data.data?.token + if (!tokenData || !tokenData.AccessKeyId || !tokenData.SecretAccessKey) { + throw new Error(`Invalid ImageX token response: ${responseText.substring(0, 200)}`) + } + + // 解析 ISO 日期为时间戳 + const expiredTime = new Date(tokenData.ExpiredTime).getTime() + + this.cachedImageXToken = { + AccessKeyId: tokenData.AccessKeyId, + SecretAccessKey: tokenData.SecretAccessKey, + SessionToken: tokenData.SessionToken, + ExpiredTime: expiredTime, + } + this.imageXTokenExpiry = expiredTime + + logger.debug('Got ImageX token, expires at:', tokenData.ExpiredTime) + + return this.cachedImageXToken + } + + /** + * 申请图片上传 + */ + private async applyImageUpload(token: ImageXToken): Promise { + const url = `https://imagex.bytedanceapi.com/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${IMAGEX_SERVICE_ID}` + + // 生成 AWS4 签名 + const signResult = await signAWS4({ + method: 'GET', + url, + accessKeyId: token.AccessKeyId, + secretAccessKey: token.SecretAccessKey, + securityToken: token.SessionToken, + region: 'cn-north-1', + service: 'imagex', + }) + + const response = await this.runtime.fetch(url, { + method: 'GET', + headers: { + ...signResult.headers, + }, + }) + + const data = await response.json() as ImageXApplyUploadResponse + + if (!data.Result?.UploadAddress) { + throw new Error('Failed to apply image upload') + } + + return data.Result.UploadAddress + } + + /** + * 上传文件到 TOS + */ + private async uploadToTOS( + uploadAddress: ImageXApplyUploadResponse['Result']['UploadAddress'], + file: Blob + ): Promise { + const storeInfo = uploadAddress.StoreInfos[0] + const uploadHost = uploadAddress.UploadHosts[0] + + if (!storeInfo || !uploadHost) { + throw new Error('Invalid upload address') + } + + // 构建上传 URL + const uploadUrl = `https://${uploadHost}/${storeInfo.StoreUri}` + + // 计算 CRC32 + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + const crc32Value = crc32(uint8Array) + + logger.debug('Uploading to TOS:', uploadUrl, 'size:', file.size, 'crc32:', crc32Value) + + // 上传文件 + const response = await this.runtime.fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Authorization': storeInfo.Auth, + 'Content-Type': file.type || 'application/octet-stream', + 'Content-CRC32': crc32Value, + }, + body: file, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`TOS upload failed: ${response.status} ${text}`) + } + + logger.debug('TOS upload success') + } + + /** + * 提交图片上传 + */ + private async commitImageUpload( + token: ImageXToken, + sessionKey: string + ): Promise { + const url = `https://imagex.bytedanceapi.com/?Action=CommitImageUpload&Version=2018-08-01&SessionKey=${encodeURIComponent(sessionKey)}&ServiceId=${IMAGEX_SERVICE_ID}` + + // 生成 AWS4 签名 + const signResult = await signAWS4({ + method: 'POST', + url, + accessKeyId: token.AccessKeyId, + secretAccessKey: token.SecretAccessKey, + securityToken: token.SessionToken, + region: 'cn-north-1', + service: 'imagex', + }) + + const response = await this.runtime.fetch(url, { + method: 'POST', + headers: { + ...signResult.headers, + 'Content-Length': '0', + }, + }) + + const data = await response.json() as ImageXCommitUploadResponse + + if (!data.Result) { + throw new Error('Failed to commit image upload') + } + + return data.Result + } + + /** + * 获取图片 URL + */ + private async getImageUrl(uri: string): Promise { + const url = `https://api.juejin.cn/imagex/v2/get_img_url?aid=${IMAGEX_AID}&uuid=${this.uuid}&uri=${encodeURIComponent(uri)}&img_type=private` + + const response = await this.runtime.fetch(url, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + const data = await response.json() as { + data?: { main_url?: string; backup_url?: string } + err_no?: number + err_msg?: string + } + + if (data.err_no && data.err_no !== 0) { + throw new Error(data.err_msg || 'Failed to get image URL') + } + + const imageUrl = data.data?.main_url || data.data?.backup_url + if (!imageUrl) { + throw new Error('Invalid image URL response') + } + + return imageUrl + } + + /** + * 上传图片 (ImageX 方式) - 内部使用 + */ + private async uploadImageBinaryInternal(file: Blob): Promise { + // 1. 获取上传凭证 + const token = await this.getImageXToken() + + // 2. 申请上传 + const uploadAddress = await this.applyImageUpload(token) + logger.debug('Apply upload success, session:', uploadAddress.SessionKey.substring(0, 50) + '...') + + // 3. 上传到 TOS + await this.uploadToTOS(uploadAddress, file) + + // 4. 提交上传 + const commitResult = await this.commitImageUpload(token, uploadAddress.SessionKey) + logger.debug('Commit upload success:', commitResult.Results?.[0]?.Uri) + + // 5. 获取图片 URL + const storeUri = uploadAddress.StoreInfos[0]?.StoreUri + if (!storeUri) { + throw new Error('No store URI in upload address') + } + + const imageUrl = await this.getImageUrl(storeUri) + logger.debug('Got image URL:', imageUrl) + + return imageUrl + } + + /** + * 获取分类列表 + */ + async getCategories() { + const response = await this.runtime.fetch( + 'https://api.juejin.cn/tag_api/v1/query_category_briefs', + { + method: 'GET', + credentials: 'include', + } + ) + + const data = await response.json() as { + data?: Array<{ category_id: string; category_name: string }> + } + + // 转换为标准 Category 格式 + return (data.data || []).map(c => ({ + id: c.category_id, + name: c.category_name, + })) + } +} diff --git a/sync/adapters/platforms/netease.ts b/sync/adapters/platforms/netease.ts new file mode 100644 index 0000000..e54c899 --- /dev/null +++ b/sync/adapters/platforms/netease.ts @@ -0,0 +1,81 @@ +/** + * 网易号适配器 + * + * 登录检测使用网易号官方接口,发布采用「编辑器辅助」方式: + * 网易号未公开草稿保存接口,社区工具均通过浏览器操作编辑器完成, + * 因此这里打开网易号发布页并自动填充标题与正文,由用户确认后存草稿。 + * + * 参考 wechatsync 驱动(Mp163):GET https://mp.163.com/wemedia/navinfo.do + */ +import { EditorAssistAdapter, type EditorAssistConfig } from '../editor-assist' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Netease') + +interface WemediaInfo { + wemediaId?: string + tname?: string + icon?: string +} + +export class NeteaseAdapter extends EditorAssistAdapter { + readonly meta: PlatformMeta = { + id: 'netease', + name: '网易号', + icon: 'https://mp.163.com/favicon.ico', + homepage: 'https://mp.163.com', + capabilities: ['article'], + category: '通用', + experimental: true, + syncMode: 'editor', + } + + readonly editorConfig: EditorAssistConfig = { + editorUrl: 'https://mp.163.com/index.html#/article/add', + titleSelectors: [ + 'input[placeholder*="标题"]', + 'textarea[placeholder*="标题"]', + 'input[class*="title"]' + ], + editorSelectors: [ + '.ProseMirror[contenteditable="true"]', + '[contenteditable="true"][class*="editor"]', + '[contenteditable="true"][class*="content"]', + 'div[contenteditable="true"]' + ], + hint: '已打开网易号发布页并填充内容,请确认后保存草稿或发布', + waitBeforeFill: 4000, + } + + private account: WemediaInfo | null = null + + async checkAuth(): Promise { + try { + const res = await this.get<{ code?: number; data?: WemediaInfo }>( + `https://mp.163.com/wemedia/navinfo.do?_=${Date.now()}` + ) + + if (res?.code !== 1 || !res.data) { + return { isAuthenticated: false } + } + + this.account = res.data + + return { + isAuthenticated: true, + userId: res.data.wemediaId ? String(res.data.wemediaId) : undefined, + username: res.data.tname, + avatar: res.data.icon, + } + } catch (error) { + logger.debug('checkAuth failed:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.openEditorAndFill(article, options) + } +} diff --git a/sync/adapters/platforms/oschina.ts b/sync/adapters/platforms/oschina.ts new file mode 100644 index 0000000..d35938a --- /dev/null +++ b/sync/adapters/platforms/oschina.ts @@ -0,0 +1,193 @@ +/** + * 开源中国适配器 + * https://my.oschina.net + */ +import { CodeAdapter } from '../code-adapter' +import type { ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +export class OschinaAdapter extends CodeAdapter { + meta: PlatformMeta = { + id: 'oschina', + name: '开源中国', + icon: 'https://www.oschina.net/favicon.ico', + homepage: 'https://my.oschina.net', + capabilities: ['article', 'draft', 'image_upload'], + category: '技术社区', + syncMode: 'api', + } + + /** 预处理配置: 开源中国使用 Markdown 格式 */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + } + + private userId: string | null = null + + /** 开源中国 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://apiv1.oschina.net/oschinapi/*', + headers: { + Origin: 'https://my.oschina.net', + Referer: 'https://my.oschina.net/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + /** + * 检查登录状态 + */ + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch('https://apiv1.oschina.net/oschinapi/user/myDetails', { + credentials: 'include', + }) + const data = await response.json() as { + success: boolean + result?: { + userId: number + userVo?: { + name: string + portraitUrl: string + } + } + } + + if (!data.success || !data.result?.userId) { + return { isAuthenticated: false, error: '未登录' } + } + + this.userId = String(data.result.userId) + + return { + isAuthenticated: true, + userId: this.userId, + username: data.result.userVo?.name || this.userId, + avatar: data.result.userVo?.portraitUrl, + } + } catch (error) { + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 上传图片 + */ + async uploadImageByUrl(url: string): Promise { + if (!this.userId) { + await this.checkAuth() + } + + // 下载图片 + const imageResponse = await this.runtime.fetch(url) + const blob = await imageResponse.blob() + const filename = this.getFilenameFromUrl(url) || 'image' + + // 构建 FormData + const formData = new FormData() + formData.append('file', blob, filename) + + const response = await this.runtime.fetch( + 'https://apiv1.oschina.net/oschinapi/ai/creation/project/uploadDetail', + { + method: 'POST', + credentials: 'include', + body: formData, + } + ) + + const res = await response.json() as { + success?: boolean + result?: string + message?: string + } + + if (!res.success || !res.result) { + throw new Error(res.message || '图片上传失败') + } + + return { url: res.result } + } + + private getFilenameFromUrl(url: string): string | null { + try { + const pathname = new URL(url).pathname + const name = pathname.split('/').pop() + return name && name.trim() ? name : null + } catch { + return null + } + } + + /** + * 发布文章 + */ + async publish(article: Article): Promise { + const now = Date.now() + + return this.withHeaderRules(this.HEADER_RULES, async () => { + // 确保已获取用户 ID + if (!this.userId) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('未登录') + } + } + + const rawMarkdown = article.markdown || '' + const rawHtml = article.html || '' + const useMarkdown = rawMarkdown.trim().length > 0 + + let content = useMarkdown ? rawMarkdown : rawHtml + content = await this.processImages(content, (src) => this.uploadImageByUrl(src)) + + const response = await this.runtime.fetch( + 'https://apiv1.oschina.net/oschinapi/api/draft/save_draft', + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: article.title, + user: Number(this.userId), + content, + contentType: useMarkdown ? 1 : 2, // 1=markdown, 2=html + catalog: 0, + originUrl: '', + privacy: true, + disableComment: false, + }), + } + ) + + const res = await response.json() as { + success?: boolean + message?: string + result?: { id?: number } + } + + if (!res.success || !res.result?.id) { + throw new Error(res.message || '发布失败') + } + + const draftId = String(res.result.id) + + return { + platform: this.meta.id, + success: true, + postId: draftId, + postUrl: `https://my.oschina.net/u/${this.userId}/blog/write/draft/${draftId}`, + draftOnly: true, + timestamp: now, + } + }).catch((error) => ({ + platform: this.meta.id, + success: false, + error: (error as Error).message, + timestamp: now, + })) + } +} diff --git a/sync/adapters/platforms/segmentfault.ts b/sync/adapters/platforms/segmentfault.ts new file mode 100644 index 0000000..59f845b --- /dev/null +++ b/sync/adapters/platforms/segmentfault.ts @@ -0,0 +1,247 @@ +/** + * 思否 (Segmentfault) 适配器 + * https://segmentfault.com + */ +import { CodeAdapter } from '../code-adapter' +import type { ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +export class SegmentfaultAdapter extends CodeAdapter { + meta: PlatformMeta = { + id: 'segmentfault', + name: '思否', + icon: 'https://imgcache.iyiou.com/Company/2016-05-11/cf-segmentfault.jpg', + homepage: 'https://segmentfault.com/user/draft', + capabilities: ['article', 'draft', 'image_upload'], + category: '技术社区', + syncMode: 'api', + } + + /** 预处理配置: 思否使用 Markdown 格式 */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + } + + private sessionToken: string | null = null + + /** 思否 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://segmentfault.com/gateway/*', + headers: { + Origin: 'https://segmentfault.com', + Referer: 'https://segmentfault.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + /** + * 检查登录状态 + */ + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch('https://segmentfault.com/user/settings', { + credentials: 'include', + }) + const html = await response.text() + + // 匹配用户链接 href="/u/username" + const userLinkMatch = html.match(/href="\/u\/([^"]+)"/) + if (!userLinkMatch) { + return { isAuthenticated: false, error: '未登录' } + } + + const uid = userLinkMatch[1] + + // 匹配头像 URL (avatar-static.segmentfault.com) + const avatarMatch = html.match(/src="(https:\/\/avatar-static\.segmentfault\.com\/[^"]+)"/) + const avatar = avatarMatch ? avatarMatch[1] : undefined + + return { + isAuthenticated: true, + userId: uid, + username: uid, + avatar: avatar, + } + } catch (error) { + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 获取 session token + */ + private async getSessionToken(): Promise { + const response = await this.runtime.fetch('https://segmentfault.com/write', { + credentials: 'include', + }) + const html = await response.text() + + // 新版 token 格式: serverData":{"Token":"xxx" + const tokenMatch = html.match(/serverData":\s*\{\s*"Token"\s*:\s*"([^"]+)"/) + if (tokenMatch) { + return tokenMatch[1] + } + + // 兼容旧版格式 + const markStr = 'window.g_initialProps = ' + const authIndex = html.indexOf(markStr) + if (authIndex === -1) { + throw new Error('获取 session token 失败') + } + + const endIndex = html.indexOf(';\n\t', authIndex) + if (endIndex === -1) { + throw new Error('解析 session token 失败') + } + + const configStr = html.substring(authIndex + markStr.length, endIndex) + + try { + const config = JSON.parse(configStr) + const token = config?.global?.sessionInfo?.key + if (!token) { + throw new Error('session token 为空') + } + return token + } catch (e) { + throw new Error('解析 session token 失败: ' + (e as Error).message) + } + } + + /** + * 上传图片 + */ + async uploadImageByUrl(url: string): Promise { + if (!this.sessionToken) { + throw new Error('未获取 token') + } + + // 下载图片 + const imageResponse = await this.runtime.fetch(url) + const blob = await imageResponse.blob() + + // 构建 FormData + const formData = new FormData() + formData.append('image', blob) + + const response = await this.runtime.fetch( + 'https://segmentfault.com/gateway/image', + { + method: 'POST', + credentials: 'include', + headers: { + token: this.sessionToken, + }, + body: formData, + } + ) + + // 处理异常响应 + const text = await response.text() + if (text === 'Unauthorized' || text.includes('禁言') || text.includes('锁定')) { + throw new Error(text === 'Unauthorized' ? '未授权' : text) + } + + let res + try { + res = JSON.parse(text) + } catch { + throw new Error('图片上传失败: ' + text) + } + + // 新版返回格式: { url: "/img/xxx", result: "https://..." } + // 旧版返回格式: [0, url, id] 或 [1, error_message] + const imageUrl = res.result || (Array.isArray(res) ? (res[0] === 1 ? null : res[1] || `https://image-static.segmentfault.com/${res[2]}`) : null) + if (!imageUrl) { + throw new Error(Array.isArray(res) ? (res[1] || '图片上传失败') : '图片上传失败') + } + return { url: imageUrl } + } + + /** + * 发布文章 + */ + async publish(article: Article): Promise { + const now = Date.now() + return this.withHeaderRules(this.HEADER_RULES, async () => { + // 获取 session token + this.sessionToken = await this.getSessionToken() + + // 优先使用 markdown,处理图片 + let content = article.markdown || article.html || '' + content = await this.processImages(content, (src) => this.uploadImageByUrl(src)) + + const postData = { + title: article.title, + tags: [], + text: content, + object_id: '', + type: 'article', + } + + const response = await this.runtime.fetch('https://segmentfault.com/gateway/draft', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + token: this.sessionToken, + accept: '*/*', + }, + body: JSON.stringify(postData), + }) + + // 处理异常响应 + const text = await response.text() + if (text === 'Unauthorized' || text.includes('禁言') || text.includes('锁定')) { + throw new Error(text === 'Unauthorized' ? '未授权' : text) + } + + let res + try { + res = JSON.parse(text) + } catch { + throw new Error('发布失败: ' + text) + } + + // 处理数组格式响应 [1, "error_message"] + if (Array.isArray(res)) { + if (res[0] === 1) { + throw new Error(res[1] || '发布失败') + } + // [0, data] 成功格式 + const data = res[1] + if (data?.id) { + return { + platform: this.meta.id, + success: true, + postId: data.id, + postUrl: `https://segmentfault.com/write?draftId=${data.id}`, + draftOnly: true, + timestamp: now, + } + } + } + + if (!res.id) { + // 尝试多种错误字段 + const errorMsg = res.message || res.msg || res.error || res.errMsg || JSON.stringify(res) + throw new Error(errorMsg) + } + + return { + platform: this.meta.id, + success: true, + postId: res.id, + postUrl: `https://segmentfault.com/write?draftId=${res.id}`, + draftOnly: true, + timestamp: now, + } + }).catch((error) => ({ + platform: this.meta.id, + success: false, + error: (error as Error).message, + timestamp: now, + })) + } +} diff --git a/sync/adapters/platforms/smzdm.ts b/sync/adapters/platforms/smzdm.ts new file mode 100644 index 0000000..63bdf65 --- /dev/null +++ b/sync/adapters/platforms/smzdm.ts @@ -0,0 +1,88 @@ +/** + * 什么值得买适配器 + * + * 什么值得买文章编辑器(ProseMirror)没有公开的发布接口,社区方案均为 + * 浏览器端填充编辑器,因此采用「编辑器辅助」方式: + * 打开写文章编辑器并自动填充标题与正文,由用户确认后发布(需人工审核)。 + * + * 登录检测:读取 Cookie(smzdm_user / sess),并访问创作中心校验。 + */ +import { EditorAssistAdapter, type EditorAssistConfig } from '../editor-assist' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Smzdm') + +/** 标题最长 30 字 */ +const TITLE_LIMIT = 30 + +export class SmzdmAdapter extends EditorAssistAdapter { + readonly meta: PlatformMeta = { + id: 'smzdm', + name: '什么值得买', + icon: 'https://www.smzdm.com/favicon.ico', + homepage: 'https://zhiyou.smzdm.com', + capabilities: ['article'], + category: '通用', + experimental: true, + syncMode: 'editor', + } + + readonly editorConfig: EditorAssistConfig = { + // 写文章编辑器(创作中心) + editorUrl: 'https://post.smzdm.com/editor/', + titleSelectors: [ + 'input[placeholder*="标题"]', + 'textarea[placeholder*="标题"]', + 'input[class*="title"]', + 'textarea[class*="title"]' + ], + editorSelectors: [ + '.ProseMirror[contenteditable="true"]', + '[contenteditable="true"][class*="editor"]', + '[contenteditable="true"][class*="content"]', + 'div[contenteditable="true"]' + ], + hint: '已打开什么值得买编辑器并填充内容,请确认后发布(发布后需平台人工审核)', + waitBeforeFill: 4500, + } + + async checkAuth(): Promise { + try { + if (!this.runtime.getCookie) { + return { isAuthenticated: false, error: '当前环境不支持读取 Cookie' } + } + + const userId = + (await this.runtime.getCookie('.smzdm.com', 'smzdm_user')) || + (await this.runtime.getCookie('.smzdm.com', 'user_id')) + + const session = + (await this.runtime.getCookie('.smzdm.com', 'sess')) || + (await this.runtime.getCookie('.smzdm.com', 'smzdm_sess')) + + if (!userId && !session) { + return { isAuthenticated: false } + } + + return { + isAuthenticated: true, + userId: userId || undefined, + } + } catch (error) { + logger.debug('checkAuth failed:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + // 标题超过 30 字会被平台截断,这里主动截断并保留完整标题在正文首行 + const title = + article.title.length > TITLE_LIMIT + ? article.title.slice(0, TITLE_LIMIT - 1) + '…' + : article.title + + return this.openEditorAndFill({ ...article, title }, options) + } +} diff --git a/sync/adapters/platforms/sohu.ts b/sync/adapters/platforms/sohu.ts new file mode 100644 index 0000000..ca5134a --- /dev/null +++ b/sync/adapters/platforms/sohu.ts @@ -0,0 +1,282 @@ +/** + * 搜狐号适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Sohu') + +interface SohuAccountInfo { + id: string + nickName: string + avatar: string +} + +/** + * 生成设备 ID (dv-id) + */ +function generateDeviceId(): string { + const chars = '0123456789abcdef' + let result = '' + for (let i = 0; i < 32; i++) { + result += chars[Math.floor(Math.random() * chars.length)] + } + return result +} + +export class SohuAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'sohu', + name: '搜狐号', + icon: 'https://mp.sohu.com/favicon.ico', + homepage: 'https://mp.sohu.com/mpfe/v3/main/first/page?newsType=1', + capabilities: ['article', 'draft', 'image_upload'], + category: '通用', + syncMode: 'api', + } + + /** 预处理配置: 搜狐号使用 HTML 格式 */ + readonly preprocessConfig = { + outputFormat: 'html' as const, + } + + private accountInfo: SohuAccountInfo | null = null + private deviceId: string = generateDeviceId() + private spCm: string = '' + + /** 搜狐号 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://mp.sohu.com/*', + headers: { + 'Origin': 'https://mp.sohu.com', + 'Referer': 'https://mp.sohu.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + // 使用 /account/list 获取所有子账号(搜狐号支持多个子账号) + const response = await this.runtime.fetch( + `https://mp.sohu.com/mpbp/bp/account/list?_=${Date.now()}`, + { + method: 'GET', + credentials: 'include', + } + ) + + const res = await response.json() as { + code: number + data?: { + data?: Array<{ + accounts: SohuAccountInfo[] + }> + } + } + + logger.debug('checkAuth response:', res) + + if (res.code !== 2000000 || !res.data?.data?.[0]?.accounts?.length) { + return { isAuthenticated: false } + } + + // 收集所有子账号 + const allAccounts: SohuAccountInfo[] = [] + for (const group of res.data.data) { + if (group.accounts) { + allAccounts.push(...group.accounts) + } + } + + if (allAccounts.length === 0) { + return { isAuthenticated: false } + } + + // 默认使用第一个子账号 + this.accountInfo = allAccounts[0] + logger.info(`Using account: ${this.accountInfo.nickName} (id: ${this.accountInfo.id})` + + (allAccounts.length > 1 ? `, ${allAccounts.length} sub-accounts available` : '')) + + // 获取 mp-cv cookie 用于 sp-cm header + await this.fetchSpCm() + + // 如果有多个子账号,在用户名中标注 + const displayName = allAccounts.length > 1 + ? `${this.accountInfo.nickName} (共${allAccounts.length}个子账号)` + : this.accountInfo.nickName + + return { + isAuthenticated: true, + userId: String(this.accountInfo.id), + username: displayName, + avatar: this.accountInfo.avatar, + } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 获取 sp-cm 值 (从 cookie 或生成) + */ + private async fetchSpCm(): Promise { + try { + // 尝试通过 runtime 获取 cookie(如果支持) + if (this.runtime.getCookie) { + const cookieValue = await this.runtime.getCookie('.sohu.com', 'mp-cv') + if (cookieValue) { + this.spCm = cookieValue + logger.debug('Got sp-cm from cookie:', this.spCm) + return + } + } + // fallback: 生成一个 + this.spCm = `100-${Date.now()}-${generateDeviceId()}` + logger.debug('Generated sp-cm:', this.spCm) + } catch (error) { + // fallback: 生成一个 + this.spCm = `100-${Date.now()}-${generateDeviceId()}` + logger.debug('Fallback sp-cm:', this.spCm) + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + // 1. 确保已登录 + if (!this.accountInfo) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录搜狐号') + } + } + + // Use pre-processed HTML content directly + let content = article.html || '' + + // Process images + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['sohu.com'], + onProgress: options?.onImageProgress, + } + ) + + // 4. 保存草稿 (v2 API - JSON 格式) + const postData = { + title: article.title, + brief: '', + content: content, + channelId: 24, + categoryId: -1, + id: 0, + userColumnId: 0, + columnNewsIds: [], + businessCode: 0, + declareOriginal: false, + cover: '', + topicIds: [], + isAd: 0, + userLabels: '[]', + reprint: false, + customTags: '', + infoResource: 0, + sourceUrl: '', + visibleToLoginedUsers: 0, + attrIds: [], + auto: true, + accountId: Number(this.accountInfo!.id), + } + + const response = await this.runtime.fetch( + `https://mp.sohu.com/mpbp/bp/news/v4/news/draft/v2?accountId=${this.accountInfo!.id}`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'dv-id': this.deviceId, + 'sp-cm': this.spCm, + }, + body: JSON.stringify(postData), + } + ) + + const res = await response.json() as { + success: boolean + data?: string | number + msg?: string + } + + logger.debug(' Save response:', res) + + if (!res.success) { + throw new Error(res.msg || '保存失败') + } + + const postId = res.data + const draftUrl = `https://mp.sohu.com/mpfe/v4/contentManagement/news/addarticle?spm=smmp.articlelist.0.0&contentStatus=2&id=${postId}` + + return this.createResult(true, { + postId: String(postId), + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + /** + * 通过 URL 上传图片 + */ + protected async uploadImageByUrl(src: string): Promise { + if (!this.accountInfo) { + throw new Error('未登录') + } + + // 1. 下载图片 + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + // 2. 上传到搜狐 + const formData = new FormData() + formData.append('file', imageBlob, 'image.jpg') + formData.append('accountId', this.accountInfo.id) + + const uploadResponse = await this.runtime.fetch( + 'https://mp.sohu.com/commons/front/outerUpload/image/file?accountId='+ this.accountInfo.id, + { + method: 'POST', + credentials: 'include', + body: formData, + } + ) + + const res = await uploadResponse.json() as { + url?: string + msg?: string + } + + logger.debug(' Image upload response:', res) + if (!res.url) { + throw new Error('图片上传失败:'+ (res.msg)) + } + + return { + url: res.url, + } + } +} diff --git a/sync/adapters/platforms/sohufocus.ts b/sync/adapters/platforms/sohufocus.ts new file mode 100644 index 0000000..23bd9f9 --- /dev/null +++ b/sync/adapters/platforms/sohufocus.ts @@ -0,0 +1,161 @@ +/** + * 搜狐焦点适配器 + * + * 参考 wechatsync 驱动(Focus)实现: + * - 登录检测:GET https://mp-fe-pc.focus.cn/user/status + * - 保存草稿:POST https://mp-fe-pc.focus.cn/news/info/publishNewsInfo + * - 图片上传:POST https://mp-fe-pc.focus.cn/common/image/upload?type=2 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Sohufocus') + +interface FocusAuthData { + uid?: string | number + accountName?: string + picUrl?: string +} + +export class SohufocusAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'sohufocus', + name: '搜狐焦点', + icon: 'https://mp.focus.cn/favicon.ico', + homepage: 'https://mp.focus.cn/fe/index.html#/info/draft', + capabilities: ['article', 'draft', 'image_upload'], + category: '房产', + syncMode: 'api', + } + + /** 搜狐焦点对多余空白敏感,压缩 HTML 标签间空白 */ + readonly preprocessConfig = { + outputFormat: 'html' as const, + compactHtml: true, + removeIframes: true, + removeComments: true, + removeEmptyElements: true, + } + + private account: FocusAuthData | null = null + + async checkAuth(): Promise { + try { + const res = await this.get<{ data?: FocusAuthData }>( + 'https://mp-fe-pc.focus.cn/user/status?' + ) + + const data = res?.data + + if (data?.uid) { + this.account = data + return { + isAuthenticated: true, + userId: String(data.uid), + username: data.accountName, + avatar: data.picUrl, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.debug('checkAuth failed:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + try { + logger.info('Starting publish...') + + if (!this.account) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录搜狐焦点(https://mp.focus.cn)') + } + } + + let content = article.html || article.markdown || '' + content = content.replace(/>[\t\s]*<') + + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['focus.cn', '51f.com'], + onProgress: options?.onImageProgress, + } + ) + + const payload = { + projectIds: [], + newsBasic: { + id: '', + cityId: 0, + title: article.title, + category: 1, + headImg: article.cover || '', + newsAbstract: article.summary || '', + isGuide: 0, + // 4 = 草稿 + status: 4, + }, + newsContent: { + content, + }, + videoIds: [], + } + + const res = await this.postJson<{ code?: number; data?: { id?: string }; msg?: string }>( + 'https://mp-fe-pc.focus.cn/news/info/publishNewsInfo', + payload + ) + + const articleId = res?.data?.id + + if (!articleId) { + throw new Error(`保存草稿失败:${res?.msg || JSON.stringify(res).slice(0, 200)}`) + } + + return this.createResult(true, { + postId: String(articleId), + postUrl: `https://mp.focus.cn/fe/index.html#/info/subinfo/${articleId}`, + draftOnly: options?.draftOnly ?? true, + }) + } catch (error) { + logger.error('publish failed:', error) + return this.createResult(false, { + error: (error as Error).message, + }) + } + } + + protected async uploadImageByUrl(src: string): Promise { + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const blob = await imageResponse.blob() + + const formData = new FormData() + formData.append('image', blob, `${Date.now()}.jpg`) + + const res = await this.postMultipart<{ code?: number; data?: string }>( + 'https://mp-fe-pc.focus.cn/common/image/upload?type=2', + formData + ) + + if (res?.code !== 200 || !res.data) { + throw new Error('图片上传失败') + } + + return { + url: `https://t-img.51f.com/sh740wsh${res.data}`, + attrs: { + 'data-key': res.data, + }, + } + } +} diff --git a/sync/adapters/platforms/toutiao.ts b/sync/adapters/platforms/toutiao.ts new file mode 100644 index 0000000..da420a9 --- /dev/null +++ b/sync/adapters/platforms/toutiao.ts @@ -0,0 +1,349 @@ +/** + * 头条适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import { markdownToHtml } from '../../lib/turndown' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Toutiao') + +export class ToutiaoAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'toutiao', + name: '头条', + icon: 'https://sf1-ttcdn-tos.pstatp.com/obj/ttfe/pgcfe/sz/mp_logo.png', + homepage: 'https://mp.toutiao.com/profile_v4/graphic/publish', + capabilities: ['article', 'draft', 'image_upload', 'cover'], + category: '通用', + syncMode: 'api', + } + + async checkAuth(): Promise { + try { + const res = await this.get<{ + data?: { user?: { id: number; screen_name: string; https_avatar_url: string } } + }>('https://mp.toutiao.com/mp/agw/media/get_media_info') + + logger.debug('checkAuth response:', res) + + if (res.data?.user?.id) { + return { + isAuthenticated: true, + userId: String(res.data.user.id), + username: res.data.user.screen_name, + avatar: res.data.user.https_avatar_url, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.error('checkAuth error:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 获取 tt-anti-token + */ + private async getAntiToken(): Promise { + const res = await this.get<{ + data?: { token: string } + }>('https://mp.toutiao.com/tt-anti-token') + return res.data?.token || '' + } + + /** + * 获取 x-secsdk-csrf-token + */ + private async getCsrfToken(): Promise { + const response = await this.runtime.fetch('https://mp.toutiao.com/ttwid/check/', { + method: 'HEAD', + credentials: 'include', + headers: { + 'x-secsdk-csrf-request': '1', + 'x-secsdk-csrf-version': '1.2.22', + }, + }) + // token 在响应头里 + return response.headers.get('x-ware-csrf-token') || '' + } + + async publish(article: Article, options?: PublishOptions): Promise { + try { + logger.info('Starting publish...') + + // 1. 获取 HTML 内容 + const rawHtml = article.html || markdownToHtml(article.markdown) + + // 2. 清理内容 + let content = this.cleanHtml(rawHtml, { + removeLinks: true, + removeIframes: true, + removeSvgImages: true, + removeTags: ['qqmusic'], + removeAttrs: ['data-reader-unique-id'], + }) + // 移除空的 figure 标签 + content = content.replace(/]*>\s*<\/figure>/gi, '') + // 移除多余的空行 + content = content.replace(/\n{3,}/g, '\n\n') + + // 3. 处理图片 + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['pstatp.com', 'toutiao.com', 'byteimg.com'], + onProgress: options?.onImageProgress, + } + ) + // 将图片包装成头条格式:

    + content = content.replace( + /]+)>/gi, + '

    ' + ) + + // 4. 处理封面 (暂时禁用以排查问题) + const coverInfo: ImageUploadResult | null = null + // if (article.cover) { + // try { + // coverInfo = await this.uploadImageByUrl(article.cover) + // } catch (e) { + // console.warn('[Toutiao] Failed to upload cover:', e) + // } + // } + + // 5. 构建封面 JSON (暂时禁用封面以排查问题) + const pgcFeedCovers = '[]' + + // 6. 构建请求参数 + const extra = JSON.stringify({ + content_source: 100000000402, + content_word_cnt: content.length, + is_multi_title: 0, + sub_titles: [], + gd_ext: { + entrance: '', + from_page: 'publisher_mp', + enter_from: 'PC', + device_platform: 'mp', + is_message: 0, + }, + }) + + const titleId = `${Date.now()}_${Math.random().toString().slice(2, 18)}` + + // 7. 构建表单数据 + const formData = new URLSearchParams() + formData.append('pgc_id', '0') + formData.append('source', '29') + formData.append('extra', extra) + formData.append('content', content) + formData.append('title', article.title) + formData.append('search_creation_info', JSON.stringify({ searchTopOne: 0, abstract: '', clue_id: '' })) + formData.append('title_id', titleId) + formData.append('mp_editor_stat', '{}') + formData.append('is_refute_rumor', '0') + formData.append('save', '0') + formData.append('timer_status', '0') + formData.append('timer_time', '') + formData.append('educluecard', '') + formData.append('draft_form_data', JSON.stringify({ coverType: 3 })) + formData.append('pgc_feed_covers', pgcFeedCovers) + formData.append('article_ad_type', '3') + formData.append('is_fans_article', '0') + formData.append('govern_forward', '0') + formData.append('praise', '0') + formData.append('disable_praise', '0') + formData.append('tree_plan_article', '0') + formData.append('activity_tag', '0') + formData.append('trends_writing_tag', '0') + formData.append('claim_exclusive', '0') + + // 8. 通过 content script 使用页面 fetch 发布(自动注入 msToken/a_bogus) + const res = await this.publishViaContentScript( + 'https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231', + formData.toString() + ) + + logger.debug('publish response:', res) + + if (res.err_no !== 0 || !res.data?.pgc_id) { + throw new Error(res.message || '发布失败') + } + + const draftId = res.data.pgc_id + const draftUrl = `https://mp.toutiao.com/profile_v4/graphic/publish?pgc_id=${draftId}` + + return this.createResult(true, { + postId: draftId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + } catch (error) { + return this.createResult(false, { + error: (error as Error).message, + }) + } + } + + /** + * 确保头条 tab 存在,如果不存在则自动创建 + */ + private async ensureToutiaoTab(): Promise { + // 查找已存在的头条页面 tab + const tabs = await chrome.tabs.query({ url: 'https://mp.toutiao.com/*' }) + + if (tabs.length > 0 && tabs[0].id) { + return tabs[0].id + } + + // 没有则创建新 tab + logger.info('No existing tab found, creating new one...') + const tab = await chrome.tabs.create({ + url: 'https://mp.toutiao.com/profile_v4/graphic/publish', + active: false, // 后台打开 + }) + + // 等待页面加载完成 + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener) + reject(new Error('头条页面加载超时')) + }, 30000) // 30秒超时 + + const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => { + if (tabId === tab.id && info.status === 'complete') { + clearTimeout(timeout) + chrome.tabs.onUpdated.removeListener(listener) + // 额外等待一下让页面 JS 初始化 + setTimeout(() => resolve(), 1000) + } + } + chrome.tabs.onUpdated.addListener(listener) + }) + + logger.info('New tab created and loaded:', tab.id) + return tab.id! + } + + /** + * 通过 chrome.scripting.executeScript 在页面上下文执行 fetch + * 页面会自动注入 msToken/a_bogus 等反爬参数 + */ + private async publishViaContentScript(url: string, body: string): Promise<{ + err_no?: number + data?: { pgc_id: string } + message?: string + }> { + // 确保有头条 tab + const tabId = await this.ensureToutiaoTab() + logger.debug('Using tab:', tabId, 'to execute fetch in MAIN world') + + // 在页面上下文 (MAIN world) 执行 fetch + const results = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + func: async (fetchUrl: string, fetchBody: string) => { + try { + const response = await fetch(fetchUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: fetchBody, + credentials: 'include', + }) + const data = await response.json() + return { success: true, data } + } catch (error) { + return { success: false, error: (error as Error).message } + } + }, + args: [url, body], + }) + + const result = results[0]?.result as { success: boolean; data?: unknown; error?: string } + + if (!result || !result.success) { + throw new Error(result?.error || '发布请求失败') + } + + return result.data as { err_no?: number; data?: { pgc_id: string }; message?: string } + } + + /** + * 通过 URL 上传图片 + * 新接口需要先下载图片再上传 + */ + protected async uploadImageByUrl(src: string): Promise { + // 1. 下载图片 + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + // 2. 获取 csrf token + const csrfToken = await this.getCsrfToken() + + // 3. 上传到新接口 + const formData = new FormData() + formData.append('image', imageBlob, 'image.jpg') + + const uploadUrl = 'https://mp.toutiao.com/spice/image?upload_source=20020002&aid=1231&device_platform=web' + const uploadResponse = await this.runtime.fetch(uploadUrl, { + method: 'POST', + credentials: 'include', + headers: { + 'x-secsdk-csrf-token': csrfToken, + }, + body: formData, + }) + + const text = await uploadResponse.text() + let res: { + code?: number + data?: { + image_uri?: string + image_url?: string + image_width?: number + image_height?: number + } + message?: string + } + + try { + res = JSON.parse(text) + } catch { + throw new Error('图片上传响应解析失败') + } + + logger.debug('Image upload response:', res) + + if (res.code !== 0 || !res.data) { + throw new Error(res.message || '图片上传失败') + } + + // 验证返回的 URL 不为空 + if (!res.data.image_url || !res.data.image_uri) { + logger.error('Upload response missing URL:', res) + throw new Error('图片上传返回数据不完整') + } + + return { + url: res.data.image_url, + attrs: { + 'class': '', + 'ic-uri': '', + 'image_type': 'image/png', + 'mime_type': '', + 'web_uri': res.data.image_uri, + 'img_width': String(res.data.image_width || 0), + 'img_height': String(res.data.image_height || 0), + }, + } + } +} diff --git a/sync/adapters/platforms/weibo.ts b/sync/adapters/platforms/weibo.ts new file mode 100644 index 0000000..304e8fc --- /dev/null +++ b/sync/adapters/platforms/weibo.ts @@ -0,0 +1,484 @@ +/** + * 微博适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' +import { parseMarkdownImages } from '../../lib/markdown-images' + +const logger = createLogger('Weibo') + +interface WeiboUserConfig { + uid: string + nick: string + avatar_large: string +} + +export class WeiboAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'weibo', + name: '微博', + icon: 'https://weibo.com/favicon.ico', + homepage: 'https://card.weibo.com/article/v5/editor', + capabilities: ['article', 'draft', 'image_upload', 'cover'], + category: '主流自媒体', + syncMode: 'api', + } + + /** 预处理配置: 微博使用 HTML 格式 */ + readonly preprocessConfig = { + outputFormat: 'html' as const, + } + + private userConfig: WeiboUserConfig | null = null + + /** 微博 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://card.weibo.com/*', + headers: { + 'Origin': 'https://card.weibo.com', + 'Referer': 'https://card.weibo.com/article/v5/editor', + }, + resourceTypes: ['xmlhttprequest'], + }, + { + urlFilter: '*://picupload.weibo.com/*', + headers: { + 'Origin': 'https://weibo.com', + 'Referer': 'https://weibo.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + const config = await this.getUserConfig() + + if (config?.uid) { + return { + isAuthenticated: true, + userId: config.uid, + username: config.nick, + avatar: config.avatar_large, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + /** + * 获取用户配置 (从编辑器页面解析) + */ + private async getUserConfig(): Promise { + if (this.userConfig) { + return this.userConfig + } + + const response = await this.runtime.fetch('https://card.weibo.com/article/v5/editor', { + credentials: 'include', + }) + const html = await response.text() + + const configMatch = html.match(/config:\s*JSON\.parse\('(.+?)'\)/) + if (!configMatch) { + logger.error('Failed to find config in HTML') + return null + } + + try { + const configJson = configMatch[1].replace(/\\'/g, "'").replace(/\\\\/g, '\\') + const config = JSON.parse(configJson) + + if (!config.uid) { + return null + } + + this.userConfig = { + uid: String(config.uid), + nick: config.nick || '', + avatar_large: config.avatar_large || '', + } + + logger.debug('User config:', this.userConfig) + return this.userConfig + } catch (e) { + logger.error('Failed to parse config:', e) + return null + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + const config = await this.getUserConfig() + if (!config?.uid) { + throw new Error('请先登录微博') + } + + // Use pre-processed HTML content directly + let content = article.html || '' + + content = content.replace(/>\s+<') + content = await this.processWeiboImages(content, options?.onImageProgress) + + const createReqId = this.generateReqId() + const createResponse = await this.runtime.fetch( + `https://card.weibo.com/article/v5/aj/editor/draft/create?uid=${config.uid}&_rid=${createReqId}`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'accept': 'application/json, text/plain, */*', + 'SN-REQID': createReqId, + }, + body: new URLSearchParams({}), + } + ) + const createRes = await createResponse.json() as { + code: number + msg?: string + data?: { id: string } + } + + if (createRes.code !== 100000 || !createRes.data?.id) { + throw new Error(createRes.msg || '创建草稿失败') + } + + const postId = createRes.data.id + logger.debug('Created draft:', postId) + + let coverUrl = '' + if (article.cover) { + try { + const coverResult = await this.uploadImageByUrl(article.cover) + coverUrl = coverResult.url + } catch (e) { + logger.warn('Failed to upload cover:', e) + } + } + + const saveReqId = this.generateReqId() + const saveResponse = await this.runtime.fetch( + `https://card.weibo.com/article/v5/aj/editor/draft/save?uid=${config.uid}&id=${postId}&_rid=${saveReqId}`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'accept': 'application/json, text/plain, */*', + 'SN-REQID': saveReqId, + }, + body: new URLSearchParams({ + id: postId, + title: article.title, + subtitle: '', + type: '', + status: '0', + publish_at: '', + error_msg: '', + error_code: '0', + collection: '[]', + free_content: '', + content: content, + cover: coverUrl, + summary: '', + writer: '', + extra: 'null', + is_word: '0', + article_recommend: '[]', + follow_to_read: '1', + isreward: '1', + pay_setting: '{"ispay":0,"isvclub":0}', + source: '0', + action: '1', + content_type: '0', + save: '1', + }), + } + ) + const saveRes = await saveResponse.json() as { + code: string | number + msg?: string + } + + logger.debug('Save response:', saveRes) + + const code = String(saveRes.code) + if (code !== '100000') { + throw new Error(saveRes.msg || `保存失败 (错误码: ${code})`) + } + + const draftUrl = `https://card.weibo.com/article/v5/editor#/draft/${postId}` + + return this.createResult(true, { + postId: postId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + private generateReqId(): string { + const input = `${this.userConfig?.uid}&${Date.now()}` + const base64 = btoa(input) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + let result = base64 + while (result.length < 43) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result.slice(0, 43) + } + + protected async uploadImageByUrl(src: string): Promise { + if (src.startsWith('data:')) { + logger.debug('Uploading data URI image via direct upload') + return this.uploadDataUri(src) + } + + const config = await this.getUserConfig() + if (!config?.uid) { + throw new Error('请先登录微博') + } + + const reqId = this.generateReqId() + + try { + const uploadRes = await this.runtime.fetch( + `https://card.weibo.com/article/v5/aj/editor/plugins/asyncuploadimg?uid=${config.uid}&_rid=${reqId}`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'accept': 'application/json, text/plain, */*', + 'SN-REQID': reqId, + }, + body: new URLSearchParams({ 'urls[0]': src }), + } + ) + + const uploadData = await uploadRes.json() + logger.debug('Async upload response:', uploadData) + } catch (e) { + logger.warn('Async upload request failed, will try polling anyway:', e) + } + + const imgDetail = await this.waitForImageDone(src) + const imgUrl = `https://wx3.sinaimg.cn/large/${imgDetail.pid}.jpg` + + return { + url: imgUrl, + attrs: { + 'data-pid': imgDetail.pid, + }, + } + } + + async uploadImageBase64(imageData: string, mimeType: string): Promise { + const dataUri = `data:${mimeType};base64,${imageData}` + return this.uploadDataUri(dataUri) + } + + private async uploadDataUri(dataUri: string): Promise { + const match = dataUri.match(/^data:([^;]+);base64,(.+)$/) + if (!match) { + throw new Error('Invalid data URI format') + } + + const mimeType = match[1] + const base64Data = match[2] + + const binaryStr = atob(base64Data) + const bytes = new Uint8Array(binaryStr.length) + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i) + } + const blob = new Blob([bytes], { type: mimeType }) + + logger.debug(`Uploading blob: ${mimeType}, size: ${blob.size}`) + + const reqId = this.generateReqId() + const uploadUrl = `https://picupload.weibo.com/interface/pic_upload.php?app=miniblog&s=json&p=1&data=1&url=&markpos=1&logo=0&nick=&file_source=4&_rid=${reqId}` + + const response = await this.runtime.fetch(uploadUrl, { + method: 'POST', + credentials: 'include', + body: blob, + }) + + const result = await response.json() as { + code?: string + data?: { + pics?: { + pic_1?: { + pid: string + width: number + height: number + } + } + } + } + + logger.debug('Direct upload response:', result) + + if (!result.data?.pics?.pic_1?.pid) { + throw new Error('图片上传失败: ' + JSON.stringify(result)) + } + + const pid = result.data.pics.pic_1.pid + const imgUrl = `https://wx3.sinaimg.cn/large/${pid}.jpg` + + return { + url: imgUrl, + attrs: { + 'data-pid': pid, + }, + } + } + + private async processWeiboImages( + content: string, + onProgress?: (current: number, total: number) => void + ): Promise { + // Content is pre-processed, use directly + const processedContent = content + + const figureImgRegex = /]*>[\s\S]*?]+src="([^"]+)"[^>]*>[\s\S]*?<\/figure>/gi + const imgRegex = /]+src="([^"]+)"[^>]*>/gi + const matches: { full: string; src: string; hasFigure: boolean }[] = [] + + let match + const figureMatches = new Set() + while ((match = figureImgRegex.exec(processedContent)) !== null) { + matches.push({ full: match[0], src: match[1], hasFigure: true }) + figureMatches.add(match[1]) + } + + while ((match = imgRegex.exec(processedContent)) !== null) { + if (!figureMatches.has(match[1])) { + matches.push({ full: match[0], src: match[1], hasFigure: false }) + } + } + + for (const mdMatch of parseMarkdownImages(processedContent)) { + matches.push({ full: mdMatch.full, src: mdMatch.src, hasFigure: false }) + } + + if (matches.length === 0) { + return processedContent + } + + logger.info(`Found ${matches.length} images to process`) + + let result = processedContent + const uploadedMap = new Map() + let processed = 0 + + for (const { full, src, hasFigure } of matches) { + if (!src) continue + + if (src.includes('sinaimg.cn') || src.includes('weibo.com')) { + logger.debug(`Skipping weibo image: ${src}`) + continue + } + + if (src.startsWith('data:')) { + continue + } + + processed++ + onProgress?.(processed, matches.length) + + try { + let imgInfo = uploadedMap.get(src) + + if (!imgInfo) { + logger.debug(`Uploading image ${processed}/${matches.length}: ${src}`) + const uploadResult = await this.uploadImageByUrl(src) + const pid = uploadResult.attrs?.['data-pid'] as string || '' + imgInfo = { pid, url: uploadResult.url } + uploadedMap.set(src, imgInfo) + } + + let replacement: string + if (hasFigure) { + replacement = full.replace( + /]+src="[^"]+"[^>]*>/i, + `` + ) + } else { + replacement = `
    ` + } + + result = result.replace(full, replacement) + logger.debug(`Image uploaded: ${imgInfo.url}`) + } catch (error) { + logger.error(`Failed to upload image: ${src}`, error) + } + + await this.delay(300) + } + + return result + } + + private async waitForImageDone(src: string): Promise<{ + pid: string + url: string + task_status_code: number + }> { + const config = await this.getUserConfig() + const maxAttempts = 30 + + for (let i = 0; i < maxAttempts; i++) { + const reqId = this.generateReqId() + const response = await this.runtime.fetch( + `https://card.weibo.com/article/v5/aj/editor/plugins/asyncimginfo?uid=${config!.uid}&_rid=${reqId}`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'accept': 'application/json, text/plain, */*', + 'SN-REQID': reqId, + }, + body: new URLSearchParams({ 'urls[0]': src }), + } + ) + + const res = await response.json() as { + data?: Array<{ pid: string; url: string; task_status_code: number }> + } + + const item = res.data?.[0] + const statusCode = item?.task_status_code + if (statusCode === 1 && item) { + logger.debug('Image upload complete:', item) + return item + } + + if (statusCode === 2) { + // task_status_code === 2 表示失败,不要继续轮询 + throw new Error('图片上传失败') + } + + await this.delay(1000) + } + + throw new Error('图片上传超时') + } +} diff --git a/sync/adapters/platforms/weixin.ts b/sync/adapters/platforms/weixin.ts new file mode 100644 index 0000000..428d252 --- /dev/null +++ b/sync/adapters/platforms/weixin.ts @@ -0,0 +1,411 @@ +/** + * 微信公众号适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' +import { inlineCss } from '../../lib/css-inline' + +const logger = createLogger('Weixin') + +interface WeixinMeta { + token: string + userName: string + nickName: string + ticket: string + svrTime: number + avatar: string +} + +// 微信公众号的默认 CSS 样式 +const WEIXIN_CSS = ` +p { + color: rgb(51, 51, 51); + font-size: 15px; + line-height: 1.75em; + margin: 0 0 1em 0; +} +h1, h2, h3, h4, h5, h6 { + font-weight: bold; +} +h1 { font-size: 1.25em; line-height: 1.4em; margin: 1em 0 0.5em 0; } +h2 { font-size: 1.125em; margin: 1em 0 0.5em 0; } +h3 { font-size: 1.05em; margin: 0.8em 0 0.4em 0; } +h4, h5, h6 { font-size: 1em; margin: 0.8em 0 0.4em 0; } +li p { margin: 0; } +ul, ol { margin: 1em 0; padding-left: 2em; } +li { margin-bottom: 0.4em; } +pre, tt, code, kbd, samp { font-family: monospace; } +pre { white-space: pre; margin: 1em 0; } +blockquote { border-left: 4px solid #ddd; padding-left: 1em; margin: 1em 0; color: #666; } +hr { border: none; border-top: 1px solid #ddd; margin: 1.5em 0; } +i, cite, em, var, address { font-style: italic; } +b, strong { font-weight: bolder; } +` + +export class WeixinAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'weixin', + name: '微信公众号', + icon: 'https://mp.weixin.qq.com/favicon.ico', + homepage: 'https://mp.weixin.qq.com', + capabilities: ['article', 'draft', 'image_upload'], + category: '主流自媒体', + syncMode: 'api', + } + + /** 预处理配置: 微信公众号使用 HTML 格式,移除非微信域名链接,压缩标签间空白避免 ProseMirror 产生空节点 */ + readonly preprocessConfig = { + outputFormat: 'html' as const, + removeLinks: true, + keepLinkDomains: ['mp.weixin.qq.com', 'weixin.qq.com'], + compactHtml: true, + } + + private weixinMeta: WeixinMeta | null = null + + /** 微信公众号 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://mp.weixin.qq.com/cgi-bin/*', + headers: { + 'Origin': 'https://mp.weixin.qq.com', + 'Referer': 'https://mp.weixin.qq.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch( + 'https://mp.weixin.qq.com/', + { + method: 'GET', + credentials: 'include', + } + ) + + const html = await response.text() + + const tokenMatch = html.match(/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/) + if (!tokenMatch) { + logger.debug(' No token found') + return { isAuthenticated: false } + } + + const ticketMatch = html.match(/ticket:\s*["']([^"']+)["']/) + const userNameMatch = html.match(/user_name:\s*["']([^"']+)["']/) + const nickNameMatch = html.match(/nick_name:\s*["']([^"']+)["']/) + const timeMatch = html.match(/time:\s*["'](\d+)["']/) + const headImgMatch = html.match(/head_img:\s*['"]([^'"]+)['"]/) + + const avatarMatch = html.match(/class="weui-desktop-account__thumb"[^>]*src="([^"]+)"/) + let avatar = avatarMatch ? avatarMatch[1] : (headImgMatch ? headImgMatch[1] : '') + if (avatar.startsWith('http://')) { + avatar = avatar.replace('http://', 'https://') + } + + this.weixinMeta = { + token: tokenMatch[1], + userName: userNameMatch ? userNameMatch[1] : '', + nickName: nickNameMatch ? nickNameMatch[1] : '', + ticket: ticketMatch ? ticketMatch[1] : '', + svrTime: timeMatch ? Number(timeMatch[1]) : Date.now() / 1000, + avatar, + } + + logger.debug(' Auth info:', { + userName: this.weixinMeta.userName, + nickName: this.weixinMeta.nickName, + hasToken: !!this.weixinMeta.token, + }) + + return { + isAuthenticated: true, + userId: this.weixinMeta.userName, + username: this.weixinMeta.nickName, + avatar: this.weixinMeta.avatar, + } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + if (!this.weixinMeta) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录微信公众号') + } + } + + // 微信到微信:使用原始 HTML,跳过所有处理 + let content = (article.source?.platform === 'weixin' && (article as any).rawHtml) + ? (article as any).rawHtml + : (article.html || '') + + if (article.source?.platform === 'weixin') { + logger.info('Source is WeChat, using raw HTML, skipping content processing') + } else { + content = this.processLatex(content) + content = this.stripExternalLinks(content) + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['mmbiz.qpic.cn', 'mmbiz.qlogo.cn'], + onProgress: options?.onImageProgress, + } + ) + content = this.processContent(content) + } + + const formData = new URLSearchParams({ + token: this.weixinMeta!.token, + lang: 'zh_CN', + f: 'json', + ajax: '1', + random: String(Math.random()), + AppMsgId: '', + count: '1', + data_seq: '0', + operate_from: 'Chrome', + isnew: '0', + ad_video_transition0: '', + can_reward0: '0', + related_video0: '', + is_video_recommend0: '-1', + title0: article.title, + author0: '', + writerid0: '0', + fileid0: '', + digest0: '', + auto_gen_digest0: '1', + content0: content, + sourceurl0: '', + need_open_comment0: '1', + only_fans_can_comment0: '0', + cdn_url0: '', + cdn_235_1_url0: '', + cdn_1_1_url0: '', + cdn_url_back0: '', + crop_list0: '', + music_id0: '', + video_id0: '', + voteid0: '', + voteismlt0: '', + supervoteid0: '', + cardid0: '', + cardquantity0: '', + cardlimit0: '', + vid_type0: '', + show_cover_pic0: '0', + shortvideofileid0: '', + copyright_type0: '0', + releasefirst0: '', + platform0: '', + reprint_permit_type0: '', + allow_reprint0: '', + allow_reprint_modify0: '', + original_article_type0: '', + ori_white_list0: '', + free_content0: '', + fee0: '0', + ad_id0: '', + guide_words0: '', + is_share_copyright0: '0', + share_copyright_url0: '', + source_article_type0: '', + reprint_recommend_title0: '', + reprint_recommend_content0: '', + share_page_type0: '0', + share_imageinfo0: '{"list":[]}', + share_video_id0: '', + dot0: '{}', + share_voice_id0: '', + insert_ad_mode0: '', + categories_list0: '[]', + }) + + const response = await this.runtime.fetch( + `https://mp.weixin.qq.com/cgi-bin/operate_appmsg?t=ajax-response&sub=create&type=77&token=${this.weixinMeta!.token}&lang=zh_CN`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData, + } + ) + + const res = await response.json() as { + appMsgId?: string + ret?: number + base_resp?: { ret: number; err_msg?: string } + } + + logger.debug(' Save response:', res) + + if (!res.appMsgId) { + const errMsg = this.formatError(res) + throw new Error(errMsg) + } + + const draftUrl = `https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit&action=edit&type=77&appmsgid=${res.appMsgId}&token=${this.weixinMeta!.token}&lang=zh_CN` + + return this.createResult(true, { + postId: res.appMsgId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + protected async uploadImageByUrl(src: string): Promise { + if (!this.weixinMeta) { + throw new Error('未登录') + } + + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + const formData = new FormData() + const timestamp = Date.now() + const fileName = `${timestamp}.jpg` + + formData.append('type', imageBlob.type || 'image/jpeg') + formData.append('id', String(timestamp)) + formData.append('name', fileName) + formData.append('lastModifiedDate', new Date().toString()) + formData.append('size', String(imageBlob.size)) + formData.append('file', imageBlob, fileName) + + const { token, userName, ticket, svrTime } = this.weixinMeta + const seq = Date.now() + + const response = await this.runtime.fetch( + `https://mp.weixin.qq.com/cgi-bin/filetransfer?action=upload_material&f=json&scene=8&writetype=doublewrite&groupid=1&ticket_id=${userName}&ticket=${ticket}&svr_time=${svrTime}&token=${token}&lang=zh_CN&seq=${seq}&t=${Math.random()}`, + { + method: 'POST', + credentials: 'include', + body: formData, + } + ) + + const res = await response.json() as { + cdn_url?: string + content?: string + base_resp?: { err_msg: string; ret: number } + } + + logger.debug(' Image upload response:', res) + + if (res.base_resp?.err_msg !== 'ok' || !res.cdn_url) { + throw new Error('图片上传失败: ' + src) + } + + return { + url: res.cdn_url, + } + } + + private isLatexFormula(text: string): boolean { + if (/[\\^_{}]/.test(text)) return true + if (/[α-ωΑ-Ω]/.test(text)) return true + if (/[∑∏∫∂∇∞≠≤≥±×÷√]/.test(text)) return true + return false + } + + private processLatex(content: string): string { + const LATEX_API = 'https://latex.codecogs.com/png.latex' + + content = content.replace(/\$\$([^$]+)\$\$/g, (match, latex) => { + if (!this.isLatexFormula(latex)) return match + const encoded = encodeURIComponent(latex.trim()) + return `

    formula

    ` + }) + + content = content.replace(/\$([^$]+)\$/g, (match, latex) => { + if (!this.isLatexFormula(latex)) return match + const encoded = encodeURIComponent(latex.trim()) + return `formula` + }) + + return content + } + + private processContent(content: string): string { + const wrapped = `
    ${content}
    ` + return inlineCss(wrapped, WEIXIN_CSS) + } + + /** + * 移除外部链接(微信不允许非 mp.weixin.qq.com 域名的链接) + * 将 文字 转换为 文字 + */ + private stripExternalLinks(content: string): string { + // 匹配 标签,保留微信域名的链接 + return content.replace( + /]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, + (match, href, text) => { + // 保留微信域名的链接 + if (href && ( + href.includes('mp.weixin.qq.com') || + href.includes('weixin.qq.com') || + href.startsWith('#') || // 锚点链接 + href.startsWith('javascript:') // JS 链接 + )) { + return match + } + // 外部链接只保留文字 + return text + } + ) + } + + private formatError(res: { ret?: number; base_resp?: { ret: number } }): string { + const ret = res.ret ?? res.base_resp?.ret + + const errorMap: Record = { + [-6]: '请输入验证码', + [-8]: '请输入验证码', + [-1]: '系统错误,请注意备份内容后重试', + [-2]: '参数错误,请注意备份内容后重试', + [-5]: '服务错误,请注意备份内容后重试', + [-99]: '内容超出字数,请调整', + [-206]: '服务负荷过大,请稍后重试', + [200002]: '参数错误,请注意备份内容后重试', + [200003]: '登录态超时,请重新登录', + [412]: '图文中含非法外链', + [62752]: '可能含有具备安全风险的链接,请检查', + [64502]: '你输入的微信号不存在', + [64505]: '发送预览失败,请稍后再试', + [64506]: '保存失败,链接不合法', + [64507]: '内容不能包含外部链接', + [64562]: '请勿插入非微信域名的链接', + [64509]: '正文中不能包含超过3个视频', + [64515]: '当前素材非最新内容,请重新打开并编辑', + [64702]: '标题超出64字长度限制', + [64703]: '摘要超出120字长度限制', + [64705]: '内容超出字数,请调整', + [10806]: '正文不能有违规内容,请重新编辑', + [10807]: '内容不能违反公众平台协议', + [220001]: '素材管理中的存储数量已达上限', + [220002]: '图片库已达到存储上限', + } + + return errorMap[ret as number] || `同步失败 (错误码: ${ret})` + } +} diff --git a/sync/adapters/platforms/woshipm.ts b/sync/adapters/platforms/woshipm.ts new file mode 100644 index 0000000..30dee2c --- /dev/null +++ b/sync/adapters/platforms/woshipm.ts @@ -0,0 +1,263 @@ +/** + * 人人都是产品经理 (woshipm.com) 适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Woshipm') + +export class WoshipmAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'woshipm', + name: '人人都是产品经理', + icon: 'https://www.woshipm.com/favicon.ico', + homepage: 'https://www.woshipm.com', + capabilities: ['article', 'draft', 'image_upload'], + category: '产品', + syncMode: 'api', + } + + /** 预处理配置: 人人都是产品经理使用 HTML 格式 */ + readonly preprocessConfig = { + outputFormat: 'html' as const, + removeEmptyLines: true, + } + + private jltoken: string = '' + + /** 人人都是产品经理 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://woshipm.com/wp-admin/admin-ajax.php*', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + resourceTypes: ['xmlhttprequest'], + }, + { + urlFilter: '*://woshipm.com/api2/*', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + resourceTypes: ['xmlhttprequest'], + }, + { + urlFilter: '*://woshipm.com/tensorflow/upyun/upload*', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + // 1. 先获取用户页面以获取 uid + const pageResponse = await this.runtime.fetch('https://www.woshipm.com/writing', { + method: 'GET', + credentials: 'include', + }) + + const pageText = await pageResponse.text() + + // 从页面提取 jltoken: "jltoken":"xxx" + const jltokenMatch = pageText.match(/"jltoken"\s*:\s*"([^"]+)"/) + if (jltokenMatch) { + this.jltoken = jltokenMatch[1] + logger.debug('Found jltoken') + } + + // 从页面提取 uid: var userSettings = {"url":"\/","uid":"1585",...} + const uidMatch = pageText.match(/var\s+userSettings\s*=\s*\{[^}]*"uid"\s*:\s*"(\d+)"/) + if (!uidMatch) { + return { isAuthenticated: false } + } + + const uid = uidMatch[1] + + // 2. 调用 profile API 验证登录状态 + const response = await this.runtime.fetch( + `https://www.woshipm.com/api2/user/profile?uid=${uid}`, + { + method: 'GET', + credentials: 'include', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + } + ) + + const data = await response.json() as { + CODE?: number + RESULT?: { + userInfoVo?: { + uid?: number + nickName?: string + avartar?: string // API typo: avartar instead of avatar + } + } + } + + if (data.CODE === 200 && data.RESULT?.userInfoVo?.uid) { + return { + isAuthenticated: true, + userId: String(data.RESULT.userInfoVo.uid), + username: data.RESULT.userInfoVo.nickName, + avatar: data.RESULT.userInfoVo.avartar, + } + } + + return { isAuthenticated: false } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + // 1. 使用预处理好的 HTML(Content Script 已处理代码块、图片、特殊标签等) + // 人人都是产品经理使用 HTML 格式 + let content = article.html || '' + + // 2. 处理图片 + content = await this.processImages( + content, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['woshipm.com', 'image.woshipm.com'], + onProgress: options?.onImageProgress, + } + ) + + // 4. 创建草稿 + const createResponse = await this.runtime.fetch( + 'https://www.woshipm.com/wp-admin/admin-ajax.php', + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new URLSearchParams({ + action: 'add_draft', + post_title: article.title, + post_content: content, + }), + } + ) + + // 检查响应状态和内容 + const responseText = await createResponse.text() + logger.debug('Create draft response:', createResponse.status, responseText.substring(0, 300)) + + if (!createResponse.ok) { + throw new Error(`创建草稿失败: ${createResponse.status} - ${responseText}`) + } + + let createData: { post_id?: string | number; url?: string; success?: boolean; error?: string } + try { + createData = JSON.parse(responseText) + } catch { + throw new Error(`创建草稿失败: 响应不是有效 JSON - ${responseText.substring(0, 100)}`) + } + + if (!createData.post_id) { + throw new Error(createData.error || '创建草稿失败: 无效响应') + } + + const draftId = String(createData.post_id) + const draftUrl = createData.url || `https://www.woshipm.com/writing?pid=${draftId}` + + logger.debug('Draft created:', draftId) + + return this.createResult(true, { + postId: draftId, + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + /** + * 通过 Blob 上传图片(覆盖基类方法) + */ + async uploadImage(file: Blob, filename?: string): Promise { + return this.uploadImageBinaryInternal(file, filename || 'image.png') + } + + /** + * 通过 URL 上传图片 + */ + protected async uploadImageByUrl(src: string): Promise { + try { + // 1. 下载图片(使用 runtime.fetch 以支持跨域) + const imageResponse = await this.runtime.fetch(src, { + credentials: 'omit', + }) + if (!imageResponse.ok) { + throw new Error(`Failed to fetch image: ${imageResponse.status}`) + } + + const blob = await imageResponse.blob() + + // 2. 上传到 woshipm + const url = await this.uploadImageBinaryInternal(blob, this.getFilenameFromUrl(src)) + return { url } + } catch (error) { + logger.warn('Failed to upload image by URL:', src, error) + return { url: src } // 失败时返回原 URL + } + } + + /** + * 上传图片 (二进制方式) - 内部使用 + */ + private async uploadImageBinaryInternal(file: Blob, filename: string): Promise { + const formData = new FormData() + formData.append('action', 'wpuf_insert_image') + formData.append('name', filename) + formData.append('files', file, filename) + + const headers: Record = { + 'Origin': 'https://www.woshipm.com', + 'Referer': 'https://www.woshipm.com/writing', + } + if (this.jltoken) { + headers['jlstar'] = `Bearer ${this.jltoken}` + } + + const response = await this.runtime.fetch('https://www.woshipm.com/tensorflow/upyun/upload', { + method: 'POST', + credentials: 'include', + headers, + body: formData, + }) + + const data = await response.json() as { + data?: Array<{ url?: string }> + error?: string + } + + if (data.data && data.data.length > 0 && data.data[0].url) { + logger.debug('Uploaded image:', filename, '->', data.data[0].url) + return data.data[0].url + } + + throw new Error(data.error || 'Failed to upload image') + } + + /** + * 从 URL 提取文件名 + */ + private getFilenameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname + const filename = pathname.split('/').pop() || 'image.png' + return filename + } catch { + return 'image.png' + } + } +} diff --git a/sync/adapters/platforms/x.ts b/sync/adapters/platforms/x.ts new file mode 100644 index 0000000..a432fe7 --- /dev/null +++ b/sync/adapters/platforms/x.ts @@ -0,0 +1,91 @@ +/** + * X (Twitter) 适配器 + * + * X 的发帖接口使用 GraphQL + 客户端 bearer / csrf 校验,且草稿接口未公开, + * 因此采用「编辑器辅助」方式:打开撰写框并填充内容(纯文本), + * 由用户确认后发布或保存草稿。 + * + * 登录检测:GET https://x.com/i/api/1.1/account/settings.json + * (使用 Web 端公开 bearer + ct0 cookie 作为 csrf token) + */ +import { EditorAssistAdapter, type EditorAssistConfig } from '../editor-assist' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('X') + +/** X Web 端公开 bearer token */ +const X_WEB_BEARER = + 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA' + +export class XAdapter extends EditorAssistAdapter { + readonly meta: PlatformMeta = { + id: 'x', + name: 'X (Twitter)', + icon: 'https://abs.twimg.com/favicons/twitter.3.ico', + homepage: 'https://x.com', + capabilities: ['article'], + category: '海外', + experimental: true, + syncMode: 'editor', + } + + readonly editorConfig: EditorAssistConfig = { + editorUrl: 'https://x.com/compose/post', + titleSelectors: [], + editorSelectors: [ + '[data-testid^="tweetTextarea"]', + 'div[role="textbox"][contenteditable="true"]', + '.public-DraftEditor-content', + '[contenteditable="true"]' + ], + hint: '已在 X 撰写框填充内容,请确认后发布或保存草稿(长文建议使用 X Articles)', + waitBeforeFill: 5000, + // X 为纯文本编辑器 + textOnly: true, + } + + async checkAuth(): Promise { + try { + if (!this.runtime.getCookie) { + return { isAuthenticated: false, error: '当前环境不支持读取 Cookie' } + } + + const csrfToken = await this.runtime.getCookie('x.com', 'ct0') + if (!csrfToken) { + return { isAuthenticated: false } + } + + const res = await this.get<{ + screen_name?: string + name?: string + profile_image_url_https?: string + }>('https://x.com/i/api/1.1/account/settings.json', { + authorization: `Bearer ${X_WEB_BEARER}`, + 'x-csrf-token': csrfToken, + 'x-twitter-active-user': 'yes', + 'x-twitter-auth-type': 'OAuth2Session', + 'x-twitter-client-language': 'zh-cn', + }) + + if (!res?.screen_name) { + return { isAuthenticated: false } + } + + return { + isAuthenticated: true, + userId: res.screen_name, + username: res.name || res.screen_name, + avatar: res.profile_image_url_https, + } + } catch (error) { + logger.debug('checkAuth failed:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.openEditorAndFill(article, options) + } +} diff --git a/sync/adapters/platforms/xiaohongshu.ts b/sync/adapters/platforms/xiaohongshu.ts new file mode 100644 index 0000000..7ac5e4c --- /dev/null +++ b/sync/adapters/platforms/xiaohongshu.ts @@ -0,0 +1,83 @@ +/** + * 小红书适配器 + * + * 小红书创作者平台的发布接口需要 x-s / x-t 浏览器端签名, + * 直接调用会被风控拦截,因此采用「编辑器辅助」方式: + * 打开「写长文」编辑器并自动填充标题与正文,由用户确认后发布。 + * + * 登录检测:GET https://creator.xiaohongshu.com/api/galaxy/user/info + */ +import { EditorAssistAdapter, type EditorAssistConfig } from '../editor-assist' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Xiaohongshu') + +interface XhsUserInfo { + userId?: string + nickName?: string + userName?: string + userImage?: string + avatar?: string +} + +export class XiaohongshuAdapter extends EditorAssistAdapter { + readonly meta: PlatformMeta = { + id: 'xiaohongshu', + name: '小红书', + icon: 'https://www.xiaohongshu.com/favicon.ico', + homepage: 'https://creator.xiaohongshu.com', + capabilities: ['article'], + category: '主流自媒体', + experimental: true, + syncMode: 'editor', + } + + readonly editorConfig: EditorAssistConfig = { + // target=article 直达「写长文」编辑器 + editorUrl: 'https://creator.xiaohongshu.com/publish/publish?target=article', + titleSelectors: [ + 'input[placeholder*="标题"]', + 'input[placeholder*="填写标题"]', + 'input[class*="title"]' + ], + editorSelectors: [ + '.tiptap.ProseMirror[contenteditable="true"]', + '.ProseMirror[contenteditable="true"]', + '[contenteditable="true"][class*="editor"]', + 'div[contenteditable="true"]' + ], + removeSelectors: ['.tiptap-topic', '.xhs-topic'], + hint: '小红书长文草稿保存在「草稿箱 → 长文笔记」,请确认后发布', + waitBeforeFill: 4500, + } + + async checkAuth(): Promise { + try { + const res = await this.get<{ success?: boolean; data?: XhsUserInfo }>( + 'https://creator.xiaohongshu.com/api/galaxy/user/info' + ) + + const data = res?.data + + if (!data || (!data.userId && !data.nickName && !data.userName)) { + return { isAuthenticated: false } + } + + return { + isAuthenticated: true, + userId: data.userId ? String(data.userId) : undefined, + username: data.nickName || data.userName, + avatar: data.userImage || data.avatar, + } + } catch (error) { + logger.debug('checkAuth failed:', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.openEditorAndFill(article, options) + } +} diff --git a/sync/adapters/platforms/xueqiu.ts b/sync/adapters/platforms/xueqiu.ts new file mode 100644 index 0000000..a0e9e43 --- /dev/null +++ b/sync/adapters/platforms/xueqiu.ts @@ -0,0 +1,263 @@ +/** + * 雪球适配器 + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import { Remarkable } from 'remarkable' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' + +const logger = createLogger('Xueqiu') + +interface XueqiuUser { + id: string + screen_name: string + photo_domain: string + profile_image_url: string +} + +export class XueqiuAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'xueqiu', + name: '雪球', + icon: 'https://xqdoc.imedao.com/17aebcfb84a145d33fc18679.ico', + homepage: 'https://mp.xueqiu.com/writeV2', + capabilities: ['article', 'draft', 'image_upload'], + category: '财经', + syncMode: 'api', + } + + /** 预处理配置: 雪球使用 Markdown 格式 */ + readonly preprocessConfig = { + outputFormat: 'markdown' as const, + // doPreFilter + processDocCode (旧版) + removeSpecialTags: true, + removeSpecialTagsWithParent: true, + processCodeBlocks: true, + } + + private currentUser: XueqiuUser | null = null + + /** 雪球 API 需要的 Header 规则 */ + private readonly HEADER_RULES = [ + { + urlFilter: '*://mp.xueqiu.com/xq/*', + headers: { + 'Origin': 'https://mp.xueqiu.com', + 'Referer': 'https://mp.xueqiu.com/', + }, + resourceTypes: ['xmlhttprequest'], + }, + ] + + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch( + 'https://mp.xueqiu.com/writeV2', + { + method: 'GET', + credentials: 'include', + } + ) + + const html = await response.text() + + // 解析 window.UOM_CURRENTUSER - 新版格式 + const userMatch = html.match(/window\.UOM_CURRENTUSER\s*=\s*(\{[\s\S]*?\})\s*<\/script>/) + if (!userMatch) { + return { isAuthenticated: false } + } + + try { + const state = JSON.parse(userMatch[1]) + const { currentUser } = state + + if (!currentUser?.id) { + return { isAuthenticated: false } + } + + this.currentUser = currentUser + + const avatar = currentUser.photo_domain && currentUser.profile_image_url + ? `https:${currentUser.photo_domain}${currentUser.profile_image_url.split(',')[0]}` + : '' + + return { + isAuthenticated: true, + userId: String(currentUser.id), + username: currentUser.screen_name, + avatar, + } + } catch (e) { + logger.error(' Failed to parse user data:', e) + return { isAuthenticated: false } + } + } catch (error) { + logger.debug('checkAuth: not logged in -', error) + return { isAuthenticated: false, error: (error as Error).message } + } + } + + async publish(article: Article, options?: PublishOptions): Promise { + return this.withHeaderRules(this.HEADER_RULES, async () => { + logger.info('Starting publish...') + + // 1. 确保已登录 + if (!this.currentUser) { + const auth = await this.checkAuth() + if (!auth.isAuthenticated) { + throw new Error('请先登录雪球') + } + } + + // Use pre-processed markdown content directly + let markdown = article.markdown || '' + + // Process images in markdown + markdown = await this.processImages( + markdown, + (src) => this.uploadImageByUrl(src), + { + skipPatterns: ['xueqiu.com', 'imedao.com'], + onProgress: options?.onImageProgress, + } + ) + + // Convert Markdown to simplified HTML (Xueqiu format) + const md = new Remarkable({ + html: true, + breaks: true, + }) + + // 自定义渲染规则适配雪球格式 + // 所有标题都转为 h4 + md.renderer.rules.heading_open = () => '

    ' + md.renderer.rules.heading_close = () => '

    ' + + // strong -> b + md.renderer.rules.strong_open = () => '' + md.renderer.rules.strong_close = () => '' + + // em -> i + md.renderer.rules.em_open = () => '' + md.renderer.rules.em_close = () => '' + + // 列表 - 移除列表包装,列表项内的 p 标签由 remarkable 自动处理 + md.renderer.rules.bullet_list_open = () => '' + md.renderer.rules.bullet_list_close = () => '' + md.renderer.rules.ordered_list_open = () => '' + md.renderer.rules.ordered_list_close = () => '' + md.renderer.rules.list_item_open = () => '' + md.renderer.rules.list_item_close = () => '' + + // 移除 hr + md.renderer.rules.hr = () => '' + + // 图片添加 class + md.renderer.rules.image = (tokens: any[], idx: number) => { + const src = tokens[idx].src || '' + const alt = tokens[idx].alt || '' + return `${alt}` + } + + let rendered = md.render(markdown) + + // Clean up: remove empty p tags and excessive newlines + rendered = rendered + .replace(/

    \s*<\/p>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() + + const content = rendered + + // 4. 保存草稿 + const formData = new URLSearchParams({ + text: content, + title: article.title, + cover_pic: '', + flags: 'false', + original_event: '', + status_id: '', + legal_user_visible: 'false', + is_private: 'false', + }) + + const response = await this.runtime.fetch( + 'https://mp.xueqiu.com/xq/statuses/draft/save.json', + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData, + } + ) + + const res = await response.json() as { + id?: string | number + error_description?: string + } + + logger.debug(' Save response:', res) + + if (!res.id) { + throw new Error(res.error_description || '保存失败') + } + + const postId = res.id + const draftUrl = `https://mp.xueqiu.com/write/draft/${postId}` + + return this.createResult(true, { + postId: String(postId), + postUrl: draftUrl, + draftOnly: options?.draftOnly ?? true, + }) + }).catch((error) => this.createResult(false, { + error: (error as Error).message, + })) + } + + /** + * 通过 URL 上传图片 + */ + protected async uploadImageByUrl(src: string): Promise { + // 1. 下载图片 + const imageResponse = await fetch(src) + if (!imageResponse.ok) { + throw new Error('图片下载失败: ' + src) + } + const imageBlob = await imageResponse.blob() + + // 2. 上传到雪球 + const formData = new FormData() + formData.append('file', imageBlob, 'image.jpg') + + const uploadResponse = await this.runtime.fetch( + 'https://mp.xueqiu.com/xq/photo/upload.json', + { + method: 'POST', + credentials: 'include', + body: formData, + } + ) + + const res = await uploadResponse.json() as { + url?: string + filename?: string + } + + logger.debug(' Image upload response:', res) + + if (!res.url || !res.filename) { + throw new Error('图片上传失败') + } + + // 雪球返回的 url 是 //开头,需要加 https: + const fullUrl = res.url.startsWith('//') ? `https:${res.url}/${res.filename}` : `${res.url}/${res.filename}` + + return { + url: fullUrl, + } + } +} diff --git a/sync/adapters/platforms/yidian.ts b/sync/adapters/platforms/yidian.ts new file mode 100644 index 0000000..bc578f1 --- /dev/null +++ b/sync/adapters/platforms/yidian.ts @@ -0,0 +1,176 @@ +/** + * 一点号适配器 + * + * 参考 wechatsync 驱动(YiDian)实现: + * - 登录检测:GET https://mp.yidianzixun.com 解析页面中 window.mpuser + * - 保存草稿:POST https://mp.yidianzixun.com/model/Article + * - 图片转存:GET /api/getImageFromUrl?src= + */ +import { CodeAdapter, type ImageUploadResult } from '../code-adapter' +import type { Article, AuthResult, SyncResult, PlatformMeta } from '../../types' +import type { PublishOptions } from '../types' +import { createLogger } from '../../lib/logger' +import { extractElementById, extractWindowObject } from '../../lib/js-object' + +const logger = createLogger('Yidian') + +export class YidianAdapter extends CodeAdapter { + readonly meta: PlatformMeta = { + id: 'yidian', + name: '一点号', + icon: 'https://www.yidianzixun.com/favicon.ico', + homepage: 'https://mp.yidianzixun.com', + capabilities: ['article', 'draft', 'image_upload'], + category: '通用', + syncMode: 'api', + } + + /** 一点号使用 HTML 内容 */ + readonly preprocessConfig = { + outputFormat: 'html' as const, + removeIframes: true, + removeComments: true, + removeEmptyElements: true, + } + + private mediaInfo: Record | null = null + + async checkAuth(): Promise { + try { + const response = await this.runtime.fetch('https://mp.yidianzixun.com/', { + credentials: 'include', + }) + const html = await response.text() + + // 页面中