爬虫开发
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled

This commit is contained in:
cjd
2026-02-18 12:56:36 +08:00
parent a98bc6f13c
commit 260460df03
45 changed files with 4091 additions and 8 deletions

View File

@@ -0,0 +1,73 @@
@extends('layouts.admin')
@section('title', '采集告警中心')
@section('head')
@include('admin.partials.modern-index-head')
@endsection
@section('content')
<div class="card modern-index-toolbar mb-3">
<div class="card-body d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
<form method="get" action="{{ route('admin.crawl-alerts.index') }}" class="d-flex flex-column flex-md-row gap-2 w-100">
<select class="form-select" name="resolved" style="max-width: 260px">
<option value="">全部状态</option>
<option value="0" @selected(($filters['resolved'] ?? '') === '0')>未处理</option>
<option value="1" @selected(($filters['resolved'] ?? '') === '1')>已处理</option>
</select>
<button class="btn btn-primary" type="submit">筛选</button>
</form>
</div>
</div>
<div class="card modern-index-card">
<div class="table-responsive">
<table class="table table-vcenter card-table modern-index-table">
<thead>
<tr>
<th>ID</th>
<th>等级</th>
<th>规则</th>
<th>运行</th>
<th>类型</th>
<th>信息</th>
<th>状态</th>
<th class="text-end">操作</th>
</tr>
</thead>
<tbody>
@forelse($items as $item)
<tr>
<td>#{{ $item->id }}</td>
<td>{{ $item->severity?->value ?? '-' }}</td>
<td>{{ $item->rule?->name ?? '-' }}</td>
<td>
@if($item->run)
<a href="{{ route('admin.crawl-runs.show', $item->run) }}">#{{ $item->run_id }}</a>
@else
-
@endif
</td>
<td>{{ $item->type }}</td>
<td>{{ $item->message }}</td>
<td>{{ $item->is_resolved ? '已处理' : '未处理' }}</td>
<td class="text-end">
@if(! $item->is_resolved)
<form method="post" action="{{ route('admin.crawl-alerts.resolve', $item) }}">
@csrf
<button class="btn btn-sm btn-outline-success" type="submit">标记已处理</button>
</form>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center text-muted py-5">暂无告警</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="card-footer">{{ $items->links() }}</div>
</div>
@endsection

View File

@@ -0,0 +1,60 @@
@extends('layouts.admin')
@section('title', '采集运行记录')
@section('head')
@include('admin.partials.modern-index-head')
@endsection
@section('content')
<div class="card modern-index-toolbar mb-3">
<div class="card-body d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
<form method="get" action="{{ route('admin.crawl-runs.index') }}" class="d-flex flex-column flex-md-row gap-2 w-100">
<input class="form-control" type="number" name="rule_id" value="{{ $filters['rule_id'] ?? '' }}" placeholder="按规则ID筛选">
<button class="btn btn-primary" type="submit"><i class="bi bi-search me-1"></i>筛选</button>
</form>
</div>
</div>
<div class="card modern-index-card">
<div class="table-responsive">
<table class="table table-vcenter card-table modern-index-table">
<thead>
<tr>
<th>ID</th>
<th>规则</th>
<th>触发方式</th>
<th>状态</th>
<th>统计</th>
<th>时间</th>
<th class="text-end">操作</th>
</tr>
</thead>
<tbody>
@forelse($items as $item)
<tr>
<td>#{{ $item->id }}</td>
<td>{{ $item->rule?->name ?? '-' }}</td>
<td>{{ $item->trigger_type?->value ?? '-' }}</td>
<td>{{ $item->status?->value ?? '-' }}</td>
<td>成功 {{ $item->success_count }} / 失败 {{ $item->failed_count }} / 跳过 {{ $item->skipped_count }}</td>
<td>{{ $item->created_at?->format('Y-m-d H:i:s') }}</td>
<td class="text-end d-flex justify-content-end gap-2">
<a class="btn btn-sm btn-outline-primary" href="{{ route('admin.crawl-runs.show', $item) }}">详情</a>
<form method="post" action="{{ route('admin.crawl-runs.retry', $item) }}">
@csrf
<button class="btn btn-sm btn-outline-success" type="submit">重试</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="text-center text-muted py-5">暂无运行记录</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="card-footer">{{ $items->links() }}</div>
</div>
@endsection

View File

@@ -0,0 +1,101 @@
@extends('layouts.admin')
@section('title', '运行详情 #'.$run->id)
@section('head')
@include('admin.partials.modern-index-head')
@endsection
@section('page_actions')
<a class="btn btn-outline-secondary" href="{{ route('admin.crawl-runs.index') }}">返回列表</a>
@endsection
@section('content')
<div class="card modern-index-card mb-3">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3"><strong>规则:</strong>{{ $run->rule?->name ?? '-' }}</div>
<div class="col-md-3"><strong>触发方式:</strong>{{ $run->trigger_type?->value ?? '-' }}</div>
<div class="col-md-3"><strong>状态:</strong>{{ $run->status?->value ?? '-' }}</div>
<div class="col-md-3"><strong>创建时间:</strong>{{ $run->created_at?->format('Y-m-d H:i:s') }}</div>
<div class="col-md-3"><strong>总URL</strong>{{ $run->total_urls }}</div>
<div class="col-md-3"><strong>成功:</strong>{{ $run->success_count }}</div>
<div class="col-md-3"><strong>失败:</strong>{{ $run->failed_count }}</div>
<div class="col-md-3"><strong>跳过:</strong>{{ $run->skipped_count }}</div>
</div>
@if($run->error_summary)
<div class="alert alert-warning mt-3 mb-0">{{ $run->error_summary }}</div>
@endif
</div>
</div>
<div class="card modern-index-card mb-3">
<div class="card-header"><strong>运行明细</strong></div>
<div class="table-responsive">
<table class="table table-vcenter card-table modern-index-table">
<thead>
<tr>
<th>ID</th>
<th>URL</th>
<th>阶段</th>
<th>状态</th>
<th>HTTP</th>
<th>耗时(ms)</th>
<th>错误</th>
</tr>
</thead>
<tbody>
@forelse($run->items as $item)
<tr>
<td>#{{ $item->id }}</td>
<td class="text-break" style="max-width: 420px">{{ $item->url }}</td>
<td>{{ $item->stage }}</td>
<td>{{ $item->status?->value ?? '-' }}</td>
<td>{{ $item->http_code ?? '-' }}</td>
<td>{{ $item->latency_ms ?? '-' }}</td>
<td>{{ $item->error_message ?? '-' }}</td>
</tr>
@empty
<tr>
<td colspan="7" class="text-center text-muted py-4">无明细数据</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="card modern-index-card">
<div class="card-header"><strong>关联告警</strong></div>
<div class="table-responsive">
<table class="table table-vcenter card-table modern-index-table">
<thead>
<tr>
<th>ID</th>
<th>等级</th>
<th>类型</th>
<th>信息</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
@forelse($run->alerts as $alert)
<tr>
<td>#{{ $alert->id }}</td>
<td>{{ $alert->severity?->value ?? '-' }}</td>
<td>{{ $alert->type }}</td>
<td>{{ $alert->message }}</td>
<td>{{ $alert->is_resolved ? '已处理' : '未处理' }}</td>
<td>{{ $alert->created_at?->format('Y-m-d H:i:s') }}</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-muted py-4">无告警</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection

View File

@@ -0,0 +1,482 @@
@extends('layouts.admin')
@section('title', $item->exists ? '编辑采集规则' : '新建采集规则')
@section('head')
@include('admin.partials.modern-form-head')
@endsection
@section('scripts')
<script>
(function () {
const previewEndpoint = '{{ route('admin.crawlers.preview') }}';
const aiSuggestEndpoint = '{{ route('admin.crawlers.ai-suggest-extractor') }}';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
const previewUrlInput = document.getElementById('preview-url');
const previewFrame = document.getElementById('preview-frame');
const previewStatus = document.getElementById('preview-status');
const selectedXPathView = document.getElementById('selected-xpath');
const extractorJsonInput = document.getElementById('extractor-json');
const pickerFieldInput = document.getElementById('picker-field');
let selectedXPath = '';
const parseJson = (text) => {
try {
const data = JSON.parse(text || '{}');
return typeof data === 'object' && data ? data : {};
} catch (_) {
return null;
}
};
const writeExtractor = (config) => {
extractorJsonInput.value = JSON.stringify(config, null, 2);
};
const collectAiOptions = () => {
const options = {};
const model = document.getElementById('ai-model')?.value?.trim();
const systemPrompt = document.getElementById('ai-system-prompt')?.value?.trim();
const userPrompt = document.getElementById('ai-user-prompt')?.value?.trim();
const temperature = document.getElementById('ai-temperature')?.value?.trim();
const maxChars = document.getElementById('ai-content-max-chars')?.value?.trim();
if (model) options.model = model;
if (systemPrompt) options.system_prompt = systemPrompt;
if (userPrompt) options.user_prompt = userPrompt;
if (temperature !== '') options.temperature = Number(temperature);
if (maxChars !== '') options.content_max_chars = Number(maxChars);
return options;
};
const normalizeConfig = (config) => {
const normalized = config && typeof config === 'object' ? config : {};
normalized.mode = document.getElementById('extractor-mode')?.value || 'xpath';
normalized.ai = collectAiOptions();
if (Object.keys(normalized.ai).length === 0) {
delete normalized.ai;
}
if (!normalized.fields || typeof normalized.fields !== 'object') {
normalized.fields = {};
}
return normalized;
};
const postJson = async (url, payload) => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error(data.message || ('HTTP ' + response.status));
}
return data;
};
const installPicker = () => {
const frameWindow = previewFrame?.contentWindow;
const frameDocument = previewFrame?.contentDocument;
if (!frameWindow || !frameDocument || frameWindow.__pickerInstalled) {
return;
}
frameWindow.__pickerInstalled = true;
const script = frameDocument.createElement('script');
script.text = `
(function () {
try {
window.open = function () { return null; };
} catch (e) {}
const disableNavigation = () => {
document.querySelectorAll('a[href], area[href]').forEach((node) => {
const href = node.getAttribute('href') || '';
node.setAttribute('data-original-href', href);
node.setAttribute('href', 'javascript:void(0)');
node.removeAttribute('target');
});
document.querySelectorAll('form').forEach((form) => {
form.setAttribute('data-original-action', form.getAttribute('action') || '');
form.setAttribute('action', 'javascript:void(0)');
form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
}, true);
});
document.querySelectorAll('button, input[type="submit"], input[type="button"]').forEach((node) => {
node.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
}, true);
});
};
disableNavigation();
document.documentElement.style.cursor = 'crosshair';
document.body.style.cursor = 'crosshair';
document.querySelectorAll('*').forEach((node) => {
node.style.cursor = 'crosshair';
});
const xpath = (el) => {
if (!el || el.nodeType !== 1) return '';
if (el.id) return '//*[@id="' + el.id + '"]';
const parts = [];
let node = el;
while (node && node.nodeType === 1) {
let i = 1;
let p = node.previousElementSibling;
while (p) {
if (p.tagName === node.tagName) i += 1;
p = p.previousElementSibling;
}
parts.unshift(node.tagName.toLowerCase() + '[' + i + ']');
node = node.parentElement;
}
return '/' + parts.join('/');
};
document.addEventListener('mouseover', (e) => {
if (e.target instanceof Element) {
e.target.style.outline = '2px solid #2563eb';
}
}, true);
document.addEventListener('mouseout', (e) => {
if (e.target instanceof Element) {
e.target.style.outline = '';
}
}, true);
document.addEventListener('click', (e) => {
if (!(e.target instanceof Element)) return;
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') {
e.stopImmediatePropagation();
}
window.parent.postMessage({
source: 'crawler-picker',
xpath: xpath(e.target),
}, '*');
}, true);
document.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
}, true);
document.addEventListener('mouseup', (e) => {
e.preventDefault();
e.stopPropagation();
}, true);
document.addEventListener('auxclick', (e) => {
e.preventDefault();
e.stopPropagation();
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
}
}, true);
}());
`;
frameDocument.body?.appendChild(script);
};
document.getElementById('preview-load-btn')?.addEventListener('click', async () => {
const url = previewUrlInput?.value?.trim();
if (!url) {
alert('请先输入 URL');
return;
}
previewStatus.textContent = '正在加载预览...';
try {
const data = await postJson(previewEndpoint, {
url,
user_agent: document.getElementById('user-agent')?.value || '',
});
previewFrame.srcdoc = data.html || '';
previewFrame.onload = installPicker;
previewStatus.textContent = '预览已加载,可点击页面元素。';
} catch (error) {
previewStatus.textContent = '加载失败:' + (error.message || 'unknown');
}
});
document.getElementById('apply-selector-btn')?.addEventListener('click', () => {
if (!selectedXPath) {
alert('请先在预览中点选元素');
return;
}
const field = (pickerFieldInput?.value || '').trim() || 'list_link_xpath';
const current = parseJson(extractorJsonInput.value);
if (current === null) {
alert('Extractor JSON 不是有效 JSON');
return;
}
const config = normalizeConfig(current);
if (field === 'list_link_xpath') {
config.list_link_xpath = selectedXPath;
} else {
config.fields[field] = selectedXPath;
}
writeExtractor(config);
});
document.getElementById('ai-suggest-btn')?.addEventListener('click', async () => {
const url = previewUrlInput?.value?.trim();
if (!url) {
alert('请先输入 URL');
return;
}
try {
const data = await postJson(aiSuggestEndpoint, {
url,
target_module: document.getElementById('target-module')?.value || 'tool',
user_agent: document.getElementById('user-agent')?.value || '',
ai_model: document.getElementById('ai-model')?.value || '',
ai_system_prompt: document.getElementById('ai-system-prompt')?.value || '',
ai_user_prompt: document.getElementById('ai-user-prompt')?.value || '',
ai_temperature: document.getElementById('ai-temperature')?.value || '',
ai_content_max_chars: document.getElementById('ai-content-max-chars')?.value || '',
});
const current = parseJson(extractorJsonInput.value);
const base = current && typeof current === 'object' ? current : {};
writeExtractor(normalizeConfig({
...base,
...data.extractor_config,
}));
previewStatus.textContent = 'AI 规则已生成并合并。';
} catch (error) {
previewStatus.textContent = 'AI 生成失败:' + (error.message || 'unknown');
}
});
window.addEventListener('message', (event) => {
if (!event.data || event.data.source !== 'crawler-picker') {
return;
}
selectedXPath = String(event.data.xpath || '').trim();
selectedXPathView.textContent = selectedXPath || '未选择';
});
document.getElementById('extractor-mode')?.addEventListener('change', () => {
const current = parseJson(extractorJsonInput.value);
if (current !== null) {
writeExtractor(normalizeConfig(current));
}
});
}());
</script>
@endsection
@section('content')
<div class="card modern-form-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title mb-0">{{ $item->exists ? '编辑采集规则' : '新建采集规则' }}</h3>
<a class="btn btn-sm btn-outline-secondary" href="{{ route('admin.crawlers.index') }}">返回列表</a>
</div>
<div class="card-body">
<form method="post" action="{{ $submitRoute }}" class="row g-3" id="crawler-form">
@csrf
@if($method !== 'POST') @method($method) @endif
@php
$entryUrls = old('entry_urls', is_array($item->entry_urls) ? implode("\n", $item->entry_urls) : '');
$headersJson = old('headers_json', json_encode($item->headers ?? [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$cookiesJson = old('cookies_json', json_encode($item->cookies ?? [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$extractorConfig = is_array($item->extractor_config) ? $item->extractor_config : [];
$extractorJson = old('extractor_json', json_encode($extractorConfig, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$mappingJson = old('mapping_json', json_encode($item->mapping_config ?? [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$dedupeJson = old('dedupe_json', json_encode($item->dedupe_config ?? [], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$extractorAi = is_array($extractorConfig['ai'] ?? null) ? $extractorConfig['ai'] : [];
$mode = old('extractor_mode', $extractorConfig['mode'] ?? 'xpath');
@endphp
<div class="col-12">
<section class="form-section">
<h4 class="form-section-title">基础配置</h4>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">规则名称</label>
<input class="form-control" name="name" value="{{ old('name', $item->name) }}" required>
</div>
<div class="col-md-3">
<label class="form-label">目标模块</label>
<select class="form-select" name="target_module" id="target-module" required>
<option value="tool" @selected(old('target_module', $item->target_module?->value ?? 'tool') === 'tool')>AI 工具</option>
<option value="model" @selected(old('target_module', $item->target_module?->value ?? 'tool') === 'model')>AI 模型</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">发布策略</label>
<select class="form-select" name="publish_policy">
<option value="draft" @selected(old('publish_policy', $item->publish_policy ?? 'draft') === 'draft')>草稿待审核</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Cron 表达式</label>
<input class="form-control" name="cron_expression" value="{{ old('cron_expression', $item->cron_expression ?: '0 */6 * * *') }}" required>
</div>
<div class="col-md-4">
<label class="form-label">时区</label>
<input class="form-control" name="timezone" value="{{ old('timezone', $item->timezone ?: 'Asia/Shanghai') }}" required>
</div>
<div class="col-md-2">
<label class="form-label">最大页面数</label>
<input class="form-control" type="number" min="1" max="2000" name="max_pages" value="{{ old('max_pages', $item->max_pages ?: 50) }}" required>
</div>
<div class="col-md-2">
<label class="form-label">启用</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="enabled" value="1" @checked(old('enabled', $item->enabled))>
<label class="form-check-label">启用规则</label>
</div>
</div>
<div class="col-12">
<label class="form-label">入口 URL每行一个</label>
<textarea class="form-control" name="entry_urls" rows="4" required>{{ $entryUrls }}</textarea>
</div>
</div>
</section>
</div>
<div class="col-12">
<section class="form-section">
<h4 class="form-section-title">抓取与 AI 配置</h4>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">每分钟限流</label>
<input class="form-control" type="number" min="1" max="2000" name="rate_limit_per_minute" value="{{ old('rate_limit_per_minute', $item->rate_limit_per_minute ?: 30) }}" required>
</div>
<div class="col-md-3">
<label class="form-label">最大重试次数</label>
<input class="form-control" type="number" min="1" max="10" name="retry_max" value="{{ old('retry_max', $item->retry_max ?: 3) }}" required>
</div>
<div class="col-md-3">
<label class="form-label">退避秒数</label>
<input class="form-control" type="number" min="1" max="3600" name="retry_backoff_seconds" value="{{ old('retry_backoff_seconds', $item->retry_backoff_seconds ?: 60) }}" required>
</div>
<div class="col-md-3">
<label class="form-label">告警邮箱</label>
<input class="form-control" type="email" name="alert_email" value="{{ old('alert_email', $item->alert_email) }}">
</div>
<div class="col-md-3">
<label class="form-label">抽取模式</label>
<select class="form-select" name="extractor_mode" id="extractor-mode" required>
<option value="xpath" @selected($mode === 'xpath')>XPath</option>
<option value="ai" @selected($mode === 'ai')>AI</option>
<option value="hybrid" @selected($mode === 'hybrid')>Hybrid</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">AI Provider</label>
<input class="form-control" name="ai_provider" value="{{ old('ai_provider', $item->ai_provider ?: 'openai_compatible') }}">
</div>
<div class="col-md-3">
<label class="form-label">AI Model</label>
<input class="form-control" name="ai_model" id="ai-model" value="{{ old('ai_model', $item->ai_model ?: config('crawler.openai_default_model')) }}">
</div>
<div class="col-md-3">
<label class="form-label">AI 温度</label>
<input class="form-control" type="number" step="0.1" min="0" max="2" name="ai_temperature" id="ai-temperature" value="{{ old('ai_temperature', $extractorAi['temperature'] ?? 0) }}">
</div>
<div class="col-md-4">
<label class="form-label">AI 截断长度</label>
<input class="form-control" type="number" min="500" max="50000" name="ai_content_max_chars" id="ai-content-max-chars" value="{{ old('ai_content_max_chars', $extractorAi['content_max_chars'] ?? 12000) }}">
</div>
<div class="col-md-4">
<label class="form-label">AI 系统提示词</label>
<textarea class="form-control" name="ai_system_prompt" id="ai-system-prompt" rows="3">{{ old('ai_system_prompt', $extractorAi['system_prompt'] ?? '') }}</textarea>
</div>
<div class="col-md-4">
<label class="form-label">AI 用户提示词</label>
<textarea class="form-control" name="ai_user_prompt" id="ai-user-prompt" rows="3">{{ old('ai_user_prompt', $extractorAi['user_prompt'] ?? '') }}</textarea>
</div>
<div class="col-md-4">
<label class="form-label">User-Agent</label>
<input class="form-control" name="user_agent" id="user-agent" value="{{ old('user_agent', $item->user_agent) }}">
</div>
<div class="col-md-4">
<label class="form-label">代理</label>
<input class="form-control" name="proxy" value="{{ old('proxy', $item->proxy) }}">
</div>
<div class="col-md-4">
<label class="form-label">AI 兜底</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="ai_fallback_enabled" value="1" @checked(old('ai_fallback_enabled', $item->ai_fallback_enabled))>
<label class="form-check-label">缺字段启用兜底</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Headers JSON</label>
<textarea class="form-control" name="headers_json" rows="5">{{ $headersJson }}</textarea>
</div>
<div class="col-md-6">
<label class="form-label">Cookies JSON</label>
<textarea class="form-control" name="cookies_json" rows="5">{{ $cookiesJson }}</textarea>
</div>
</div>
</section>
</div>
<div class="col-12">
<section class="form-section">
<h4 class="form-section-title">Extractor / Mapping / 预览选元素</h4>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Extractor JSON</label>
<textarea class="form-control" name="extractor_json" id="extractor-json" rows="14" required>{{ $extractorJson }}</textarea>
</div>
<div class="col-md-6">
<label class="form-label">Mapping JSON</label>
<textarea class="form-control" name="mapping_json" rows="6">{{ $mappingJson }}</textarea>
<label class="form-label mt-3">Dedupe JSON</label>
<textarea class="form-control" name="dedupe_json" rows="6">{{ $dedupeJson }}</textarea>
</div>
<div class="col-md-9">
<input class="form-control" type="url" id="preview-url" placeholder="输入目标页面 URL用于预览和 AI 生成规则)">
</div>
<div class="col-md-3 d-grid">
<button class="btn btn-outline-primary" type="button" id="preview-load-btn">加载预览</button>
</div>
<div class="col-12">
<iframe id="preview-frame" style="width:100%;height:480px;border:1px solid #d7e0ef;border-radius:.6rem;" sandbox="allow-same-origin allow-scripts"></iframe>
<div class="small text-muted mt-2" id="preview-status">未加载预览</div>
</div>
<div class="col-md-5">
<label class="form-label">当前 XPath</label>
<div id="selected-xpath" class="form-control" style="height:auto;min-height:42px;">未选择</div>
</div>
<div class="col-md-4">
<label class="form-label">写入字段(支持自定义)</label>
<input class="form-control" id="picker-field" placeholder="list_link_xpath 或 name/summary/...">
</div>
<div class="col-md-3 d-grid">
<label class="form-label">&nbsp;</label>
<button class="btn btn-primary" type="button" id="apply-selector-btn">写入 Extractor JSON</button>
</div>
<div class="col-md-12 d-grid">
<button class="btn btn-outline-success" type="button" id="ai-suggest-btn">AI 生成抽取规则并合并到 Extractor JSON</button>
</div>
</div>
</section>
</div>
<div class="col-12 d-flex justify-content-between align-items-center">
<small class="text-muted">建议流程:加载预览 -> 点选元素写 XPath -> AI 补全规则 -> 保存。</small>
<button class="btn btn-primary" type="submit">保存规则</button>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,74 @@
@extends('layouts.admin')
@section('title', '采集规则')
@section('head')
@include('admin.partials.modern-index-head')
@endsection
@section('page_actions')
<a class="btn btn-success" href="{{ route('admin.crawlers.create') }}"><i class="bi bi-plus-circle me-1"></i>新建规则</a>
@endsection
@section('content')
<div class="card modern-index-toolbar mb-3">
<div class="card-body d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between">
<form method="get" action="{{ route('admin.crawlers.index') }}" class="d-flex flex-column flex-md-row gap-2 w-100">
<input class="form-control" type="text" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="搜索规则名称">
<button class="btn btn-primary" type="submit"><i class="bi bi-search me-1"></i>搜索</button>
</form>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<div class="toolbar-meta"> {{ number_format($items->total()) }} 条规则</div>
</div>
</div>
<div class="card modern-index-card">
<div class="table-responsive">
<table class="table table-vcenter card-table modern-index-table">
<thead>
<tr>
<th>规则</th>
<th>目标模块</th>
<th>Cron</th>
<th>状态</th>
<th>最近运行</th>
<th class="text-end">操作</th>
</tr>
</thead>
<tbody>
@forelse($items as $item)
<tr>
<td>
<div class="modern-index-title">{{ $item->name }}</div>
<div class="modern-index-summary">运行次数:{{ $item->runs_count }} / 下次:{{ $item->next_run_at?->format('Y-m-d H:i') ?? '-' }}</div>
</td>
<td>{{ $item->target_module?->label() ?? '-' }}</td>
<td><code>{{ $item->cron_expression }}</code></td>
<td>
@if($item->enabled)
<span class="badge bg-green-lt text-green-fg">启用</span>
@else
<span class="badge bg-gray-200 text-dark">停用</span>
@endif
</td>
<td>{{ $item->last_run_at?->format('Y-m-d H:i') ?? '-' }}</td>
<td class="text-end d-flex justify-content-end gap-2">
<form method="post" action="{{ route('admin.crawlers.run', $item) }}">
@csrf
<button class="btn btn-sm btn-outline-success" type="submit">立即执行</button>
</form>
<a class="btn btn-sm btn-outline-primary" href="{{ route('admin.crawlers.edit', $item) }}">编辑</a>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-muted py-5">暂无采集规则</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="card-footer">{{ $items->links() }}</div>
</div>
@endsection

View File

@@ -9,17 +9,21 @@
'tools' => ['label' => 'AI 工具', 'index' => 'admin.tools.index', 'subtitle' => '维护工具信息、状态与展示内容。'],
'models' => ['label' => 'AI 模型', 'index' => 'admin.models.index', 'subtitle' => '管理模型参数、评分与发布状态。'],
'articles' => ['label' => 'AI 资讯', 'index' => 'admin.articles.index', 'subtitle' => '维护资讯内容、来源与发布质量。'],
'guides' => ['label' => 'AI 教程', 'index' => 'admin.guides.index', 'subtitle' => '维护教程内容与学习难度层。'],
'guides' => ['label' => 'AI 教程', 'index' => 'admin.guides.index', 'subtitle' => '维护教程内容与学习难度层。'],
'categories' => ['label' => '分类管理', 'index' => 'admin.categories.index', 'subtitle' => '统一管理分类体系与启用状态。'],
'sources' => ['label' => '来源管理', 'index' => 'admin.sources.index', 'subtitle' => '维护可来源白名单与抓取策略。'],
'settings' => ['label' => '首页配置', 'index' => 'admin.settings.index', 'subtitle' => '配置首页模块、条目展示顺序。'],
'feedback' => ['label' => '反馈管理', 'index' => 'admin.feedback.index', 'subtitle' => '跟进用户反馈并及时更新处理状态。'],
'sources' => ['label' => '来源管理', 'index' => 'admin.sources.index', 'subtitle' => '维护可来源及可信度配置。'],
'settings' => ['label' => '首页配置', 'index' => 'admin.settings.index', 'subtitle' => '配置首页模块、条目展示顺序。'],
'feedback' => ['label' => '反馈管理', 'index' => 'admin.feedback.index', 'subtitle' => '跟进用户反馈并更新处理状态。'],
'crawlers' => ['label' => '采集规则', 'index' => 'admin.crawlers.index', 'subtitle' => '维护采集目标、字段映射与调度策略。'],
'crawl-runs' => ['label' => '采集运行', 'index' => 'admin.crawl-runs.index', 'subtitle' => '查看每次采集执行结果、失败原因和重试。'],
'crawl-alerts' => ['label' => '采集告警', 'index' => 'admin.crawl-alerts.index', 'subtitle' => '集中处理采集异常并追踪恢复情况。'],
][$moduleKey] ?? ['label' => '管理后台', 'index' => 'admin.dashboard', 'subtitle' => '维护站点内容与配置。'];
$actionLabel = [
'index' => '列表',
'create' => '新建',
'edit' => '编辑',
'show' => '详情',
][$actionKey] ?? '详情';
$defaultTitle = $moduleMeta['label'];
@@ -30,7 +34,7 @@
if ($pageSubtitle === '') {
$pageSubtitle = $actionKey === 'index'
? $moduleMeta['subtitle']
: '当前为'.$actionLabel.'页面,请按提示完成必填信息保存。';
: '当前为'.$actionLabel.'页面,请按提示完信息保存。';
}
@endphp

View File

@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
@@ -111,6 +111,10 @@
<li class="nav-item"><a class="nav-link @if(request()->routeIs('admin.settings.*')) active @endif" href="{{ route('admin.settings.index') }}"><span class="nav-link-icon"><i class="bi bi-sliders"></i></span><span class="nav-link-title">首页配置</span></a></li>
<li class="nav-item"><a class="nav-link @if(request()->routeIs('admin.feedback.*')) active @endif" href="{{ route('admin.feedback.index') }}"><span class="nav-link-icon"><i class="bi bi-chat-left-dots"></i></span><span class="nav-link-title">反馈管理</span></a></li>
<li class="sidebar-caption">采集系统</li>
<li class="nav-item"><a class="nav-link @if(request()->routeIs('admin.crawlers.*')) active @endif" href="{{ route('admin.crawlers.index') }}"><span class="nav-link-icon"><i class="bi bi-diagram-2"></i></span><span class="nav-link-title">采集规则</span></a></li>
<li class="nav-item"><a class="nav-link @if(request()->routeIs('admin.crawl-runs.*')) active @endif" href="{{ route('admin.crawl-runs.index') }}"><span class="nav-link-icon"><i class="bi bi-hourglass-split"></i></span><span class="nav-link-title">运行记录</span></a></li>
<li class="nav-item"><a class="nav-link @if(request()->routeIs('admin.crawl-alerts.*')) active @endif" href="{{ route('admin.crawl-alerts.index') }}"><span class="nav-link-icon"><i class="bi bi-bell"></i></span><span class="nav-link-title">告警中心</span></a></li>
<li class="sidebar-caption">站点</li>
<li class="nav-item"><a class="nav-link" href="{{ route('home') }}" target="_blank"><span class="nav-link-icon"><i class="bi bi-globe2"></i></span><span class="nav-link-title">访问前台</span></a></li>
</ul>
@@ -329,4 +333,3 @@
@yield('scripts')
</body>
</html>