Files
Ant-Browser/backend/internal/automation/default_scripts.go
T
ant-black 70132417ad publish: 1.2.0 snapshot (f3d7ec5)
channel: master

version: 1.2.0

source-ref: D:\code\open_source\ant-chrome master

source-commit: f3d7ec5

merged-public-base: 3d264eb
2026-05-05 18:08:10 +08:00

806 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package automation
const DualInstanceRuntimeScriptID = "dual-instance-runtime-switch"
func DefaultScripts() []ScriptRecord {
return []ScriptRecord{
{
ID: DualInstanceRuntimeScriptID,
Name: "双实例启动与 Runtime 切换",
Description: "通过 Launch API 分别启动两个实例,切换 Runtime 会话后交给 OpenClaw 执行。",
Type: "launch-api",
Status: "ready",
EntryFile: "index.cjs",
Tags: []string{"Launch API", "OpenClaw", "双实例"},
ParamsText: `{
"browsers": [
{
"code": "BUYER_001",
"skipDefaultStartUrls": true,
"startUrls": ["https://finance.sina.com.cn/"]
},
{
"code": "BUYER_002",
"skipDefaultStartUrls": true,
"startUrls": ["https://map.baidu.com/"]
}
],
"timeoutMs": 45000
}`,
ScriptText: `export async function run({ baseUrl, apiKey, params, log }) {
const normalizeCode = (value, fallback) =>
String(value || fallback || '').trim().toUpperCase()
const normalizeStringArray = (value) =>
Array.isArray(value)
? value
.map((item) => String(item || '').trim())
.filter(Boolean)
: []
const normalizeBrowserInput = (value, fallbackCode, fallbackStartUrls, defaultSkip) => {
const raw = value && typeof value === 'object' ? value : {}
const code = normalizeCode(raw.code || raw.launchCode, fallbackCode)
if (!code) {
return null
}
const startUrls = normalizeStringArray(raw.startUrls)
const fallbackUrls = normalizeStringArray(fallbackStartUrls)
const launchArgs = normalizeStringArray(raw.launchArgs)
return {
code,
skipDefaultStartUrls:
raw.skipDefaultStartUrls !== undefined
? raw.skipDefaultStartUrls !== false
: defaultSkip,
startUrls: startUrls.length > 0 ? startUrls : fallbackUrls,
launchArgs,
}
}
const timeoutMs = Number.isFinite(Number(params.timeoutMs))
? Math.max(1000, Math.round(Number(params.timeoutMs)))
: 45000
const defaultSkipDefaultStartUrls = params.skipDefaultStartUrls !== false
let browsers = Array.isArray(params.browsers)
? params.browsers
.map((item, index) =>
normalizeBrowserInput(
item,
['BUYER_001', 'BUYER_002'][index] || '',
['https://finance.sina.com.cn/', 'https://map.baidu.com/'][index] || [],
defaultSkipDefaultStartUrls,
),
)
.filter(Boolean)
: []
if (browsers.length === 0) {
browsers = [
normalizeBrowserInput(
{ code: params.primaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
'BUYER_001',
['https://finance.sina.com.cn/'],
defaultSkipDefaultStartUrls,
),
normalizeBrowserInput(
{ code: params.secondaryCode, skipDefaultStartUrls: params.skipDefaultStartUrls },
'BUYER_002',
['https://map.baidu.com/'],
defaultSkipDefaultStartUrls,
),
].filter(Boolean)
}
if (browsers.length === 0) {
throw new Error('params.browsers 不能为空')
}
const headers = {
'Content-Type': 'application/json',
...(apiKey ? { 'X-Ant-Api-Key': apiKey } : {}),
}
const post = async (path, payload) => {
const response = await fetch(baseUrl + path, {
method: 'POST',
headers,
body: JSON.stringify(payload),
})
const text = await response.text()
let body = text
try {
body = text ? JSON.parse(text) : null
} catch {
body = text
}
if (!response.ok) {
throw new Error(path + ' failed: ' + response.status + ' ' + text)
}
return body
}
const sessions = []
for (const browser of browsers) {
const sessionResult = await post('/api/runtime/session', {
selector: { code: browser.code, matchMode: 'unique' },
skipDefaultStartUrls: browser.skipDefaultStartUrls,
...(browser.startUrls.length > 0 ? { startUrls: browser.startUrls } : {}),
...(browser.launchArgs.length > 0 ? { launchArgs: browser.launchArgs } : {}),
timeoutMs,
})
sessions.push(sessionResult)
}
const browserCodes = browsers.map((item) => item.code)
log('browserCodes', browserCodes)
return {
ok: true,
summary: browserCodes.length + ' 个浏览器已就绪:' + browserCodes.join(' / '),
browserCodes,
sessions,
}
}`,
Notes: "先通过接口启动两个实例并切换 Runtime 会话;随后把实例信息交给 OpenClaw 执行自动化动作。",
Source: ScriptSource{
Type: "builtin",
URI: "repo://backend/internal/automation/default_scripts.go",
Ref: "HEAD",
Path: DualInstanceRuntimeScriptID,
},
},
{
ID: "news-query-txt",
Name: "查询新闻并写 TXT",
Description: "通过 Bing 搜索新闻关键词,提取结果并写入本地 txt 文件。",
Type: "playwright-cdp",
Status: "ready",
EntryFile: "index.cjs",
Tags: []string{"Playwright", "新闻", "TXT"},
ParamsText: `{
"keyword": "OpenAI",
"limit": 10,
"timeRange": "week",
"outputFileName": "openai-news.txt",
"timeoutMs": 30000,
"waitAfterLoadMs": 1500,
"captureScreenshot": false
}`,
ScriptText: `const fs = require('fs')
const DEFAULT_EXCLUDED_DOMAINS = [
'zhihu.com',
'baidu.com',
'qq.com',
'36kr.com',
'apifox.com',
'chatgpt-chinese.com',
'openwebui.cn',
'open-openai.com',
'xiniushu.com',
'reddit.com',
'quora.com',
'tieba.baidu.com',
'weibo.com',
'x.com',
'twitter.com',
'youtube.com',
'bilibili.com',
'douyin.com',
'xiaohongshu.com',
]
function normalizeInt(value, fallback, min, max) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return fallback
}
const rounded = Math.round(parsed)
if (rounded < min) {
return min
}
if (rounded > max) {
return max
}
return rounded
}
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeDomainList(value) {
if (!Array.isArray(value)) {
return []
}
const deduped = new Set()
for (const item of value) {
const normalized = normalizeText(item).replace(/^https?:\/\//, '').replace(/^www\./, '').toLowerCase()
if (normalized) {
deduped.add(normalized)
}
}
return Array.from(deduped)
}
function buildDefaultQuery(keyword) {
const normalizedKeyword = normalizeText(keyword) || 'OpenAI'
if (/[\u3400-\u9fff]/.test(normalizedKeyword)) {
return normalizedKeyword + ' 新闻'
}
return normalizedKeyword + ' news'
}
function buildFallbackQueries(keyword, baseQuery) {
const normalizedKeyword = normalizeText(keyword) || 'OpenAI'
const normalizedBaseQuery = normalizeText(baseQuery)
const candidates = [
normalizedBaseQuery,
]
if (/[\u3400-\u9fff]/.test(normalizedKeyword)) {
candidates.push(normalizedKeyword + ' 最新新闻')
} else {
candidates.push(normalizedKeyword + ' latest news')
}
const deduped = new Set()
for (const item of candidates) {
const normalized = normalizeText(item)
if (normalized) {
deduped.add(normalized)
}
}
return Array.from(deduped)
}
function buildSearchQuery(baseQuery, excludedDomains) {
const normalizedBaseQuery = normalizeText(baseQuery)
const normalizedDomains = normalizeDomainList(excludedDomains)
const parts = [normalizedBaseQuery]
for (const domain of normalizedDomains) {
parts.push('-site:' + domain)
}
return parts.filter(Boolean).join(' ')
}
function mapTimeRangeToBingFilter(value) {
switch (normalizeText(value).toLowerCase()) {
case 'day':
case '24h':
case 'today':
return 'ex1:"ez1"'
case 'week':
return 'ex1:"ez2"'
case 'month':
return 'ex1:"ez3"'
default:
return ''
}
}
function buildSearchURL(query, timeRange, firstResultIndex) {
const searchParams = new URLSearchParams({ q: query })
const filter = mapTimeRangeToBingFilter(timeRange)
if (filter) {
searchParams.set('filters', filter)
}
if (Number.isFinite(firstResultIndex) && firstResultIndex > 1) {
searchParams.set('first', String(firstResultIndex))
}
return 'https://www.bing.com/search?' + searchParams.toString()
}
function splitSnippet(snippet) {
const normalized = normalizeText(snippet)
if (!normalized) {
return { publishedAt: '', summary: '' }
}
const match = normalized.match(/^([^·]{0,40})\s*·\s*(.+)$/)
if (
match &&
/(前|分钟|小时|天前|周前|月前|昨天|\d{4}|\d{1,2}[/-]\d{1,2})/.test(match[1])
) {
return {
publishedAt: normalizeText(match[1]),
summary: normalizeText(match[2]),
}
}
return {
publishedAt: '',
summary: normalized,
}
}
function parseHostname(rawUrl) {
const normalized = normalizeText(rawUrl)
if (!normalized) {
return ''
}
try {
return new URL(normalized).hostname.replace(/^www\./, '').toLowerCase()
} catch {
return ''
}
}
function parsePathname(rawUrl) {
const normalized = normalizeText(rawUrl)
if (!normalized) {
return ''
}
try {
const pathname = new URL(normalized).pathname.replace(/\/+/g, '/').toLowerCase()
if (!pathname) {
return ''
}
return pathname === '/' ? pathname : pathname.replace(/\/$/, '')
} catch {
return ''
}
}
function looksLikeQuestionTitle(title) {
const normalized = normalizeText(title)
if (!normalized) {
return false
}
if (/[?]/.test(normalized)) {
return true
}
return /^(如何|为什么|怎么看|怎样|怎么|是否|有没有|谁能|请问|评价|如何评价|如何看待|为什么说)/.test(normalized)
}
function looksLikeAggregateText(text) {
const normalized = normalizeText(text).toLowerCase()
if (!normalized) {
return false
}
return /(roundup|digest|flash report|llm news today|ai news today|daily ai news|news today|model releases)/.test(normalized)
}
function looksLikeListingPath(pathname) {
const normalized = normalizeText(pathname).toLowerCase()
if (!normalized || normalized === '/') {
return false
}
if (/(^|\/)(tag|tags|topic|topics|category|categories|label|labels|brand|brands)(\/|$)/.test(normalized)) {
return true
}
if (/(^|\/)(news|latest|headlines|insights)$/.test(normalized)) {
return true
}
return /\/news\/(brand|brands|topic|topics|tag|tags)(\/|$)/.test(normalized)
}
function looksLikeListingText(text) {
const normalized = normalizeText(text).toLowerCase()
if (!normalized) {
return false
}
return /(latest news|breaking headlines|news and insights|news and analysis|everything you need to know|get the latest|最新资讯|最新动态|实时追踪|热点快讯|快讯)/.test(normalized)
}
function isBlockedHostname(hostname) {
const normalized = normalizeText(hostname).toLowerCase()
if (!normalized) {
return false
}
const blockedSuffixes = DEFAULT_EXCLUDED_DOMAINS
const blockedKeywords = [
'aitrack',
'aitoolly',
'aiflashreport',
'llm-stats',
'opentools',
]
if (blockedSuffixes.some(function (suffix) {
return normalized === suffix || normalized.endsWith('.' + suffix)
})) {
return true
}
return blockedKeywords.some(function (keyword) {
return normalized.includes(keyword)
})
}
function evaluateNewsItem(item) {
const hostname = parseHostname(item.url)
const pathname = parsePathname(item.url)
const summary = normalizeText(item.summary)
const source = normalizeText(item.source)
const reasons = []
if (!normalizeText(item.url)) {
reasons.push('missing-url')
}
if (!hostname) {
reasons.push('invalid-url')
}
if (hostname && isBlockedHostname(hostname)) {
reasons.push('blocked-host')
}
if (!source) {
reasons.push('missing-source')
}
if (summary.length < 20) {
reasons.push('summary-too-short')
}
if (looksLikeQuestionTitle(item.title)) {
reasons.push('question-title')
}
if (looksLikeAggregateText(item.title) || looksLikeAggregateText(summary)) {
reasons.push('aggregate-page')
}
if (looksLikeListingPath(pathname) || looksLikeListingText(item.title) || looksLikeListingText(summary)) {
reasons.push('listing-page')
}
return Object.assign({}, item, {
hostname: hostname,
pathname: pathname,
qualityAccepted: reasons.length === 0,
qualityReasons: reasons,
})
}
function formatRejectedReason(reason) {
switch (reason) {
case 'missing-url':
return '缺少链接'
case 'invalid-url':
return '链接无效'
case 'blocked-host':
return '来源站点已过滤'
case 'missing-source':
return '缺少来源'
case 'summary-too-short':
return '摘要过短'
case 'question-title':
return '标题更像问答'
case 'aggregate-page':
return '更像聚合页'
case 'listing-page':
return '更像列表页/专题页'
default:
return reason
}
}
function formatReport(items, metadata) {
const lines = [
'新闻抓取结果',
'查询词: ' + metadata.query,
'抓取时间: ' + metadata.generatedAt,
'搜索地址: ' + metadata.searchUrl,
'原始结果: ' + metadata.rawCount,
'通过校验: ' + items.length,
'过滤数量: ' + metadata.rejectedItems.length,
'',
]
for (const item of items) {
lines.push(item.rank + '. ' + item.title)
if (item.source) {
lines.push('来源: ' + item.source)
}
if (item.publishedAt) {
lines.push('时间: ' + item.publishedAt)
}
lines.push('链接: ' + item.url)
if (item.summary) {
lines.push('摘要: ' + item.summary)
}
lines.push('')
}
if (metadata.rejectedItems.length > 0) {
lines.push('被过滤结果(最多展示 5 条)')
lines.push('')
for (const item of metadata.rejectedItems.slice(0, 5)) {
lines.push(item.rank + '. ' + item.title)
if (item.hostname) {
lines.push('站点: ' + item.hostname)
}
lines.push('原因: ' + item.qualityReasons.map(formatRejectedReason).join(' / '))
lines.push('')
}
}
return lines.join('\n')
}
function pickBestAttempt(current, candidate) {
if (!current) {
return candidate
}
if (candidate.acceptedItems.length !== current.acceptedItems.length) {
return candidate.acceptedItems.length > current.acceptedItems.length ? candidate : current
}
if (candidate.distinctHostCount !== current.distinctHostCount) {
return candidate.distinctHostCount > current.distinctHostCount ? candidate : current
}
if (candidate.rawItems.length !== current.rawItems.length) {
return candidate.rawItems.length > current.rawItems.length ? candidate : current
}
return candidate
}
module.exports.run = async ({ launch, connect, selector, params, log, artifact }) => {
const timeout = normalizeInt(params.timeoutMs, 30000, 1000, 120000)
const waitAfterLoadMs = normalizeInt(params.waitAfterLoadMs, 1500, 0, 10000)
const limit = normalizeInt(params.limit, 10, 1, 50)
const maxPages = normalizeInt(params.maxPages, 3, 1, 5)
const baseQuery = normalizeText(params.query) || buildDefaultQuery(params.keyword)
const excludedDomains = normalizeDomainList(params.excludeDomains).length > 0
? normalizeDomainList(params.excludeDomains)
: DEFAULT_EXCLUDED_DOMAINS
const outputFileName = normalizeText(params.outputFileName) || 'news-results.txt'
const scanLimit = Math.max(10, Math.min(20, limit * 2))
const startUrls = Array.isArray(params.startUrls) && params.startUrls.length > 0
? params.startUrls
: undefined
const session = await launch({
selector,
startUrls,
skipDefaultStartUrls: true,
})
const connection = await connect(session)
const browser = connection.browser
const context = connection.context || browser.contexts()[0]
const page = await context.newPage()
const closeRunnerPage = async function () {
if (!page.isClosed()) {
await page.close().catch(function () {})
}
}
const searchCandidates = buildFallbackQueries(params.keyword, baseQuery)
const minAcceptedCount = Math.min(limit, Math.max(2, Math.ceil(limit * 0.2)))
const minDistinctHostCount = Math.min(3, minAcceptedCount)
let bestAttempt = null
try {
for (const candidateQuery of searchCandidates) {
const searchQuery = buildSearchQuery(candidateQuery, excludedDomains)
const normalizedItems = []
const seenUrls = new Set()
let scannedPageCount = 0
let firstSearchUrl = ''
for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
const firstResultIndex = pageIndex * 10 + 1
const searchUrl = buildSearchURL(searchQuery, params.timeRange, firstResultIndex)
try {
await page.goto(searchUrl, {
waitUntil: 'domcontentloaded',
timeout,
})
await page.waitForSelector('li.b_algo', { timeout })
} catch (error) {
if (pageIndex > 0 && normalizedItems.length > 0) {
break
}
throw error
}
if (waitAfterLoadMs > 0) {
await page.waitForTimeout(waitAfterLoadMs)
}
if (!firstSearchUrl) {
firstSearchUrl = page.url()
}
const pageItems = await page.$$eval('li.b_algo', function (nodes, maxItems) {
const clean = function (value) {
return String(value || '').replace(/\s+/g, ' ').trim()
}
return nodes
.slice(0, maxItems)
.map(function (node) {
const titleLink = node.querySelector('h2 a')
const title = clean(titleLink && titleLink.textContent)
const url = titleLink ? titleLink.href : ''
const sourceNode = node.querySelector('.tptt')
const source = clean(sourceNode && sourceNode.textContent)
const citeNode = node.querySelector('.b_attribution cite')
const cite = clean(citeNode && citeNode.textContent)
const snippetNode = node.querySelector('.b_caption p')
const snippet = clean(snippetNode && snippetNode.textContent)
if (!title) {
return null
}
return {
title,
url,
source: source || cite,
snippet,
}
})
.filter(Boolean)
}, scanLimit)
let appendedCount = 0
for (const item of pageItems) {
const dedupeKey = normalizeText(item.url)
if (!dedupeKey || seenUrls.has(dedupeKey)) {
continue
}
seenUrls.add(dedupeKey)
normalizedItems.push(
evaluateNewsItem(
Object.assign(
{
rank: normalizedItems.length + 1,
},
item,
splitSnippet(item.snippet)
)
)
)
appendedCount += 1
}
scannedPageCount += 1
if (appendedCount === 0 || pageItems.length < 8) {
break
}
}
const acceptedItems = normalizedItems.filter(function (item) {
return item.qualityAccepted
}).slice(0, limit)
const rejectedItems = normalizedItems.filter(function (item) {
return !item.qualityAccepted
})
const distinctHostCount = new Set(
acceptedItems
.map(function (item) {
return item.hostname
})
.filter(Boolean)
).size
log('searchQuery', searchQuery)
log('rawItemCount', normalizedItems.length)
log('acceptedItemCount', acceptedItems.length)
log('rejectedItemCount', rejectedItems.length)
log('distinctHostCount', distinctHostCount)
log('scannedPageCount', scannedPageCount)
bestAttempt = pickBestAttempt(bestAttempt, {
baseQuery: candidateQuery,
searchQuery: searchQuery,
searchUrl: firstSearchUrl || page.url(),
rawItems: normalizedItems,
acceptedItems: acceptedItems,
rejectedItems: rejectedItems,
distinctHostCount: distinctHostCount,
scannedPageCount: scannedPageCount,
})
if (acceptedItems.length >= minAcceptedCount && distinctHostCount >= minDistinctHostCount) {
break
}
}
} catch (error) {
await closeRunnerPage()
throw error
}
if (!bestAttempt || bestAttempt.rawItems.length === 0) {
await closeRunnerPage()
throw new Error('未抓到新闻搜索结果,当前页面: ' + page.url())
}
const normalizedItems = bestAttempt.rawItems
const acceptedItems = bestAttempt.acceptedItems
const rejectedItems = bestAttempt.rejectedItems
const distinctHostCount = bestAttempt.distinctHostCount
const searchUrl = bestAttempt.searchUrl
const scannedPageCount = bestAttempt.scannedPageCount || 1
const outputName = outputFileName.toLowerCase().endsWith('.txt')
? outputFileName
: outputFileName + '.txt'
const outputPath = artifact(outputName)
const reportText = formatReport(acceptedItems, {
query: bestAttempt.baseQuery,
generatedAt: new Date().toISOString(),
searchUrl: searchUrl,
rawCount: normalizedItems.length,
rejectedItems: rejectedItems,
})
fs.writeFileSync(outputPath, reportText, 'utf8')
let screenshotPath = ''
if (params.captureScreenshot === true) {
screenshotPath = artifact('news-search.png')
await page.screenshot({
path: screenshotPath,
fullPage: true,
})
}
log('outputPath', outputPath)
await closeRunnerPage()
if (acceptedItems.length < minAcceptedCount || distinctHostCount < minDistinctHostCount) {
return {
ok: false,
summary: '新闻结果质量不足,仅 ' + acceptedItems.length + '/' + normalizedItems.length + ' 条通过校验',
error: '搜索结果更像普通搜索、问答页或聚合页,未达到新闻抓取标准',
query: bestAttempt.baseQuery,
searchQuery: bestAttempt.searchQuery,
searchUrl: searchUrl,
outputPath,
screenshotPath,
rawItemCount: normalizedItems.length,
itemCount: acceptedItems.length,
rejectedCount: rejectedItems.length,
distinctHostCount: distinctHostCount,
scannedPageCount: scannedPageCount,
firstTitle: acceptedItems[0] ? acceptedItems[0].title : '',
}
}
return {
ok: true,
summary: '已筛出 ' + acceptedItems.length + ' 条有效新闻并写入 TXT',
query: bestAttempt.baseQuery,
searchQuery: bestAttempt.searchQuery,
searchUrl: searchUrl,
outputPath,
screenshotPath,
rawItemCount: normalizedItems.length,
itemCount: acceptedItems.length,
rejectedCount: rejectedItems.length,
distinctHostCount: distinctHostCount,
scannedPageCount: scannedPageCount,
firstTitle: acceptedItems[0] ? acceptedItems[0].title : '',
}
}`,
Notes: "脚本会优先使用 Bing 搜索真实新闻结果,并自动追加时间过滤、排除问答/聚合站点、回退查询词和质量校验;只有达到新闻质量门槛时才会判定成功,并把结果写入本地 txt。执行时可直接点“创建 Demo 并执行”,成功后在结果里的 outputPath 查看文件。",
Source: ScriptSource{
Type: "builtin",
URI: "repo://backend/internal/automation/default_scripts.go",
Ref: "HEAD",
Path: "news-query-txt",
},
},
}
}