feat: 切换到Firecrawl Browser Sandbox并更新API密钥 - 将抓取功能从Firecrawl Agent切换到Firecrawl Browser Sandbox - 更新.env文件中的FIRECRAWL_API_KEY为新密钥 - 修改前端界面文本,将"Firecrawl Agent"改为"Firecrawl Browser Sandbox" - 重构runScraper函数,添加按钮状态管理和滚动定位功能 - 移除zod验证schema,简化数据处理逻辑 - 更新定时任务调度器以使用新的浏览器抓取方式 - 清空results.json历史数据 ```
1229 lines
44 KiB
HTML
1229 lines
44 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>南京公共资源交易平台 - 中标价格采集工具</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
background: white;
|
||
border-radius: 16px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.header {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 30px;
|
||
text-align: center;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 28px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.header p {
|
||
opacity: 0.9;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
background: #f5f5f5;
|
||
border-bottom: 2px solid #e0e0e0;
|
||
}
|
||
|
||
.tab {
|
||
flex: 1;
|
||
padding: 15px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
background: #f5f5f5;
|
||
border: none;
|
||
font-size: 16px;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.tab:hover {
|
||
background: #e8e8e8;
|
||
}
|
||
|
||
.tab.active {
|
||
background: white;
|
||
color: #667eea;
|
||
font-weight: bold;
|
||
border-bottom: 3px solid #667eea;
|
||
}
|
||
|
||
.content {
|
||
padding: 30px;
|
||
}
|
||
|
||
.tab-content {
|
||
display: none;
|
||
}
|
||
|
||
.tab-content.active {
|
||
display: block;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
font-weight: 500;
|
||
color: #333;
|
||
}
|
||
|
||
.form-group input,
|
||
.form-group select {
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 2px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
transition: border 0.3s;
|
||
}
|
||
|
||
.form-group input:focus,
|
||
.form-group select:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.btn {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
padding: 12px 30px;
|
||
border-radius: 8px;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.loading {
|
||
display: none;
|
||
text-align: center;
|
||
padding: 20px;
|
||
color: #667eea;
|
||
}
|
||
|
||
.loading.active {
|
||
display: block;
|
||
}
|
||
|
||
.spinner {
|
||
border: 3px solid #f3f3f3;
|
||
border-top: 3px solid #667eea;
|
||
border-radius: 50%;
|
||
width: 40px;
|
||
height: 40px;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 10px;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.results {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.list-item {
|
||
background: #f9f9f9;
|
||
padding: 15px;
|
||
margin-bottom: 10px;
|
||
border-radius: 8px;
|
||
border-left: 4px solid #667eea;
|
||
}
|
||
|
||
.list-item h3 {
|
||
font-size: 16px;
|
||
margin-bottom: 8px;
|
||
color: #333;
|
||
}
|
||
|
||
.list-item .meta {
|
||
font-size: 14px;
|
||
color: #666;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.list-item .budget {
|
||
display: inline-block;
|
||
background: #667eea;
|
||
color: white;
|
||
padding: 4px 12px;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.list-item a {
|
||
color: #667eea;
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.list-item a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.summary {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.summary h2 {
|
||
margin-bottom: 15px;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.summary .stat {
|
||
display: inline-block;
|
||
margin-right: 30px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.summary .stat-label {
|
||
opacity: 0.9;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.summary .stat-value {
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
margin-top: 5px;
|
||
}
|
||
|
||
.export-btn {
|
||
background: #28a745;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.export-btn:hover {
|
||
box-shadow: 0 5px 15px rgba(40, 167, 69, 0.4);
|
||
}
|
||
|
||
.simple-list {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.error {
|
||
background: #f8d7da;
|
||
color: #721c24;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.checkbox-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 16px;
|
||
background: #f8f9ff;
|
||
border: 2px solid #e0e5ff;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
user-select: none;
|
||
}
|
||
|
||
.checkbox-wrapper:hover {
|
||
background: #eef1ff;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.checkbox-wrapper input[type="checkbox"] {
|
||
width: 20px;
|
||
height: 20px;
|
||
cursor: pointer;
|
||
accent-color: #667eea;
|
||
}
|
||
|
||
.checkbox-wrapper label {
|
||
margin: 0;
|
||
cursor: pointer;
|
||
font-size: 15px;
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.pagination {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 20px;
|
||
padding: 20px 0;
|
||
}
|
||
|
||
.pagination button {
|
||
background: white;
|
||
border: 2px solid #667eea;
|
||
color: #667eea;
|
||
padding: 8px 16px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
transition: all 0.3s;
|
||
min-width: 40px;
|
||
}
|
||
|
||
.pagination button:hover:not(:disabled) {
|
||
background: #667eea;
|
||
color: white;
|
||
}
|
||
|
||
.pagination button:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.pagination .page-info {
|
||
color: #666;
|
||
font-size: 14px;
|
||
margin: 0 10px;
|
||
}
|
||
|
||
/* ===== 抓取来源配置页样式 ===== */
|
||
.scrapers-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.scrapers-toolbar h2 {
|
||
margin: 0;
|
||
color: #667eea;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.btn-add {
|
||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||
color: white;
|
||
border: none;
|
||
padding: 10px 22px;
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
transition: all 0.2s;
|
||
box-shadow: 0 2px 8px rgba(17, 153, 142, 0.3);
|
||
}
|
||
|
||
.btn-add:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 14px rgba(17, 153, 142, 0.4);
|
||
}
|
||
|
||
.scrapers-table-wrap {
|
||
overflow-x: auto;
|
||
border-radius: 12px;
|
||
border: 1px solid #e8eaf0;
|
||
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.06);
|
||
}
|
||
|
||
.scrapers-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 14px;
|
||
min-width: 800px;
|
||
}
|
||
|
||
.scrapers-table thead tr {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
}
|
||
|
||
.scrapers-table th {
|
||
padding: 14px 14px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.scrapers-table tbody tr {
|
||
border-bottom: 1px solid #f0f0f5;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.scrapers-table tbody tr:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.scrapers-table tbody tr:hover {
|
||
background: #f5f7ff;
|
||
}
|
||
|
||
.scrapers-table td {
|
||
padding: 12px 14px;
|
||
vertical-align: top;
|
||
color: #333;
|
||
}
|
||
|
||
.scrapers-table td.prompt-cell {
|
||
max-width: 220px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: #666;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.tag {
|
||
display: inline-block;
|
||
padding: 2px 10px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tag-type {
|
||
background: #e8f4fd;
|
||
color: #1a73c8;
|
||
}
|
||
|
||
.tag-enabled {
|
||
background: #e4f9ee;
|
||
color: #1a8a4a;
|
||
}
|
||
|
||
.tag-disabled {
|
||
background: #feeaea;
|
||
color: #c0392b;
|
||
}
|
||
|
||
.url-cell a {
|
||
color: #667eea;
|
||
text-decoration: none;
|
||
font-size: 12px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.url-cell a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.action-btns {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 5px 12px;
|
||
border-radius: 6px;
|
||
border: none;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn-edit {
|
||
background: #fff3cd;
|
||
color: #856404;
|
||
}
|
||
|
||
.btn-edit:hover {
|
||
background: #ffc107;
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-delete {
|
||
background: #fdeaea;
|
||
color: #c0392b;
|
||
}
|
||
|
||
.btn-delete:hover {
|
||
background: #e74c3c;
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-run {
|
||
background: #e8f4fd;
|
||
color: #1a73c8;
|
||
}
|
||
|
||
.btn-run:hover {
|
||
background: #667eea;
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-toggle-on {
|
||
background: #e4f9ee;
|
||
color: #1a8a4a;
|
||
}
|
||
|
||
.btn-toggle-on:hover {
|
||
background: #27ae60;
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-toggle-off {
|
||
background: #feeaea;
|
||
color: #c0392b;
|
||
}
|
||
|
||
.btn-toggle-off:hover {
|
||
background: #e74c3c;
|
||
color: #fff;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: #aaa;
|
||
}
|
||
|
||
.empty-state svg {
|
||
margin-bottom: 12px;
|
||
opacity: 0.4;
|
||
}
|
||
|
||
/* 弹窗 */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
z-index: 1000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal-overlay.show {
|
||
display: flex;
|
||
}
|
||
|
||
.modal-box {
|
||
background: white;
|
||
border-radius: 16px;
|
||
padding: 32px;
|
||
width: 600px;
|
||
max-width: 95vw;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||
animation: modalIn 0.2s ease;
|
||
}
|
||
|
||
@keyframes modalIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0.95) translateY(-10px);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: scale(1) translateY(0);
|
||
}
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.modal-header h3 {
|
||
margin: 0;
|
||
color: #333;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.modal-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
color: #999;
|
||
line-height: 1;
|
||
padding: 0;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
color: #333;
|
||
}
|
||
|
||
.modal-form .form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
}
|
||
|
||
.modal-form .form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.modal-form .form-group label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #555;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.modal-form .form-group input,
|
||
.modal-form .form-group select,
|
||
.modal-form .form-group textarea {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1.5px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
transition: border 0.2s;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.modal-form .form-group input:focus,
|
||
.modal-form .form-group select:focus,
|
||
.modal-form .form-group textarea:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.12);
|
||
}
|
||
|
||
.modal-form .form-group textarea {
|
||
resize: vertical;
|
||
min-height: 90px;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.btn-cancel {
|
||
background: #f0f0f0;
|
||
color: #555;
|
||
border: none;
|
||
padding: 10px 24px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.btn-cancel:hover {
|
||
background: #e0e0e0;
|
||
}
|
||
|
||
.btn-save {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
padding: 10px 28px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.btn-save:hover {
|
||
box-shadow: 0 4px 14px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.run-result {
|
||
margin-top: 16px;
|
||
padding: 14px;
|
||
background: #f7f8ff;
|
||
border-radius: 8px;
|
||
border: 1px solid #e0e5ff;
|
||
font-size: 13px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
color: #333;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>南京公共资源交易平台</h1>
|
||
<p>交通水务中标结果公示 - 中标价格采集工具</p>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<button class="tab active" onclick="switchTab('scheduler')">定时任务</button>
|
||
<button class="tab" onclick="switchTab('email')">邮件配置</button>
|
||
<button class="tab" onclick="switchTab('scrapers')">抓取来源</button>
|
||
<a href="/results.html" target="_blank" class="tab" style="text-decoration:none;color:inherit;">📊 抓取结果</a>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<!-- 定时任务 -->
|
||
<div id="scheduler" class="tab-content active">
|
||
<h2 style="margin-bottom: 20px; color: #667eea;">定时任务配置</h2>
|
||
<p style="color: #666; margin-bottom: 20px;">配置定时任务自动采集大于指定金额的项目并发送邮件报告</p>
|
||
|
||
<!-- 任务状态 -->
|
||
<div id="schedulerStatus"
|
||
style="margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8px;">
|
||
<h3 style="margin-top: 0; margin-bottom: 15px;">任务状态</h3>
|
||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
||
<div>
|
||
<div style="opacity: 0.9; font-size: 14px;">运行状态</div>
|
||
<div style="font-size: 20px; font-weight: bold; margin-top: 5px;"
|
||
id="schedulerRunningStatus">加载中...</div>
|
||
</div>
|
||
<div>
|
||
<div style="opacity: 0.9; font-size: 14px;">执行时间</div>
|
||
<div style="font-size: 20px; font-weight: bold; margin-top: 5px;" id="schedulerCronTime">-
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div style="opacity: 0.9; font-size: 14px;">已启用来源</div>
|
||
<div style="font-size: 20px; font-weight: bold; margin-top: 5px;"
|
||
id="schedulerEnabledCount">-</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配置表单 -->
|
||
<div class="form-group">
|
||
<div class="checkbox-wrapper" onclick="document.getElementById('schedulerEnabled').click();">
|
||
<input type="checkbox" id="schedulerEnabled" onclick="event.stopPropagation();">
|
||
<label for="schedulerEnabled">启用定时任务</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>执行计划</label>
|
||
<select id="schedulerCronPreset" onchange="handleCronPresetChange()">
|
||
<option value="0 9 * * *">每天上午9点</option>
|
||
<option value="0 6 * * *">每天上午6点</option>
|
||
<option value="0 12 * * *">每天中午12点</option>
|
||
<option value="0 18 * * *">每天下午18点</option>
|
||
<option value="0 9,18 * * *">每天9点和18点</option>
|
||
<option value="0 */6 * * *">每6小时</option>
|
||
<option value="0 */12 * * *">每12小时</option>
|
||
<option value="0 9 * * 1">每周一上午9点</option>
|
||
<option value="0 9 1 * *">每月1日上午9点</option>
|
||
<option value="custom">自定义时间...</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 自定义时间配置 -->
|
||
<div class="form-group" id="customCronGroup" style="display: none;">
|
||
<label>自定义执行时间</label>
|
||
<div style="display: flex; gap: 10px; align-items: center;">
|
||
<div style="flex: 1;">
|
||
<label style="font-size: 12px; color: #666;">小时 (0-23)</label>
|
||
<input type="number" id="customHour" min="0" max="23" value="9">
|
||
</div>
|
||
<div style="flex: 1;">
|
||
<label style="font-size: 12px; color: #666;">分钟 (0-59)</label>
|
||
<input type="number" id="customMinute" min="0" max="59" value="0">
|
||
</div>
|
||
</div>
|
||
<small style="color: #666; display: block; margin-top: 5px;">
|
||
将在每天指定的时间执行
|
||
</small>
|
||
</div>
|
||
|
||
<!-- 隐藏的Cron表达式字段 -->
|
||
<input type="hidden" id="schedulerCronInput" value="0 9 * * *">
|
||
|
||
|
||
<div class="form-group">
|
||
<label>金额阈值(万元)- 邮件报告中只显示大于此金额的条目,0 表示不筛选</label>
|
||
<input type="number" id="schedulerThresholdInput" value="0" min="0" step="1000">
|
||
<small style="color: #666; display: block; margin-top: 5px;">
|
||
10亿 = 100000万元 | 1亿 = 10000万元 | 0 = 不筛选,全部显示
|
||
</small>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>任务描述 (可选)</label>
|
||
<input type="text" id="schedulerDescription" placeholder="例如: 每天9点自动抓取所有启用来源">
|
||
</div>
|
||
|
||
<button class="btn" onclick="saveSchedulerConfig()">保存配置</button>
|
||
<button class="btn" onclick="testSchedulerNow()"
|
||
style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">立即测试运行</button>
|
||
<button class="btn" onclick="loadSchedulerConfig()" style="background: #6c757d;">刷新状态</button>
|
||
|
||
<div id="schedulerConfigStatus" style="margin-top: 20px;"></div>
|
||
|
||
<div
|
||
style="margin-top: 30px; padding: 20px; background: #fff3cd; border-radius: 8px; border-left: 4px solid #ffc107;">
|
||
<h3 style="margin-top: 0; color: #856404;">使用说明</h3>
|
||
<ul style="line-height: 1.8; color: #856404;">
|
||
<li><strong>数据来源:</strong> 运行「抓取来源」页中所有已启用的抓取配置</li>
|
||
<li><strong>自动抓取:</strong> 按计划时间自动逐个运行所有启用的抓取来源,结果保存到「抓取结果」页</li>
|
||
<li><strong>邮件通知:</strong> 抓取完成后自动将结果发送到配置的邮箱(需先完成邮件配置)</li>
|
||
<li><strong>提示:</strong> 请前往「抓取来源」页配置并启用需要定时抓取的来源</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 邮件配置 -->
|
||
<div id="email" class="tab-content">
|
||
<h2 style="margin-bottom: 20px; color: #667eea;">邮件配置</h2>
|
||
<p style="color: #666; margin-bottom: 20px;">配置SMTP邮件服务器信息,用于发送报告到指定邮箱</p>
|
||
|
||
<div class="form-group">
|
||
<label>SMTP服务器地址 *</label>
|
||
<input type="text" id="smtpHost" placeholder="例如: smtp.qq.com, smtp.163.com, smtp.gmail.com">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>SMTP端口 *</label>
|
||
<input type="number" id="smtpPort" value="587" placeholder="通常为 587 (TLS) 或 465 (SSL)">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>发件人邮箱 (SMTP用户名) *</label>
|
||
<input type="email" id="smtpUser" placeholder="your-email@example.com">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>SMTP密码/授权码 *</label>
|
||
<input type="password" id="smtpPass" placeholder="邮箱密码或授权码">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>收件人邮箱 (多个用逗号分隔) *</label>
|
||
<input type="text" id="recipients" placeholder="email1@example.com, email2@example.com">
|
||
</div>
|
||
|
||
<button class="btn" onclick="saveEmailConfig()">保存配置</button>
|
||
<button class="btn" onclick="testEmailConfig()"
|
||
style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">测试连接</button>
|
||
|
||
<div id="emailConfigStatus" style="margin-top: 20px;"></div>
|
||
|
||
<div
|
||
style="margin-top: 30px; padding: 20px; background: #f0f8ff; border-radius: 8px; border-left: 4px solid #667eea;">
|
||
<h3 style="margin-top: 0; color: #667eea;">常用邮箱配置参考</h3>
|
||
<ul style="line-height: 1.8; color: #666;">
|
||
<li><strong>QQ邮箱:</strong> smtp.qq.com, 端口 587 或 465, 需要使用授权码</li>
|
||
<li><strong>163邮箱:</strong> smtp.163.com, 端口 465 或 25, 需要使用授权码</li>
|
||
<li><strong>Gmail:</strong> smtp.gmail.com, 端口 587 或 465</li>
|
||
<li><strong>Outlook:</strong> smtp-mail.outlook.com, 端口 587</li>
|
||
</ul>
|
||
<p style="margin: 10px 0 0 0; color: #999; font-size: 13px;">
|
||
提示: QQ和163邮箱需要在邮箱设置中开启SMTP服务并生成授权码,授权码不是邮箱密码。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 抓取来源配置 -->
|
||
<div id="scrapers" class="tab-content" style="padding:30px ;">
|
||
<div class="scrapers-toolbar">
|
||
<h2>抓取来源配置</h2>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<button class="btn-add" onclick="runAllScrapers()" id="btnRunAll"
|
||
style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"
|
||
viewBox="0 0 24 24">
|
||
<polygon points="5,3 19,12 5,21" />
|
||
</svg>
|
||
运行全部启用
|
||
</button>
|
||
<a href="/results.html" target="_blank" class="btn-add"
|
||
style="background:linear-gradient(135deg,#11998e 0%,#38ef7d 100%);text-decoration:none;">
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"
|
||
viewBox="0 0 24 24">
|
||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||
<path d="M3 9h18M9 21V9" />
|
||
</svg>
|
||
查看结果
|
||
</a>
|
||
<button class="btn-add" onclick="openScraperModal()" style="cursor:pointer;">
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"
|
||
viewBox="0 0 24 24">
|
||
<line x1="12" y1="5" x2="12" y2="19" />
|
||
<line x1="5" y1="12" x2="19" y2="12" />
|
||
</svg>
|
||
新增来源
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p style="color:#888;font-size:13px;margin:-8px 0 18px;">通过配置 URL 和提示词,使用 Firecrawl Browser Sandbox
|
||
抓取任意网页数据。结果会自动保存,可在「抓取结果」页查看历史。</p>
|
||
|
||
<div class="scrapers-table-wrap">
|
||
<table class="scrapers-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:80px">城市</th>
|
||
<th style="width:80px">板块</th>
|
||
<th style="width:70px">子板块</th>
|
||
<th style="width:80px">类型</th>
|
||
<th>链接地址</th>
|
||
<th>提示词</th>
|
||
<th style="width:70px">AI模型</th>
|
||
<th style="width:60px">状态</th>
|
||
<th style="width:180px">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="scrapersTbody">
|
||
<tr id="scrapers-empty-row">
|
||
<td colspan="9" class="empty-state">
|
||
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5"
|
||
viewBox="0 0 24 24">
|
||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||
<path d="M3 9h18M9 21V9" />
|
||
</svg>
|
||
<div>暂无配置,点击「新增来源」添加抓取任务</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 批量运行状态 -->
|
||
<div id="batchRunStatus"
|
||
style="display:none;margin-top:16px;padding:14px;background:#f7f8ff;border-radius:8px;border:1px solid #e0e5ff;font-size:13px;color:#333;">
|
||
</div>
|
||
|
||
<!-- 测试结果展示 -->
|
||
<div id="scraperRunResult" style="display:none;">
|
||
<div style="margin-top:20px;font-size:14px;font-weight:600;color:#333;margin-bottom:8px;">📋 测试抓取结果
|
||
</div>
|
||
<div class="run-result" id="scraperRunResultContent"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增/编辑弹窗 -->
|
||
<div class="modal-overlay" id="scraperModal">
|
||
<div class="modal-box">
|
||
<div class="modal-header">
|
||
<h3 id="scraperModalTitle">新增抓取来源</h3>
|
||
<button class="modal-close" onclick="closeScraperModal()">×</button>
|
||
</div>
|
||
<form class="modal-form" onsubmit="saveScraperItem(event)">
|
||
<input type="hidden" id="scraperEditId">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>城市 *</label>
|
||
<input type="text" id="scraperCity" placeholder="例: 南京市" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>板块</label>
|
||
<input type="text" id="scraperSection" placeholder="例: 交通水务">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>子板块</label>
|
||
<input type="text" id="scraperSubsection" placeholder="例: 建设工程">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>类型</label>
|
||
<input type="text" id="scraperType" placeholder="例: 招标公告">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>链接地址 *</label>
|
||
<input type="url" id="scraperUrl" placeholder="https://..." required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>提示词(Agent 指令)*</label>
|
||
<textarea id="scraperPrompt"
|
||
placeholder="提取页面上今日的招标公告信息,包括:标题、项目金额(可能为合同预估价/最高投标限价等等)、发布日期(YYYY-MM-DD格式)、详情页完整URL"
|
||
required></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>AI 模型</label>
|
||
<select id="scraperModel">
|
||
<option value="spark-1-mini">spark-1-mini(默认)</option>
|
||
<option value="spark-2">spark-2</option>
|
||
<option value="gpt-4o-mini">gpt-4o-mini</option>
|
||
<option value="claude-3-haiku">claude-3-haiku</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="display:flex;align-items:flex-end;padding-bottom:2px;">
|
||
<div class="checkbox-wrapper" style="width:100%;"
|
||
onclick="document.getElementById('scraperEnabled').click();">
|
||
<input type="checkbox" id="scraperEnabled" checked onclick="event.stopPropagation();">
|
||
<label for="scraperEnabled">启用此来源</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeScraperModal()">取消</button>
|
||
<button type="submit" class="btn-save">保存</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- docx库已改为按需加载,只在用户点击导出时才加载,提升首屏加载速度 -->
|
||
<script src="app.js" defer></script>
|
||
<script>
|
||
// ===== 抓取来源配置 JS =====
|
||
let scrapersList = [];
|
||
|
||
async function loadScrapers() {
|
||
try {
|
||
const res = await fetch('/api/scrapers');
|
||
const json = await res.json();
|
||
scrapersList = json.data || [];
|
||
renderScrapers();
|
||
} catch (e) {
|
||
console.error('加载抓取来源失败:', e);
|
||
}
|
||
}
|
||
|
||
function renderScrapers() {
|
||
const tbody = document.getElementById('scrapersTbody');
|
||
if (scrapersList.length === 0) {
|
||
tbody.innerHTML = `<tr id="scrapers-empty-row"><td colspan="9" class="empty-state">
|
||
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M3 9h18M9 21V9"/></svg>
|
||
<div>暂无配置,点击「新增来源」添加抓取任务</div></td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = scrapersList.map(s => `
|
||
<tr>
|
||
<td>${s.city || '-'}</td>
|
||
<td>${s.section || '-'}</td>
|
||
<td>${s.subsection || '-'}</td>
|
||
<td><span class="tag tag-type">${s.type || ''}</span></td>
|
||
<td class="url-cell"><a href="${s.url}" target="_blank" title="${s.url}">${s.url.replace(/^https?:\/\//, '').substring(0, 35)}${s.url.length > 40 ? '...' : ''}</a></td>
|
||
<td class="prompt-cell" title="${(s.prompt || '').replace(/"/g, '"')}">${s.prompt || '-'}</td>
|
||
<td style="font-size:12px;color:#888;">${s.model || 'spark-1-mini'}</td>
|
||
<td><span class="tag ${s.enabled ? 'tag-enabled' : 'tag-disabled'}">${s.enabled ? '启用' : '禁用'}</span></td>
|
||
<td>
|
||
<div class="action-btns">
|
||
<button class="btn-sm btn-edit" onclick="openScraperModal('${s.id}')" title="编辑">编辑</button>
|
||
<button class="btn-sm btn-run" onclick="runScraper('${s.id}', this)" title="测试运行">测试</button>
|
||
<button class="btn-sm ${s.enabled ? 'btn-toggle-on' : 'btn-toggle-off'}" onclick="toggleScraper('${s.id}', ${!s.enabled})" title="切换启用状态">${s.enabled ? '禁用' : '启用'}</button>
|
||
<button class="btn-sm btn-delete" onclick="deleteScraper('${s.id}')" title="删除">删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function openScraperModal(id) {
|
||
const item = id ? scrapersList.find(s => s.id === id) : null;
|
||
document.getElementById('scraperModalTitle').textContent = item ? '编辑抓取来源' : '新增抓取来源';
|
||
document.getElementById('scraperEditId').value = item ? item.id : '';
|
||
document.getElementById('scraperCity').value = item ? item.city : '';
|
||
document.getElementById('scraperSection').value = item ? item.section : '';
|
||
document.getElementById('scraperSubsection').value = item ? item.subsection : '';
|
||
document.getElementById('scraperType').value = item ? item.type : '招标公告';
|
||
document.getElementById('scraperUrl').value = item ? item.url : '';
|
||
document.getElementById('scraperPrompt').value = item ? item.prompt : '提取页面上今日的招标公告信息,包括:标题、项目金额(可能为合同预估价/最高投标限价等等)、发布日期(YYYY-MM-DD格式)、详情页完整URL';
|
||
document.getElementById('scraperModel').value = item ? (item.model || 'spark-1-mini') : 'spark-1-mini';
|
||
document.getElementById('scraperEnabled').checked = item ? item.enabled : true;
|
||
document.getElementById('scraperRunResult').style.display = 'none';
|
||
document.getElementById('scraperModal').classList.add('show');
|
||
}
|
||
|
||
function closeScraperModal() {
|
||
document.getElementById('scraperModal').classList.remove('show');
|
||
}
|
||
|
||
async function saveScraperItem(e) {
|
||
e.preventDefault();
|
||
const id = document.getElementById('scraperEditId').value;
|
||
const data = {
|
||
city: document.getElementById('scraperCity').value.trim(),
|
||
url: document.getElementById('scraperUrl').value.trim(),
|
||
section: document.getElementById('scraperSection').value.trim(),
|
||
subsection: document.getElementById('scraperSubsection').value.trim(),
|
||
type: document.getElementById('scraperType').value,
|
||
prompt: document.getElementById('scraperPrompt').value.trim(),
|
||
model: document.getElementById('scraperModel').value,
|
||
enabled: document.getElementById('scraperEnabled').checked,
|
||
};
|
||
try {
|
||
const url = id ? `/api/scrapers/${id}` : '/api/scrapers';
|
||
const method = id ? 'PUT' : 'POST';
|
||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||
const json = await res.json();
|
||
if (!json.success) throw new Error(json.error);
|
||
closeScraperModal();
|
||
await loadScrapers();
|
||
} catch (err) {
|
||
alert('保存失败: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function deleteScraper(id) {
|
||
const item = scrapersList.find(s => s.id === id);
|
||
if (!confirm(`确定要删除「${item?.city} - ${item?.type}」吗?`)) return;
|
||
try {
|
||
const res = await fetch(`/api/scrapers/${id}`, { method: 'DELETE' });
|
||
const json = await res.json();
|
||
if (!json.success) throw new Error(json.error);
|
||
await loadScrapers();
|
||
} catch (err) {
|
||
alert('删除失败: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function toggleScraper(id, enabled) {
|
||
try {
|
||
const res = await fetch(`/api/scrapers/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled })
|
||
});
|
||
const json = await res.json();
|
||
if (!json.success) throw new Error(json.error);
|
||
await loadScrapers();
|
||
} catch (err) {
|
||
alert('操作失败: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function runScraper(id, btnEl) {
|
||
const item = scrapersList.find(s => s.id === id);
|
||
const resultDiv = document.getElementById('scraperRunResult');
|
||
const contentDiv = document.getElementById('scraperRunResultContent');
|
||
resultDiv.style.display = 'block';
|
||
contentDiv.textContent = `正在测试抓取「${item?.city} - ${item?.type}」,请稍候...`;
|
||
resultDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
|
||
const originalText = btnEl ? btnEl.textContent : '';
|
||
if (btnEl) {
|
||
btnEl.disabled = true;
|
||
btnEl.textContent = '测试中...';
|
||
}
|
||
try {
|
||
const res = await fetch(`/api/scrapers/${id}/run`, { method: 'POST' });
|
||
const json = await res.json();
|
||
if (!json.success) throw new Error(json.error);
|
||
contentDiv.textContent = JSON.stringify(json.data, null, 2);
|
||
} catch (err) {
|
||
contentDiv.textContent = '❌ 测试失败: ' + err.message;
|
||
} finally {
|
||
if (btnEl) {
|
||
btnEl.disabled = false;
|
||
btnEl.textContent = originalText || '测试';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 切换到抓取来源 Tab 时自动加载
|
||
const _origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// 拦截 tab 切换,在进入 scrapers tab 时加载数据
|
||
document.querySelectorAll('.tab').forEach(btn => {
|
||
if (btn.textContent.trim() === '抓取来源') {
|
||
btn.addEventListener('click', () => { loadScrapers(); });
|
||
}
|
||
});
|
||
// 点击弹窗遮罩关闭
|
||
document.getElementById('scraperModal').addEventListener('click', function (e) {
|
||
if (e.target === this) closeScraperModal();
|
||
});
|
||
});
|
||
|
||
// 批量运行所有已启用来源
|
||
async function runAllScrapers() {
|
||
const enabled = scrapersList.filter(s => s.enabled);
|
||
if (enabled.length === 0) {
|
||
alert('没有已启用的抓取来源,请先在列表中启用至少一个来源。');
|
||
return;
|
||
}
|
||
if (!confirm(`确定要运行全部 ${enabled.length} 个已启用的抓取来源吗?\n结果将自动保存,可在「抓取结果」页查看。`)) return;
|
||
|
||
const btn = document.getElementById('btnRunAll');
|
||
const statusDiv = document.getElementById('batchRunStatus');
|
||
btn.disabled = true;
|
||
btn.textContent = '运行中...';
|
||
statusDiv.style.display = 'block';
|
||
statusDiv.innerHTML = `⏳ 正在运行 ${enabled.length} 个抓取来源,请稍候...`;
|
||
|
||
try {
|
||
const res = await fetch('/api/scrape/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
const json = await res.json();
|
||
if (!json.success) throw new Error(json.error);
|
||
|
||
const results = json.data || [];
|
||
const ok = results.filter(r => !r.error).length;
|
||
const err = results.filter(r => r.error).length;
|
||
statusDiv.innerHTML = `
|
||
✅ 批量抓取完成!成功 <strong>${ok}</strong> 条,失败 <strong>${err}</strong> 条。
|
||
<a href="/results.html" target="_blank" style="color:#667eea;font-weight:600;text-decoration:underline;">点击查看抓取结果 →</a>
|
||
`;
|
||
} catch (e) {
|
||
statusDiv.innerHTML = `❌ 批量运行失败: ${e.message}`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = `<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg> 运行全部启用`;
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|