Compare commits

...

1 Commits

Author SHA1 Message Date
admin 6f55d2c809 feat(extension): 接入今日头条浏览器发布交接 2026-08-16 01:45:20 +08:00
6 changed files with 506 additions and 116 deletions
+2 -2
View File
@@ -12,9 +12,9 @@ AiToEarn 自维护的 Chrome/Edge 扩展,也是 Gitea Release 唯一允许打
## Browser Provider
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入掘金、简书知乎:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充 Markdown 编辑器。
AiToEarn 的 Browser Provider 使用当前 Chrome 登录态,不把平台 Cookie、Token 或 CSRF 信息上传到服务端。当前已接入掘金、简书知乎和今日头条:扩展读取平台稳定 UID,核对它与 AiToEarn 绑定账号一致后,在持久化发布任务中打开并填充编辑器。今日头条会将 Markdown 转为安全的富文本粘贴,保留标题、段落、列表、链接和图片
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id` 才记为已发布;知乎 `/write/:id` 记为草稿,无法确定时进入“发布结果待确认”,不会伪报成功。
任务会复用已有编辑器标签页。标签页仍位于已保存草稿或公开文章时,扩展只返回并激活该标签页;如果标签页已离开任务页面,则在同一标签页重新进入编辑器并填充,不重复创建窗口。掘金只有 `/post/:id`、简书只有 `/p/:id`、知乎只有 `zhuanlan.zhihu.com/p/:id`、今日头条只有 `www.toutiao.com/article/:id``www.toutiao.com/item/:id` 才记为已发布;知乎 `/write/:id` 和今日头条带 `draft_id` 的编辑器地址记为草稿,无法确定时进入“发布结果待确认”,不会伪报成功。
扩展默认只打开并填充编辑器,不替用户点击公开发布。微信公众号保存草稿及任何平台的最终发布仍需用户明确确认。
+6 -1
View File
@@ -11,7 +11,11 @@ import {
ZhihuPlatform,
syncZhihuContent,
} from './zhihu.js'
import { ToutiaoPlatform, syncToutiaoContent } from './toutiao.js'
import {
inspectToutiaoTask,
ToutiaoPlatform,
syncToutiaoContent,
} from './toutiao.js'
import { SegmentFaultPlatform } from './segmentfault.js'
import { CnblogsPlatform } from './cnblogs.js'
import { OSChinaPlatform } from './oschina.js'
@@ -131,6 +135,7 @@ const INSPECT_HANDLERS = {
juejin: tab => inspectJuejinTaskState(tab?.url || ''),
jianshu: inspectJianshuTask,
zhihu: inspectZhihuTask,
toutiao: inspectToutiaoTask,
}
// 导出
+405 -106
View File
@@ -1,4 +1,3 @@
// 今日头条平台配置
const ToutiaoPlatform = {
id: 'toutiao',
name: 'Toutiao',
@@ -9,134 +8,434 @@ const ToutiaoPlatform = {
type: 'toutiao',
}
import { injectUtils } from './common.js'
function parseToutiaoPublishedUrl(rawUrl) {
if (!rawUrl) return null
// 今日头条内容填充函数(在页面主世界中执行)
function fillToutiaoContentInPage(title, body) {
// 等待满足条件的元素出现
function waitForElement(predicate, timeout = 10000) {
try {
const url = new URL(rawUrl, 'https://mp.toutiao.com')
if (url.protocol !== 'https:' || !['toutiao.com', 'www.toutiao.com'].includes(url.hostname)) {
return null
}
const published = url.pathname.match(/^\/(?:article|item|w)\/([^/?#]+)\/?$/)
if (!published?.[1]) return null
return {
status: 'published',
url: url.href,
platformWorkId: published[1],
message: '已确认今日头条文章公开地址',
}
} catch {
return null
}
}
function getToutiaoDraftId(url) {
for (const key of ['draft_id', 'draftId']) {
const value = url.searchParams.get(key)
if (value?.trim()) return value.trim()
}
return undefined
}
function inspectToutiaoTaskState(rawUrl, publicUrl) {
const published = parseToutiaoPublishedUrl(publicUrl) || parseToutiaoPublishedUrl(rawUrl)
if (published) return published
let url
try {
url = new URL(rawUrl)
} catch {
return { status: 'failed', message: '无法读取今日头条标签页地址' }
}
if (url.protocol !== 'https:' || url.hostname !== 'mp.toutiao.com') {
return {
status: 'publication_uncertain',
message: '当前标签页已经离开今日头条,无法确认发布结果',
}
}
if (/^\/profile_v4\/graphic\/publish\/?$/.test(url.pathname)) {
const draftId = getToutiaoDraftId(url)
if (draftId) {
return {
status: 'draft_saved',
url: url.href,
platformWorkId: draftId,
message: '已确认今日头条草稿地址',
}
}
return {
status: 'filled',
url: url.href,
message: '内容已填充到今日头条编辑器,请检查后保存草稿或发布',
}
}
if (/^\/profile_v4\/graphic\/articles\/?$/.test(url.pathname)) {
return {
status: 'publication_uncertain',
url: url.href,
message: '已回到今日头条文章列表,请打开本次文章核对发布结果',
}
}
return {
status: 'publication_uncertain',
url: url.href,
message: '当前今日头条页面无法确认是草稿还是已发布文章',
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function safeUrl(value) {
try {
const url = new URL(value)
return ['http:', 'https:'].includes(url.protocol) ? url.href : null
} catch {
return null
}
}
function renderToutiaoInline(value) {
const pattern = /!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)|`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g
let html = ''
let cursor = 0
for (const match of value.matchAll(pattern)) {
html += escapeHtml(value.slice(cursor, match.index))
if (match[1]) {
const url = safeUrl(match[2])
html += url
? `<img src="${escapeHtml(url)}" alt="${escapeHtml(match[1])}" />`
: escapeHtml(match[0])
} else if (match[3]) {
const url = safeUrl(match[4])
html += url
? `<a href="${escapeHtml(url)}">${escapeHtml(match[3])}</a>`
: escapeHtml(match[0])
} else if (match[5]) {
html += `<code>${escapeHtml(match[5])}</code>`
} else if (match[6] || match[7]) {
html += `<strong>${escapeHtml(match[6] || match[7])}</strong>`
} else {
html += `<em>${escapeHtml(match[8] || match[9])}</em>`
}
cursor = match.index + match[0].length
}
return html + escapeHtml(value.slice(cursor))
}
function renderToutiaoMarkdown(markdown) {
const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n')
const blocks = []
let paragraph = []
let listType
let listItems = []
let codeLines
const flushParagraph = () => {
if (paragraph.length) {
blocks.push(`<p>${paragraph.map(renderToutiaoInline).join('<br />')}</p>`)
paragraph = []
}
}
const flushList = () => {
if (!listType) return
blocks.push(`<${listType}>${listItems.map(item => `<li>${renderToutiaoInline(item)}</li>`).join('')}</${listType}>`)
listType = undefined
listItems = []
}
const flushBlocks = () => {
flushParagraph()
flushList()
}
for (const line of lines) {
if (codeLines) {
if (/^\s*```/.test(line)) {
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
codeLines = undefined
} else {
codeLines.push(line)
}
continue
}
if (/^\s*```/.test(line)) {
flushBlocks()
codeLines = []
continue
}
if (!line.trim()) {
flushBlocks()
continue
}
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/)
if (heading) {
flushBlocks()
const level = heading[1].length
blocks.push(`<h${level}>${renderToutiaoInline(heading[2])}</h${level}>`)
continue
}
const unordered = line.match(/^\s*[-*+]\s+(.+)$/)
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/)
if (unordered || ordered) {
flushParagraph()
const nextType = unordered ? 'ul' : 'ol'
if (listType && listType !== nextType) flushList()
listType = nextType
listItems.push((unordered || ordered)[1])
continue
}
const quote = line.match(/^\s*>\s?(.*)$/)
if (quote) {
flushBlocks()
blocks.push(`<blockquote>${renderToutiaoInline(quote[1])}</blockquote>`)
continue
}
if (/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
flushBlocks()
blocks.push('<hr />')
continue
}
if (listType) flushList()
paragraph.push(line)
}
if (codeLines) blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
flushBlocks()
return blocks.join('')
}
function renderToutiaoPlainText(markdown) {
return String(markdown || '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[`*_#>~-]/g, '')
.replace(/\s+/g, '')
}
function fillToutiaoContent(title, markdown, body) {
const contentToFill = markdown || body || ''
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
const normalizeText = value => String(value || '').replace(/\s+/g, '')
function findFirst(selectors) {
return selectors.map(selector => document.querySelector(selector)).find(Boolean)
}
function waitForElement(selectors, timeout = 15000) {
return new Promise(resolve => {
const el = predicate()
if (el) return resolve(el)
const existing = findFirst(selectors)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const el = predicate()
if (el) {
observer.disconnect()
resolve(el)
}
})
observer.observe(document.body, { childList: true, subtree: true })
setTimeout(() => {
const element = findFirst(selectors)
if (!element) return
observer.disconnect()
resolve(predicate())
resolve(element)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
window.setTimeout(() => {
observer.disconnect()
resolve(findFirst(selectors))
}, timeout)
})
}
async function fillContent() {
// 填充标题 - 头条使用 textarea
const titleInput = await waitForElement(() =>
document.querySelector('textarea[placeholder*="标题"]')
)
if (titleInput && title) {
titleInput.focus()
// 模拟用户输入
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set
nativeSetter.call(titleInput, title)
titleInput.dispatchEvent(
new InputEvent('input', { bubbles: true, data: title, inputType: 'insertText' })
)
titleInput.dispatchEvent(new Event('change', { bubbles: true }))
titleInput.dispatchEvent(new Event('blur', { bubbles: true }))
console.log('[COSE] 头条标题填充成功:', title)
} else {
console.log('[COSE] 头条未找到标题输入框')
function setInputValue(input, value) {
const prototype = input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
input.focus()
if (setter) setter.call(input, value)
else input.value = value
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }))
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('blur', { bubbles: true }))
}
function selectEditorContents(editor) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
function dispatchHtmlPaste(editor, html, plainText) {
editor.focus()
selectEditorContents(editor)
if (typeof DataTransfer !== 'undefined' && typeof ClipboardEvent !== 'undefined') {
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', plainText)
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData,
}))
return true
}
return false
}
async function fill() {
const titleInput = await waitForElement([
'.editor-title textarea',
'textarea[placeholder*="标题"]',
'input[placeholder*="标题"]',
])
if (!titleInput) return { success: false, error: '未找到今日头条标题输入框' }
if (title) setInputValue(titleInput, title)
const editor = await waitForElement([
'.ProseMirror[contenteditable="true"]',
'.ProseMirror',
'[contenteditable="true"][role="textbox"]',
])
if (!editor) return { success: false, error: '未找到今日头条正文编辑器' }
if (!contentToFill) return { success: true, method: 'title-only' }
const html = renderToutiaoMarkdown(contentToFill)
const plainText = renderToutiaoPlainText(contentToFill)
dispatchHtmlPaste(editor, html, plainText)
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
editor.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(700)
const expectedTextLength = plainText.length
const minimumLength = Math.min(Math.max(12, Math.floor(expectedTextLength * 0.1)), 120)
let editorTextLength = normalizeText(editor.innerText || editor.textContent).length
let imageCount = editor.querySelectorAll('img').length
if (editorTextLength + imageCount * 12 < minimumLength && typeof document.execCommand === 'function') {
editor.focus()
selectEditorContents(editor)
document.execCommand('insertHTML', false, html)
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: plainText }))
await sleep(500)
editorTextLength = normalizeText(editor.innerText || editor.textContent).length
imageCount = editor.querySelectorAll('img').length
}
// 等待编辑器加载
await new Promise(resolve => setTimeout(resolve, 500))
if (editorTextLength + imageCount * 12 < minimumLength) {
return {
success: false,
error: '今日头条未确认接收正文,请在编辑器中手动粘贴后再重试',
}
}
// 头条使用 ProseMirror 富文本编辑器
const editor = await waitForElement(() => document.querySelector('.ProseMirror'))
if (editor && body) {
editor.focus()
// 对于 ProseMirror,我们需要更智能的方式来填充内容
// 清空现有内容
editor.innerHTML = ''
// 将内容分割成段落
const lines = body.split('\n').filter(line => line.trim() !== '')
// 使用 document.execCommand 插入内容(ProseMirror 兼容)
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
// 创建 HTML 内容
const htmlContent = lines.map(line => `<p>${line}</p>`).join('')
// 使用 insertHTML 命令
document.execCommand('insertHTML', false, htmlContent)
// 触发事件让 ProseMirror 同步
editor.dispatchEvent(new InputEvent('input', { bubbles: true }))
editor.dispatchEvent(new Event('change', { bubbles: true }))
console.log('[COSE] 头条内容填充成功')
return { success: true }
} else {
console.log('[COSE] 头条未找到编辑器')
return { success: false, error: '未找到编辑器' }
return {
success: true,
method: 'paste-html',
imageCount,
}
}
return fillContent()
return fill()
}
async function inspectToutiaoTask(tab, helpers) {
const currentState = inspectToutiaoTaskState(tab?.url || '')
if (!tab?.id || currentState.status === 'published') return currentState
try {
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const publicLink = Array.from(document.querySelectorAll('a[href]')).find(link => {
try {
const url = new URL(link.getAttribute('href'), location.href)
return ['toutiao.com', 'www.toutiao.com'].includes(url.hostname)
&& /^\/(?:article|item|w)\/[^/]+\/?$/.test(url.pathname)
&& (link.target === '_blank' || /查看文章|阅读文章|发布成功|已发布/.test(link.textContent || ''))
} catch {
return false
}
})
const visibleText = document.body?.innerText || ''
return {
publicUrl: publicLink
? new URL(publicLink.getAttribute('href'), location.href).href
: undefined,
draftSaved: /草稿已保存|已保存草稿|保存成功/.test(visibleText),
}
},
})
const observed = result?.[0]?.result
const state = inspectToutiaoTaskState(tab.url || '', observed?.publicUrl)
if (state.status === 'filled' && observed?.draftSaved) {
return {
...state,
status: 'draft_saved',
message: '已确认今日头条编辑器显示草稿保存成功',
}
}
return state
} catch {
return currentState
}
}
/**
* 今日头条同步处理器
* @param {object} tab - Chrome tab 对象
* @param {object} content - 内容对象 { title, body, markdown }
* @param {object} helpers - 帮助函数 { chrome, waitForTab, addTabToSyncGroup }
* @returns {Promise<{success: boolean, message?: string, tabId?: number}>}
*/
async function syncToutiaoContent(tab, content, helpers) {
const { chrome, waitForTab } = helpers
if (!tab?.id) return { success: false, message: '无法获取今日头条编辑器标签页' }
// 等待页面加载完成
await waitForTab(tab.id)
// 额外等待一下让编辑器完全加载
await new Promise(resolve => setTimeout(resolve, 2500))
// 先注入公共工具函数
await injectUtils(chrome, tab.id)
// 在页面中执行填充
const result = await chrome.scripting.executeScript({
await helpers.waitForTab(tab.id)
const result = await helpers.chrome.scripting.executeScript({
target: { tabId: tab.id },
func: fillToutiaoContentInPage,
args: [content.title, content.body || content.markdown || ''],
func: fillToutiaoContent,
args: [content.title, content.markdown, content.body],
world: 'MAIN',
})
const fillResult = result?.[0]?.result
if (fillResult?.success) {
return { success: true, message: '已打开头条号并填充内容', tabId: tab.id }
} else {
return { success: false, message: fillResult?.error || '内容填充失败', tabId: tab.id }
if (!fillResult?.success) {
return {
success: false,
message: fillResult?.error || '今日头条内容填充失败',
tabId: tab.id,
}
}
return {
success: true,
message: '已打开并填充今日头条编辑器,请检查后保存草稿或发布',
tabId: tab.id,
}
}
// 导出
export { ToutiaoPlatform, fillToutiaoContentInPage, syncToutiaoContent }
export {
ToutiaoPlatform,
fillToutiaoContent,
inspectToutiaoTask,
inspectToutiaoTaskState,
parseToutiaoPublishedUrl,
renderToutiaoMarkdown,
renderToutiaoPlainText,
syncToutiaoContent,
}
+23 -5
View File
@@ -34,14 +34,32 @@ export const ZhihuLoginConfig = {
}
// 头条号
export function parseToutiaoAccount(response) {
const data = response?.data && typeof response.data === 'object' ? response.data : response
const user = data?.user && typeof data.user === 'object' ? data.user : undefined
const platformUid = [
data?.user_id,
data?.uid,
data?.creator_id,
data?.account_id,
data?.id,
user?.user_id,
user?.uid,
user?.id,
].find(value => typeof value === 'string' || typeof value === 'number')
return {
platformUid: platformUid === undefined || platformUid === null ? undefined : String(platformUid),
username: data?.name || data?.nickname || user?.name || user?.nickname,
avatar: data?.avatar_url || data?.avatar || user?.avatar_url || user?.avatar,
}
}
export const ToutiaoLoginConfig = {
api: 'https://mp.toutiao.com/mp/agw/creator_center/user_info?app_id=1231',
method: 'GET',
checkLogin: response => response?.code === 0 && response?.name,
getUserInfo: response => ({
username: response?.name,
avatar: response?.avatar_url,
}),
checkLogin: response => response?.code === 0 && Boolean(parseToutiaoAccount(response).platformUid),
getUserInfo: parseToutiaoAccount,
}
// 百家号
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "AiToEarn - 内容营销助手",
"version": "1.6.0",
"version": "1.7.0",
"description": "AiToEarn 自维护内容扩展:网页采集、账号检测、多平台文章分发与受控人工互动",
"permissions": [
"activeTab",
+69 -1
View File
@@ -1,9 +1,16 @@
import assert from 'node:assert/strict'
import { ZhihuLoginConfig } from '../distribution/cose/detection/src/configs.js'
import {
ToutiaoLoginConfig,
ZhihuLoginConfig,
} from '../distribution/cose/detection/src/configs.js'
import { parseJianshuAccount } from '../distribution/cose/detection/src/platforms/jianshu.js'
import { inspectJianshuTaskState } from '../distribution/cose/core/platforms/jianshu.js'
import { inspectJuejinTaskState } from '../distribution/cose/core/platforms/juejin.js'
import { inspectZhihuTaskState } from '../distribution/cose/core/platforms/zhihu.js'
import {
inspectToutiaoTaskState,
renderToutiaoMarkdown,
} from '../distribution/cose/core/platforms/toutiao.js'
import { canReturnExistingPlatformTaskState } from '../distribution/cose/core/task-state.js'
assert.deepEqual(
@@ -155,4 +162,65 @@ assert.equal(
true,
)
assert.deepEqual(
ToutiaoLoginConfig.getUserInfo({
code: 0,
data: {
user_id: 'toutiao-user-1',
name: '头条作者',
avatar_url: 'https://p3-sign.toutiaoimg.com/avatar.jpg',
},
}),
{
platformUid: 'toutiao-user-1',
username: '头条作者',
avatar: 'https://p3-sign.toutiaoimg.com/avatar.jpg',
},
)
assert.equal(
ToutiaoLoginConfig.checkLogin({ code: 0, data: { name: '无稳定标识' } }),
false,
)
assert.deepEqual(
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish'),
{
status: 'filled',
url: 'https://mp.toutiao.com/profile_v4/graphic/publish',
message: '内容已填充到今日头条编辑器,请检查后保存草稿或发布',
},
)
assert.deepEqual(
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish?draft_id=123456'),
{
status: 'draft_saved',
url: 'https://mp.toutiao.com/profile_v4/graphic/publish?draft_id=123456',
platformWorkId: '123456',
message: '已确认今日头条草稿地址',
},
)
assert.deepEqual(
inspectToutiaoTaskState('https://www.toutiao.com/article/987654321/'),
{
status: 'published',
url: 'https://www.toutiao.com/article/987654321/',
platformWorkId: '987654321',
message: '已确认今日头条文章公开地址',
},
)
assert.equal(
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/articles').status,
'publication_uncertain',
)
assert.equal(
canReturnExistingPlatformTaskState(
inspectToutiaoTaskState('https://mp.toutiao.com/profile_v4/graphic/publish?draftId=789'),
),
true,
)
assert.match(
renderToutiaoMarkdown('# 标题\n\n- 第一项\n- 第二项\n\n![配图](https://example.com/image.png)\n\n**重点**'),
/<h1>标题<\/h1><ul><li>第一项<\/li><li>第二项<\/li><\/ul><p><img src="https:\/\/example\.com\/image\.png" alt="配图" \/><\/p><p><strong>重点<\/strong><\/p>/,
)
console.log('Distribution handoff tests passed')