Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcd13dec4f | ||
|
|
c3eef4fa21 | ||
|
|
9d6481bd06 | ||
|
|
e13e4074f1 | ||
|
|
20f3ec1f18 |
@@ -38,6 +38,11 @@ app.register_blueprint(system_bp)
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
# 搜索页面
|
||||
@app.route('/search')
|
||||
def search_page():
|
||||
return render_template('search.html')
|
||||
|
||||
# API首页
|
||||
@app.route('/api')
|
||||
def api_index():
|
||||
|
||||
+3
-2
@@ -140,17 +140,18 @@ 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)
|
||||
engine = data.get('engine', 'bing_cn') # 默认 Bing 中国
|
||||
|
||||
if not keyword:
|
||||
return jsonify({'error': '请提供搜索关键词'}), 400
|
||||
|
||||
# 执行互联网搜索
|
||||
results = search_service.search_internet(keyword, max_results)
|
||||
results = search_service.search_internet(keyword, max_results, engine)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'keyword': keyword,
|
||||
'engine': engine,
|
||||
'results': results,
|
||||
'count': len(results)
|
||||
})
|
||||
|
||||
+130
-28
@@ -33,18 +33,32 @@ class SearchService:
|
||||
)
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
|
||||
def search_internet(self, keyword, max_results=None):
|
||||
def search_internet(self, keyword, max_results=None, engine='bing_cn'):
|
||||
"""
|
||||
从互联网搜索(使用 agent-browser 浏览器自动化)
|
||||
|
||||
支持的搜索引擎:
|
||||
- bing_cn: Bing 中国(默认)
|
||||
- bing_global: Bing 国际版
|
||||
- google: Google
|
||||
- baidu: 百度
|
||||
"""
|
||||
max_results = max_results or self.max_results
|
||||
results = []
|
||||
|
||||
# 根据搜索引擎选择 URL
|
||||
encoded_keyword = urllib.parse.quote(keyword)
|
||||
search_urls = {
|
||||
'bing_cn': f"https://cn.bing.com/search?q={encoded_keyword}",
|
||||
'bing_global': f"https://www.bing.com/search?q={encoded_keyword}",
|
||||
'google': f"https://www.google.com/search?q={encoded_keyword}",
|
||||
'baidu': f"https://www.baidu.com/s?wd={encoded_keyword}"
|
||||
}
|
||||
|
||||
search_url = search_urls.get(engine, search_urls['bing_cn'])
|
||||
|
||||
try:
|
||||
# 1. 打开 Bing 搜索
|
||||
encoded_keyword = urllib.parse.quote(keyword)
|
||||
search_url = f"https://www.bing.com/search?q={encoded_keyword}"
|
||||
|
||||
# 1. 打开搜索引擎
|
||||
stdout, stderr, code = self._run_browser('open', search_url, '--timeout', '20000')
|
||||
if code != 0:
|
||||
print(f"打开搜索页面失败: {stderr}")
|
||||
@@ -66,9 +80,11 @@ class SearchService:
|
||||
print(f"解析 JSON 失败: {stdout[:500]}")
|
||||
return results
|
||||
|
||||
# 4. 从 accessibility tree 中提取搜索结果
|
||||
# Bing 搜索结果在 main[aria-label="搜索结果"] 区域内
|
||||
results = self._parse_bing_results(data, max_results)
|
||||
# 4. 根据搜索引擎选择解析方法
|
||||
if engine == 'baidu':
|
||||
results = self._parse_baidu_results(data, max_results)
|
||||
else:
|
||||
results = self._parse_bing_results(data, max_results)
|
||||
|
||||
# 5. 关闭浏览器
|
||||
self._run_browser('close')
|
||||
@@ -143,6 +159,44 @@ class SearchService:
|
||||
|
||||
return results
|
||||
|
||||
def _parse_baidu_results(self, snapshot_data, max_results=10):
|
||||
"""从百度搜索结果中解析标题和链接"""
|
||||
results = []
|
||||
|
||||
snapshot = snapshot_data.get('data', {}).get('snapshot', '')
|
||||
if not snapshot:
|
||||
return results
|
||||
|
||||
# 百度搜索结果解析
|
||||
refs = []
|
||||
lines = snapshot.split('\n')
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
# 百度结果通常在 link 标签中
|
||||
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)
|
||||
# 过滤百度内部链接和广告
|
||||
if len(title) > 10 and '百度' not in title[:6]:
|
||||
refs.append((title, ref))
|
||||
|
||||
# 获取每个结果的 URL
|
||||
for title, ref in refs[:max_results]:
|
||||
url = self._get_link_url(ref)
|
||||
if url and 'baidu.com' not in url:
|
||||
results.append({
|
||||
'title': title,
|
||||
'url': url,
|
||||
'snippet': '',
|
||||
'source': 'baidu'
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def _get_link_url(self, ref):
|
||||
"""通过 agent-browser 获取链接的 URL"""
|
||||
try:
|
||||
@@ -155,43 +209,91 @@ class SearchService:
|
||||
return None
|
||||
|
||||
def fetch_url_content(self, url):
|
||||
"""抓取网页内容"""
|
||||
"""抓取网页内容(使用 agent-browser 浏览器方式,绑过反爬虫)"""
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
response = requests.get(url, headers=headers, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
# 使用浏览器方式抓取
|
||||
stdout, stderr, code = self._run_browser('open', url, '--timeout', '20000')
|
||||
if code != 0:
|
||||
print(f"打开页面失败: {stderr}")
|
||||
return None
|
||||
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
# 等待页面加载
|
||||
self._run_browser('wait', '5000')
|
||||
|
||||
# 提取标题
|
||||
title = soup.find('title')
|
||||
title = title.text.strip() if title else ''
|
||||
# 获取页面标题
|
||||
stdout, stderr, code = self._run_browser('get', 'title', '--timeout', '5000')
|
||||
title = stdout.strip().replace('[agent-browser] ', '').strip() if code == 0 else ''
|
||||
|
||||
# 提取正文(简单提取,可优化)
|
||||
# 移除脚本和样式
|
||||
for script in soup(['script', 'style']):
|
||||
script.decompose()
|
||||
# 获取页面内容(通过 snapshot 获取 accessibility tree)
|
||||
stdout, stderr, code = self._run_browser('snapshot', '--json', '--timeout', '15000')
|
||||
text = ''
|
||||
if code == 0 and stdout:
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
snapshot = data.get('data', {}).get('snapshot', '')
|
||||
# 从 snapshot 中提取所有 StaticText
|
||||
text = self._extract_text_from_snapshot(snapshot)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 提取文本
|
||||
text = soup.get_text(separator='\n', strip=True)
|
||||
# 获取 URL(可能被重定向)
|
||||
stdout, stderr, code = self._run_browser('get', 'url', '--timeout', '5000')
|
||||
actual_url = stdout.strip() if code == 0 else url
|
||||
|
||||
# 提取元数据
|
||||
meta_desc = soup.find('meta', attrs={'name': 'description'})
|
||||
description = meta_desc['content'] if meta_desc else ''
|
||||
# 关闭浏览器
|
||||
self._run_browser('close')
|
||||
|
||||
# 提取描述(从页面内容的前200字符)
|
||||
description = text[:200].strip() if text else ''
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'content': text,
|
||||
'url': url,
|
||||
'url': actual_url,
|
||||
'fetch_date': datetime.now().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"抓取URL失败: {url}, 错误: {str(e)}")
|
||||
# 尝试关闭浏览器
|
||||
try:
|
||||
self._run_browser('close')
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _extract_text_from_snapshot(self, snapshot):
|
||||
"""从 accessibility tree snapshot 中提取文本内容"""
|
||||
texts = []
|
||||
|
||||
for line in snapshot.split('\n'):
|
||||
line = line.strip()
|
||||
if 'StaticText' in line and 'checkbox' not in line:
|
||||
# 找到 StaticText 后的内容
|
||||
idx = line.find('StaticText')
|
||||
after = line[idx + 10:].strip() # 跳过 'StaticText'
|
||||
|
||||
# 去掉开头的引号
|
||||
if after.startswith('"'):
|
||||
after = after[1:]
|
||||
|
||||
# 如果以 JSON 开头(错误信息),跳过
|
||||
if after.startswith('{'):
|
||||
continue
|
||||
|
||||
# 提取文本内容
|
||||
text = after.rstrip('"').strip()
|
||||
if text and len(text) > 1:
|
||||
texts.append(text)
|
||||
|
||||
result = '\n'.join(texts)
|
||||
|
||||
# 检测是否是反爬错误页面
|
||||
if '请求存在异常' in result or '暂时限制本次访问' in result:
|
||||
return '[该网站触发了反爬机制,无法抓取内容]'
|
||||
|
||||
return result
|
||||
|
||||
def search_articles(self, keyword, category=None):
|
||||
"""从内容库搜索"""
|
||||
return db.search_articles(keyword, category)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/* 搜索页面专用样式 */
|
||||
|
||||
.search-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.search-header {
|
||||
background: white;
|
||||
padding: 20px 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.search-header h1 {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-box {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-input-large {
|
||||
flex: 1;
|
||||
padding: 15px 20px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.search-input-large:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.count-input {
|
||||
width: 80px;
|
||||
padding: 15px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 15px 30px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.engine-select {
|
||||
padding: 15px;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.engine-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.search-options {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 搜索进度 */
|
||||
.search-progress {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
width: 0%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 结果区域 */
|
||||
.results-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.results-header {
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.results-header h2 {
|
||||
color: #667eea;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.results-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 搜索结果列表 */
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
border-color: #667eea;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.result-item.saved {
|
||||
border-color: #10b981;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.result-item.exists {
|
||||
border-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.result-item.fetched {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.result-number {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.result-item.saved .result-number {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.result-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-title:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.result-title .saved-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.result-url a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.result-url a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.result-content-preview {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 保存进度 */
|
||||
.save-progress {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
padding: 20px 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 100;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-info i {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.search-input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-options {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -735,4 +735,37 @@ body {
|
||||
|
||||
#internet-search-status i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 搜索入口链接 */
|
||||
.search-entrance-link {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 40px 80px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.search-entrance-link:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.search-entrance-link i {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.search-entrance-link span {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-entrance-link small {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// API基础地址
|
||||
const API_BASE = '';
|
||||
|
||||
// 搜索结果存储
|
||||
let searchResults = [];
|
||||
let currentResultIndex = -1;
|
||||
|
||||
// 页面加载初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 回车搜索
|
||||
document.getElementById('search-keyword').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
doSearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 执行搜索
|
||||
async function doSearch() {
|
||||
const keyword = document.getElementById('search-keyword').value.trim();
|
||||
const maxResults = parseInt(document.getElementById('search-count').value) || 10;
|
||||
const engine = document.getElementById('search-engine').value;
|
||||
const autoFetch = document.getElementById('auto-fetch').checked;
|
||||
const autoSave = document.getElementById('auto-save').checked;
|
||||
|
||||
if (!keyword) {
|
||||
showToast('请输入搜索关键词', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
const engineNames = {
|
||||
'bing_cn': 'Bing 中国',
|
||||
'bing_global': 'Bing 国际',
|
||||
'google': 'Google',
|
||||
'baidu': '百度'
|
||||
};
|
||||
updateStatus('searching', `正在通过 ${engineNames[engine]} 搜索...`);
|
||||
document.getElementById('search-btn').disabled = true;
|
||||
|
||||
// 显示进度
|
||||
const progress = document.getElementById('search-progress');
|
||||
progress.style.display = 'block';
|
||||
document.getElementById('progress-fill').style.width = '0%';
|
||||
document.getElementById('progress-text').textContent = '正在搜索...';
|
||||
|
||||
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, engine })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
searchResults = data.results.map(r => ({
|
||||
...r,
|
||||
fetched: false,
|
||||
saved: false,
|
||||
content: ''
|
||||
}));
|
||||
|
||||
displayResults();
|
||||
updateStatus('success', `找到 ${searchResults.length} 条结果`);
|
||||
document.getElementById('results-count').textContent = `${searchResults.length} 条结果`;
|
||||
document.getElementById('save-all-btn').disabled = searchResults.length === 0;
|
||||
|
||||
document.getElementById('progress-fill').style.width = '100%';
|
||||
document.getElementById('progress-text').textContent = '搜索完成!';
|
||||
|
||||
// 自动抓取和保存
|
||||
if (autoFetch && searchResults.length > 0) {
|
||||
setTimeout(() => fetchAllResults(autoSave), 500);
|
||||
}
|
||||
} else {
|
||||
updateStatus('error', '搜索失败');
|
||||
showToast('搜索失败: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus('error', '搜索出错');
|
||||
showToast('搜索出错', 'error');
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
progress.style.display = 'none';
|
||||
}, 1000);
|
||||
|
||||
document.getElementById('search-btn').disabled = false;
|
||||
}
|
||||
|
||||
// 显示搜索结果
|
||||
function displayResults() {
|
||||
const container = document.getElementById('search-results');
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
container.innerHTML = '<div class="empty-text">未找到相关结果</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = searchResults.map((r, i) => `
|
||||
<div class="result-item ${r.saved ? 'saved' : ''} ${r.fetched ? 'fetched' : ''}" id="result-${i}">
|
||||
<div class="result-number">${i + 1}</div>
|
||||
<div class="result-main">
|
||||
<div class="result-title" onclick="showResultDetail(${i})">
|
||||
${r.saved ? '<i class="ri-check-line saved-icon"></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, 70))}${r.url.length > 70 ? '...' : ''}
|
||||
</a>
|
||||
</div>
|
||||
${r.content ? `<div class="result-content-preview">${escapeHtml(r.content.substring(0, 100))}...</div>` : ''}
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button onclick="fetchResult(${i})" class="btn btn-sm btn-secondary" id="fetch-btn-${i}" ${r.fetched ? 'disabled' : ''}>
|
||||
<i class="ri-download-line"></i> ${r.fetched ? '已抓取' : '抓取'}
|
||||
</button>
|
||||
<button onclick="saveResult(${i})" class="btn btn-sm btn-success" id="save-btn-${i}" ${r.saved ? 'disabled' : ''}>
|
||||
<i class="ri-save-line"></i> ${r.saved ? '已保存' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 检查URL是否已在内容库
|
||||
async function checkUrlExists(url) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/articles/search?q=${encodeURIComponent(url)}`);
|
||||
const data = await response.json();
|
||||
if (data.success && data.articles && data.articles.length > 0) {
|
||||
// 检查是否有完全匹配的 URL
|
||||
return data.articles.some(a => a.url === url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查URL失败:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 抓取单个结果
|
||||
async function fetchResult(index) {
|
||||
const result = searchResults[index];
|
||||
if (!result || result.fetched) return;
|
||||
|
||||
const btn = document.getElementById(`fetch-btn-${index}`);
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="ri-loader-4-line"></i> 检查中...';
|
||||
|
||||
// 先检查内容库是否已存在
|
||||
const exists = await checkUrlExists(result.url);
|
||||
if (exists) {
|
||||
searchResults[index].fetched = true;
|
||||
searchResults[index].saved = true;
|
||||
searchResults[index].content = '[内容库中已存在此链接]';
|
||||
btn.innerHTML = '<i class="ri-check-line"></i> 已存在';
|
||||
btn.className = 'btn btn-sm btn-warning';
|
||||
|
||||
const saveBtn = document.getElementById(`save-btn-${index}`);
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i class="ri-check-line"></i> 已存在';
|
||||
|
||||
const item = document.getElementById(`result-${index}`);
|
||||
item.classList.add('saved');
|
||||
|
||||
showToast('该链接已在内容库中', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.innerHTML = '<i class="ri-loader-4-line"></i> 抓取中...';
|
||||
|
||||
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]
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
searchResults[index].fetched = true;
|
||||
searchResults[index].content = data.data.content;
|
||||
searchResults[index].title = data.data.title || result.title;
|
||||
|
||||
btn.innerHTML = '<i class="ri-check-line"></i> 已抓取';
|
||||
|
||||
// 更新结果显示
|
||||
const item = document.getElementById(`result-${index}`);
|
||||
item.classList.add('fetched');
|
||||
|
||||
showToast('抓取成功', 'success');
|
||||
displayResults();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="ri-download-line"></i> 抓取';
|
||||
showToast('抓取失败: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="ri-download-line"></i> 抓取';
|
||||
showToast('抓取出错', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 保存单个结果
|
||||
async function saveResult(index) {
|
||||
const result = searchResults[index];
|
||||
if (!result) return;
|
||||
|
||||
// 如果已经保存(可能是内容库中已存在),直接返回
|
||||
if (result.saved) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果未抓取,先抓取
|
||||
if (!result.fetched) {
|
||||
await fetchResult(index);
|
||||
// 抓取后如果已标记为 saved(内容库已存在),不继续保存
|
||||
if (searchResults[index].saved) return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById(`save-btn-${index}`);
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="ri-loader-4-line"></i> 保存中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/articles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
product_names: [searchResults[index].title],
|
||||
category: '',
|
||||
keywords: [],
|
||||
summary: searchResults[index].content.substring(0, 200),
|
||||
content: searchResults[index].content,
|
||||
source: searchResults[index].url,
|
||||
url: searchResults[index].url
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
searchResults[index].saved = true;
|
||||
btn.innerHTML = '<i class="ri-check-line"></i> 已保存';
|
||||
|
||||
const item = document.getElementById(`result-${index}`);
|
||||
item.classList.add('saved');
|
||||
|
||||
showToast('保存成功', 'success');
|
||||
displayResults();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="ri-save-line"></i> 保存';
|
||||
showToast('保存失败: ' + data.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="ri-save-line"></i> 保存';
|
||||
showToast('保存出错', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 一键保存全部
|
||||
async function saveAllResults() {
|
||||
const unsaved = searchResults.filter(r => !r.saved);
|
||||
if (unsaved.length === 0) {
|
||||
showToast('没有需要保存的结果', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = document.getElementById('save-progress');
|
||||
progress.style.display = 'block';
|
||||
|
||||
let saved = 0;
|
||||
const total = unsaved.length;
|
||||
|
||||
for (let i = 0; i < searchResults.length; i++) {
|
||||
if (searchResults[i].saved) continue;
|
||||
|
||||
document.getElementById('save-progress-text').textContent =
|
||||
`正在保存 ${saved + 1}/${total}...`;
|
||||
document.getElementById('save-fill').style.width =
|
||||
`${(saved / total) * 100}%`;
|
||||
|
||||
await saveResult(i);
|
||||
saved++;
|
||||
}
|
||||
|
||||
document.getElementById('save-fill').style.width = '100%';
|
||||
document.getElementById('save-progress-text').textContent = '保存完成!';
|
||||
|
||||
setTimeout(() => {
|
||||
progress.style.display = 'none';
|
||||
}, 1500);
|
||||
|
||||
showToast(`成功保存 ${saved} 条结果`, 'success');
|
||||
}
|
||||
|
||||
// 抓取所有结果
|
||||
async function fetchAllResults(autoSave) {
|
||||
const progress = document.getElementById('search-progress');
|
||||
progress.style.display = 'block';
|
||||
|
||||
const total = searchResults.length;
|
||||
let fetched = 0;
|
||||
|
||||
for (let i = 0; i < searchResults.length; i++) {
|
||||
if (searchResults[i].fetched) {
|
||||
fetched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
document.getElementById('progress-text').textContent =
|
||||
`正在抓取 ${fetched + 1}/${total}...`;
|
||||
document.getElementById('progress-fill').style.width =
|
||||
`${(fetched / total) * 100}%`;
|
||||
|
||||
await fetchResult(i);
|
||||
fetched++;
|
||||
}
|
||||
|
||||
document.getElementById('progress-fill').style.width = '100%';
|
||||
document.getElementById('progress-text').textContent = '抓取完成!';
|
||||
|
||||
setTimeout(() => {
|
||||
progress.style.display = 'none';
|
||||
}, 1000);
|
||||
|
||||
// 自动保存
|
||||
if (autoSave) {
|
||||
setTimeout(() => saveAllResults(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示结果详情
|
||||
function showResultDetail(index) {
|
||||
currentResultIndex = index;
|
||||
const result = searchResults[index];
|
||||
|
||||
const body = document.getElementById('result-detail-body');
|
||||
body.innerHTML = `
|
||||
<div class="detail-meta">
|
||||
<div class="detail-meta-item">
|
||||
<label>标题</label>
|
||||
<span>${escapeHtml(result.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>${result.fetched ? '已抓取' : '未抓取'} / ${result.saved ? '已保存' : '未保存'}</span>
|
||||
</div>
|
||||
</div>
|
||||
${result.content ? `
|
||||
<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(result.content.substring(0, 3000))}${result.content.length > 3000 ? '\n...(内容过长,已截断)' : ''}</pre>
|
||||
</div>
|
||||
` : '<div class="detail-section"><p>尚未抓取内容,点击"保存到内容库"将自动抓取并保存</p></div>'}
|
||||
`;
|
||||
|
||||
document.getElementById('result-detail-modal').classList.add('active');
|
||||
}
|
||||
|
||||
// 保存当前结果
|
||||
async function saveCurrentResult() {
|
||||
if (currentResultIndex >= 0) {
|
||||
await saveResult(currentResultIndex);
|
||||
closeModal('result-detail-modal');
|
||||
}
|
||||
}
|
||||
|
||||
// 清空结果
|
||||
function clearResults() {
|
||||
searchResults = [];
|
||||
displayResults();
|
||||
document.getElementById('results-count').textContent = '0 条结果';
|
||||
document.getElementById('save-all-btn').disabled = true;
|
||||
updateStatus('ready', '就绪');
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
function updateStatus(status, text) {
|
||||
const dot = document.getElementById('search-status');
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
dot.className = 'status-dot';
|
||||
if (status === 'searching') {
|
||||
dot.style.background = '#f59e0b';
|
||||
} else if (status === 'success') {
|
||||
dot.style.background = '#10b981';
|
||||
} else if (status === 'error') {
|
||||
dot.style.background = '#ef4444';
|
||||
} else {
|
||||
dot.style.background = '#10b981';
|
||||
}
|
||||
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('active');
|
||||
}
|
||||
|
||||
// 显示提示
|
||||
function showToast(message, type = '') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.className = `toast active ${type}`;
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('active');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// HTML转义
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
+7
-13
@@ -116,23 +116,17 @@
|
||||
</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 class="panel-body" style="text-align: center; padding: 40px;">
|
||||
<a href="/search" class="search-entrance-link">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>点击进入互联网搜索页面</span>
|
||||
<small>支持一键保存所有搜索结果到内容库</small>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>互联网搜索 - 参数数据自动化管理系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/search.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="search-container">
|
||||
<!-- 头部 -->
|
||||
<header class="search-header">
|
||||
<div class="header-left">
|
||||
<a href="/" class="back-link">
|
||||
<i class="ri-arrow-left-line"></i> 返回主页
|
||||
</a>
|
||||
<h1><i class="ri-search-line"></i> 互联网搜索</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="status-badge">
|
||||
<span id="search-status" class="status-dot"></span>
|
||||
<span id="status-text">就绪</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-box">
|
||||
<div class="search-input-group">
|
||||
<select id="search-engine" class="engine-select" title="选择搜索引擎">
|
||||
<option value="bing_cn" selected>Bing 中国</option>
|
||||
<option value="bing_global">Bing 国际</option>
|
||||
<option value="google">Google</option>
|
||||
<option value="baidu">百度</option>
|
||||
</select>
|
||||
<input type="text" id="search-keyword" placeholder="输入关键词搜索..." class="search-input-large">
|
||||
<input type="number" id="search-count" value="10" min="1" max="20" class="count-input" title="结果数量">
|
||||
<button onclick="doSearch()" class="btn btn-primary btn-large" id="search-btn">
|
||||
<i class="ri-search-line"></i> 搜索
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-options">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="auto-fetch" checked>
|
||||
<span>搜索后自动抓取内容</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="auto-save">
|
||||
<span>抓取后自动保存到内容库</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索状态 -->
|
||||
<div id="search-progress" class="search-progress" style="display: none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="progress-text">正在搜索...</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div class="results-section">
|
||||
<div class="results-header">
|
||||
<h2><i class="ri-list-check"></i> 搜索结果</h2>
|
||||
<div class="results-actions">
|
||||
<span id="results-count" class="results-count">0 条结果</span>
|
||||
<button onclick="saveAllResults()" class="btn btn-success" id="save-all-btn" disabled>
|
||||
<i class="ri-save-line"></i> 一键保存全部
|
||||
</button>
|
||||
<button onclick="clearResults()" class="btn btn-secondary" id="clear-btn">
|
||||
<i class="ri-delete-bin-line"></i> 清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="results-body">
|
||||
<div id="search-results" class="search-results">
|
||||
<div class="empty-text">输入关键词开始搜索</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存进度 -->
|
||||
<div id="save-progress" class="save-progress" style="display: none;">
|
||||
<div class="progress-info">
|
||||
<i class="ri-loader-4-line"></i>
|
||||
<span id="save-progress-text">正在保存...</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="save-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果详情模态框 -->
|
||||
<div id="result-detail-modal" class="modal">
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h3><i class="ri-file-text-line"></i> 内容详情</h3>
|
||||
<button onclick="closeModal('result-detail-modal')" class="close-btn">
|
||||
<i class="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="result-detail-body">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="closeModal('result-detail-modal')" class="btn btn-secondary">关闭</button>
|
||||
<button onclick="saveCurrentResult()" class="btn btn-success">
|
||||
<i class="ri-save-line"></i> 保存到内容库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示消息 -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="/static/js/search.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user