Compare commits

...
3 Commits
Author SHA1 Message Date
hz4th_coder 7bb5a250c8 前端增加人工互联网搜索功能
- 新增互联网搜索面板,支持手动输入关键词搜索
- 搜索结果展示为卡片形式,显示标题和链接
- 支持抓取单个结果的详细内容
- 可一键保存搜索结果到内容库
- 新增搜索 API 端点:/api/articles/internet-search
- 搜索结果详情模态框展示
- 响应式设计,搜索结果自适应布局
2026-07-13 12:04:20 +08:00
hz4th_coder 4f02a6a0ea 实现浏览器方式互联网搜索功能
- 使用 agent-browser 浏览器自动化进行 Bing 搜索
- 解析 accessibility tree 提取搜索结果
- 自动获取每个结果的 URL
- 支持设置最大结果数量
2026-07-13 11:55:33 +08:00
hz4th_coder 187d5037b8 添加Git推送说明文档 2026-07-12 01:24:46 +08:00
6 changed files with 640 additions and 6 deletions
+88
View File
@@ -0,0 +1,88 @@
# Git推送说明
## 📌 当前状态
代码已准备推送,但Git服务器返回403错误:
```
remote: Push to create is not enabled for users.
```
这表示Git服务器不允许用户直接推送创建仓库。
---
## ✅ 解决方案
### 方案1: 管理员预先创建仓库
请在Git服务器上手动创建以下仓库:
**仓库信息:**
- **地址**: http://121.40.164.32:12007/hz4th_coder/param-auto-manager.git
- **账号**: hz4th_coder
- **组织**: hz4th_coder
**创建后推送代码:**
```bash
cd /home/openclaw/.openclaw/workspace-hz4th_coder/works/param-auto-manager
# 添加远程仓库(如果还没有)
git remote add origin http://hz4th_coder:262e7dbfce09c8cc21fbacff2b450cc3f1c3e265@121.40.164.32:12007/hz4th_coder/param-auto-manager.git
# 推送代码和标签
git push -u origin master
git push origin v1.0.0
git push origin v1.1.0
git push origin v1.2.0
```
---
### 方案2: 开通推送权限
联系Git服务器管理员,为 `hz4th_coder` 用户开通"Push to create"权限。
---
## 📊 当前Git状态
```bash
cd works/param-auto-manager
git log --oneline -5
```
输出:
```
47729a5 添加部署文档和说明
8ad0246 添加前端界面和操作页面
e3bc883 添加.gitignore文件,排除缓存和临时文件
b7b0925 初始化参数数据自动化管理系统
```
标签:
```
v1.0.0 - 初始化版本
v1.1.0 - 添加前端界面
v1.2.0 - 添加部署文档
```
---
## 🔐 Git认证信息
- **账号**: hz4th_coder
- **邮箱**: hz4th_coder@tphai.com
- **Token**: 262e7dbfce09c8cc21fbacff2b450cc3f1c3e265
---
## 📝 待推送文件统计
- 总文件数: 26个源文件
- 总代码行数: 约5000行
- 版本标签: 3个
---
**建议**: 请在Git服务器创建仓库后,执行推送命令即可完成部署。
+56
View File
@@ -133,3 +133,59 @@ def fetch_article():
}) })
else: else:
return jsonify({'error': '抓取失败'}), 500 return jsonify({'error': '抓取失败'}), 500
@bp.route('/internet-search', methods=['POST'])
def internet_search():
"""互联网搜索(浏览器方式)"""
data = request.get_json()
keyword = data.get('keyword', '')
max_results = data.get('max_results', 10)
save_to_library = data.get('save_to_library', False)
if not keyword:
return jsonify({'error': '请提供搜索关键词'}), 400
# 执行互联网搜索
results = search_service.search_internet(keyword, max_results)
return jsonify({
'success': True,
'keyword': keyword,
'results': results,
'count': len(results)
})
@bp.route('/internet-search-and-fetch', methods=['POST'])
def internet_search_and_fetch():
"""互联网搜索并抓取内容"""
data = request.get_json()
keyword = data.get('keyword', '')
max_results = data.get('max_results', 5)
category = data.get('category')
if not keyword:
return jsonify({'error': '请提供搜索关键词'}), 400
# 执行互联网搜索
results = search_service.search_internet(keyword, max_results)
# 抓取每个结果的详细内容
fetched_results = []
for r in results:
url = r.get('url')
if url:
content = search_service.fetch_url_content(url)
if content:
fetched_results.append({
'title': r['title'],
'url': url,
'source': r['source'],
'fetched_content': content
})
return jsonify({
'success': True,
'keyword': keyword,
'results': fetched_results,
'count': len(fetched_results)
})
+133 -5
View File
@@ -4,6 +4,10 @@
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import json import json
import subprocess
import os
import re
import urllib.parse
from datetime import datetime from datetime import datetime
from config import Config from config import Config
from models.database import db from models.database import db
@@ -13,19 +17,143 @@ class SearchService:
self.timeout = Config.SEARCH_TIMEOUT self.timeout = Config.SEARCH_TIMEOUT
self.max_results = Config.SEARCH_MAX_RESULTS self.max_results = Config.SEARCH_MAX_RESULTS
def _run_browser(self, *args, timeout=30000):
"""运行 agent-browser 命令"""
env = os.environ.copy()
env['XDG_RUNTIME_DIR'] = '/tmp/agent-browser-runtime'
os.makedirs(env['XDG_RUNTIME_DIR'], exist_ok=True)
cmd = ['agent-browser'] + list(args)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=timeout // 1000 + 5
)
return result.stdout, result.stderr, result.returncode
def search_internet(self, keyword, max_results=None): def search_internet(self, keyword, max_results=None):
""" """
从互联网搜索(使用搜索API或爬虫 从互联网搜索(使用 agent-browser 浏览器自动化
这里暂时使用简单的搜索模拟
""" """
max_results = max_results or self.max_results max_results = max_results or self.max_results
# TODO: 接入真实的搜索API(如Google Custom Search、Bing等)
# 这里先返回空列表,等待后续接入真实API
results = [] results = []
try:
# 1. 打开 Bing 搜索
encoded_keyword = urllib.parse.quote(keyword)
search_url = f"https://www.bing.com/search?q={encoded_keyword}"
stdout, stderr, code = self._run_browser('open', search_url, '--timeout', '20000')
if code != 0:
print(f"打开搜索页面失败: {stderr}")
return results return results
# 等待页面加载
stdout, stderr, code = self._run_browser('wait', '5000')
# 2. 获取搜索结果页面结构 (JSON 格式)
stdout, stderr, code = self._run_browser('snapshot', '--json', '--timeout', '30000')
if code != 0:
print(f"获取页面结构失败: {stderr}")
return results
# 3. 解析 JSON 提取搜索结果
try:
data = json.loads(stdout)
except json.JSONDecodeError:
print(f"解析 JSON 失败: {stdout[:500]}")
return results
# 4. 从 accessibility tree 中提取搜索结果
# Bing 搜索结果在 main[aria-label="搜索结果"] 区域内
results = self._parse_bing_results(data, max_results)
# 5. 关闭浏览器
self._run_browser('close')
except subprocess.TimeoutExpired:
print(f"搜索超时: {keyword}")
except Exception as e:
print(f"搜索出错: {str(e)}")
# 尝试关闭浏览器
try:
self._run_browser('close')
except:
pass
return results
def _parse_bing_results(self, snapshot_data, max_results=10):
"""
从 Bing 搜索结果的 snapshot 中解析出标题和链接
snapshot_data 是 agent-browser snapshot --json 的输出
结构: {success, data: {snapshot: "文本格式的 accessibility tree"}, error}
"""
results = []
# 获取 snapshot 文本
snapshot = snapshot_data.get('data', {}).get('snapshot', '')
if not snapshot:
return results
# 解析 accessibility tree 文本
in_results = False
refs = [] # 存储 (title, ref) 元组
lines = snapshot.split('\n')
for i, line in enumerate(lines):
line = line.strip()
# 进入搜索结果区域
if 'main "搜索结果"' in line:
in_results = True
continue
# 离开搜索结果区域
if in_results and line.startswith('- ') and 'main' in line and '搜索结果' not in line:
break
if not in_results:
continue
# 匹配标题链接:link "标题文字" [ref=eXX]
# 需要过滤域名链接(如 "zhihu.com")和短链接
if 'link "' in line and '[ref=' in line:
match = re.search(r'link "([^"]+)" \[ref=(e\d+)\]', line)
if match:
title = match.group(1)
ref = match.group(2)
# 过滤短标题(域名链接如 "zhihu.com"
if len(title) > 20 and '.' not in title[:10]: # 不是域名格式
refs.append((title, ref))
# 获取每个结果的 URL
for title, ref in refs[:max_results]:
url = self._get_link_url(ref)
if url and 'bing.com/search' not in url: # 过滤搜索结果页本身的链接
results.append({
'title': title,
'url': url,
'snippet': '',
'source': 'bing'
})
return results
def _get_link_url(self, ref):
"""通过 agent-browser 获取链接的 URL"""
try:
stdout, stderr, code = self._run_browser('get', 'attr', f'@{ref}', 'href', '--json', '--timeout', '5000')
if code == 0 and stdout:
data = json.loads(stdout)
return data.get('data', {}).get('value', '')
except Exception as e:
print(f"获取 URL 失败 (ref={ref}): {e}")
return None
def fetch_url_content(self, url): def fetch_url_content(self, url):
"""抓取网页内容""" """抓取网页内容"""
try: try:
+93
View File
@@ -643,3 +643,96 @@ body {
.priority-low { .priority-low {
color: #10b981; color: #10b981;
} }
/* 搜索结果卡片 */
.search-results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
}
.search-result-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
display: flex;
gap: 15px;
transition: all 0.2s;
}
.search-result-card:hover {
border-color: #667eea;
background: white;
}
.result-number {
width: 30px;
height: 30px;
background: #667eea;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
}
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
font-size: 15px;
color: #333;
cursor: pointer;
margin-bottom: 8px;
word-break: break-word;
}
.result-title:hover {
color: #667eea;
}
.result-url {
font-size: 12px;
color: #666;
margin-bottom: 6px;
overflow: hidden;
}
.result-url a {
color: #3b82f6;
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
}
.result-url a:hover {
text-decoration: underline;
}
.result-source {
font-size: 12px;
color: #999;
}
.result-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 搜索状态 */
#internet-search-status {
display: flex;
align-items: center;
gap: 8px;
}
#internet-search-status i {
font-size: 16px;
}
+229
View File
@@ -613,3 +613,232 @@ function getStatusText(status) {
}; };
return statusMap[status] || status; return statusMap[status] || status;
} }
// ========== 互联网搜索功能 ==========
let currentSearchResult = null;
// 执行互联网搜索
async function doInternetSearch() {
const keyword = document.getElementById('internet-search-keyword').value.trim();
const maxResults = parseInt(document.getElementById('internet-search-count').value) || 10;
if (!keyword) {
showToast('请输入搜索关键词', 'error');
return;
}
// 显示搜索状态
const statusDiv = document.getElementById('internet-search-status');
const resultsDiv = document.getElementById('internet-search-results');
const searchBtn = document.getElementById('search-btn');
statusDiv.innerHTML = '<i class="ri-loader-4-line"></i> 正在搜索...';
resultsDiv.innerHTML = '<div class="empty-text">搜索中...</div>';
searchBtn.disabled = true;
try {
const response = await fetch(`${API_BASE}/api/articles/internet-search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword, max_results: maxResults })
});
const data = await response.json();
if (data.success) {
statusDiv.innerHTML = `<i class="ri-check-line"></i> 搜索完成,找到 ${data.count} 条结果`;
if (data.results.length > 0) {
resultsDiv.innerHTML = data.results.map((r, i) => `
<div class="search-result-card">
<div class="result-number">${i + 1}</div>
<div class="result-content">
<div class="result-title" onclick="showSearchResultDetail(${i})">${escapeHtml(r.title)}</div>
<div class="result-url">
<a href="${escapeHtml(r.url)}" target="_blank">
<i class="ri-external-link-line"></i> ${escapeHtml(r.url.substring(0, 60))}${r.url.length > 60 ? '...' : ''}
</a>
</div>
<div class="result-source">来源: ${escapeHtml(r.source)}</div>
</div>
<div class="result-actions">
<button onclick="fetchAndShowResult(${i})" class="btn btn-sm btn-secondary">
<i class="ri-download-line"></i> 抓取内容
</button>
<button onclick="quickSaveResult(${i})" class="btn btn-sm btn-success">
<i class="ri-save-line"></i> 保存
</button>
</div>
</div>
`).join('');
// 存储搜索结果供后续使用
window.lastSearchResults = data.results;
} else {
resultsDiv.innerHTML = '<div class="empty-text">未找到相关结果</div>';
}
} else {
statusDiv.innerHTML = `<i class="ri-error-warning-line"></i> 搜索失败: ${escapeHtml(data.error)}`;
resultsDiv.innerHTML = '<div class="empty-text">搜索失败</div>';
}
} catch (error) {
statusDiv.innerHTML = '<i class="ri-error-warning-line"></i> 搜索出错';
resultsDiv.innerHTML = '<div class="empty-text">搜索出错,请稍后重试</div>';
console.error('搜索错误:', error);
}
searchBtn.disabled = false;
}
// 显示搜索结果详情
function showSearchResultDetail(index) {
if (!window.lastSearchResults || !window.lastSearchResults[index]) {
return;
}
currentSearchResult = window.lastSearchResults[index];
const body = document.getElementById('search-result-body');
body.innerHTML = `
<div class="detail-meta">
<div class="detail-meta-item">
<label>标题</label>
<span>${escapeHtml(currentSearchResult.title)}</span>
</div>
<div class="detail-meta-item">
<label>URL</label>
<a href="${escapeHtml(currentSearchResult.url)}" target="_blank">${escapeHtml(currentSearchResult.url)}</a>
</div>
<div class="detail-meta-item">
<label>来源</label>
<span>${escapeHtml(currentSearchResult.source)}</span>
</div>
</div>
<div class="detail-section">
<h4><i class="ri-information-line"></i> 提示</h4>
<p>点击"抓取内容"按钮可以获取页面详细内容,然后保存到内容库。</p>
</div>
`;
document.getElementById('search-result-modal').classList.add('active');
}
// 抓取并显示搜索结果内容
async function fetchAndShowResult(index) {
if (!window.lastSearchResults || !window.lastSearchResults[index]) {
return;
}
const result = window.lastSearchResults[index];
currentSearchResult = result;
showToast('正在抓取页面内容...', '');
try {
const response = await fetch(`${API_BASE}/api/articles/fetch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: result.url,
product_names: [result.title],
category: ''
})
});
const data = await response.json();
if (data.success) {
// 更新当前搜索结果,添加抓取的内容
currentSearchResult = {
...result,
fetched: true,
fetchedContent: data.data
};
const body = document.getElementById('search-result-body');
body.innerHTML = `
<div class="detail-meta">
<div class="detail-meta-item">
<label>标题</label>
<span>${escapeHtml(data.data.title)}</span>
</div>
<div class="detail-meta-item">
<label>URL</label>
<a href="${escapeHtml(result.url)}" target="_blank">${escapeHtml(result.url)}</a>
</div>
<div class="detail-meta-item">
<label>描述</label>
<span>${escapeHtml(data.data.description || '无')}</span>
</div>
</div>
<div class="detail-section">
<h4><i class="ri-file-text-line"></i> 页面内容</h4>
<pre style="white-space: pre-wrap; max-height: 400px; overflow-y: auto; background: #f8f9fa; padding: 15px; border-radius: 8px;">${escapeHtml(data.data.content.substring(0, 2000))}${data.data.content.length > 2000 ? '\n... (内容过长,已截断)' : ''}</pre>
</div>
`;
document.getElementById('search-result-modal').classList.add('active');
showToast('内容抓取成功', 'success');
} else {
showToast('抓取失败: ' + data.error, 'error');
}
} catch (error) {
showToast('抓取出错', 'error');
console.error('抓取错误:', error);
}
}
// 快速保存搜索结果到内容库
async function quickSaveResult(index) {
if (!window.lastSearchResults || !window.lastSearchResults[index]) {
return;
}
const result = window.lastSearchResults[index];
// 先抓取内容再保存
showToast('正在抓取并保存...', '');
try {
const response = await fetch(`${API_BASE}/api/articles/fetch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: result.url,
product_names: [result.title],
category: ''
})
});
const data = await response.json();
if (data.success) {
showToast('已保存到内容库', 'success');
loadArticles();
loadStats();
} else {
showToast('保存失败: ' + data.error, 'error');
}
} catch (error) {
showToast('保存出错', 'error');
}
}
// 从详情模态框保存到内容库
async function saveSearchResultToLibrary() {
if (!currentSearchResult) {
return;
}
// 如果还没有抓取内容,先抓取
if (!currentSearchResult.fetched) {
await fetchAndShowResult(window.lastSearchResults.findIndex(r => r.url === currentSearchResult.url));
return;
}
showToast('已保存到内容库', 'success');
closeModal('search-result-modal');
loadArticles();
loadStats();
}
+40
View File
@@ -116,6 +116,26 @@
</div> </div>
</div> </div>
<!-- 互联网搜索区域 -->
<div class="panel full-width">
<div class="panel-header">
<h2><i class="ri-search-line"></i> 互联网搜索</h2>
<div class="panel-actions">
<input type="text" id="internet-search-keyword" placeholder="输入关键词搜索..." class="search-input" style="width: 300px;">
<input type="number" id="internet-search-count" value="10" min="1" max="20" style="width: 80px;" title="结果数量">
<button onclick="doInternetSearch()" class="btn btn-primary" id="search-btn">
<i class="ri-search-line"></i> 搜索
</button>
</div>
</div>
<div class="panel-body">
<div id="internet-search-status" style="margin-bottom: 10px; color: #666; font-size: 14px;"></div>
<div class="search-results-grid" id="internet-search-results">
<div class="empty-text">输入关键词进行互联网搜索</div>
</div>
</div>
</div>
<!-- 处理历史 --> <!-- 处理历史 -->
<div class="panel full-width"> <div class="panel full-width">
<div class="panel-header"> <div class="panel-header">
@@ -267,6 +287,26 @@
</div> </div>
</div> </div>
<!-- 搜索结果详情模态框 -->
<div id="search-result-modal" class="modal">
<div class="modal-content large">
<div class="modal-header">
<h3><i class="ri-external-link-line"></i> 搜索结果详情</h3>
<button onclick="closeModal('search-result-modal')" class="close-btn">
<i class="ri-close-line"></i>
</button>
</div>
<div class="modal-body" id="search-result-body">
</div>
<div class="modal-footer">
<button onclick="closeModal('search-result-modal')" class="btn btn-secondary">关闭</button>
<button onclick="saveSearchResultToLibrary()" class="btn btn-success">
<i class="ri-save-line"></i> 保存到内容库
</button>
</div>
</div>
</div>
<!-- 文章详情模态框 --> <!-- 文章详情模态框 -->
<div id="article-detail-modal" class="modal"> <div id="article-detail-modal" class="modal">
<div class="modal-content large"> <div class="modal-content large">