mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-15 21:08:01 +02:00
Add files via upload
This commit is contained in:
@@ -511,6 +511,8 @@ func setupRoutes(
|
||||
|
||||
// 信息收集 - FOFA 查询(后端代理)
|
||||
protected.POST("/fofa/search", fofaHandler.Search)
|
||||
// 信息收集 - 自然语言解析为 FOFA 语法(需人工确认后再查询)
|
||||
protected.POST("/fofa/parse", fofaHandler.ParseNaturalLanguage)
|
||||
|
||||
// 批量任务管理
|
||||
protected.POST("/batch-tasks", agentHandler.CreateBatchQueue)
|
||||
|
||||
+163
-6
@@ -1,8 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -11,22 +13,31 @@ import (
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
openaiClient "cyberstrike-ai/internal/openai"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type FofaHandler struct {
|
||||
cfg *config.Config
|
||||
logger *zap.Logger
|
||||
client *http.Client
|
||||
cfg *config.Config
|
||||
logger *zap.Logger
|
||||
client *http.Client
|
||||
openAIClient *openaiClient.Client
|
||||
}
|
||||
|
||||
func NewFofaHandler(cfg *config.Config, logger *zap.Logger) *FofaHandler {
|
||||
// LLM 请求通常比 FOFA 查询更慢一点,单独给一个更宽松的超时。
|
||||
llmHTTPClient := &http.Client{Timeout: 2 * time.Minute}
|
||||
var llmCfg *config.OpenAIConfig
|
||||
if cfg != nil {
|
||||
llmCfg = &cfg.OpenAI
|
||||
}
|
||||
return &FofaHandler{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
openAIClient: openaiClient.NewClient(llmCfg, llmHTTPClient, logger),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +49,16 @@ type fofaSearchRequest struct {
|
||||
Full bool `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
type fofaParseRequest struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
|
||||
type fofaParseResponse struct {
|
||||
Query string `json:"query"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type fofaAPIResponse struct {
|
||||
Error bool `json:"error"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
@@ -86,6 +107,142 @@ func (h *FofaHandler) resolveBaseURL() string {
|
||||
return "https://fofa.info/api/v1/search/all"
|
||||
}
|
||||
|
||||
// ParseNaturalLanguage 将自然语言解析为 FOFA 查询语法(仅生成,不执行查询)
|
||||
func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
|
||||
var req fofaParseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.Text = strings.TrimSpace(req.Text)
|
||||
if req.Text == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "text 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.cfg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "系统配置未初始化"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(h.cfg.OpenAI.APIKey) == "" || strings.TrimSpace(h.cfg.OpenAI.Model) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "未配置 AI 模型:请在系统设置中填写 openai.api_key 与 openai.model(支持 OpenAI 兼容 API,如 DeepSeek)",
|
||||
"need": []string{"openai.api_key", "openai.model"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if h.openAIClient == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "AI 客户端未初始化"})
|
||||
return
|
||||
}
|
||||
|
||||
systemPrompt := strings.TrimSpace(`
|
||||
你是“FOFA 查询语法生成器”。任务:把用户输入的自然语言搜索意图,转换成 FOFA 查询语法。
|
||||
|
||||
输出要求(非常重要):
|
||||
1) 只输出 JSON(不要 markdown、不要代码块、不要额外解释文本)
|
||||
2) JSON 结构必须是:
|
||||
{
|
||||
"query": "string,FOFA查询语法(可直接粘贴到 FOFA 或本系统查询框)",
|
||||
"explanation": "string,可选,解释你如何映射字段/逻辑",
|
||||
"warnings": ["string"...] 可选,列出歧义/风险/需要人工确认的点
|
||||
}
|
||||
|
||||
查询语法要点(来自 FOFA 语法参考):
|
||||
- 逻辑连接符:&&(与)、||(或),必要时用 () 包住子表达式以确认优先级(括号优先级最高)
|
||||
- 比较/匹配:
|
||||
- = 匹配;当字段="" 时,可查询“不存在该字段”或“值为空”的情况
|
||||
- == 完全匹配;当字段=="" 时,可查询“字段存在且值为空”的情况
|
||||
- != 不匹配;当字段!="" 时,可查询“值不为空”的情况
|
||||
- *= 模糊匹配;可使用 * 或 ? 进行搜索
|
||||
- 直接输入关键词(不带字段)会在标题、HTML内容、HTTP头、URL字段中搜索;但当意图明确时优先用字段表达(更可控、更准确)
|
||||
|
||||
常用字段速查(来自截图的分类示例,按需使用):
|
||||
- 基础类(General):ip、port、domain、host、os、server、asn、org、is_domain、is_ipv6
|
||||
- 标记类(Special Label):app、fid、product、product.version
|
||||
- 其它筛选:category、type(常见:type="service" / type="subdomain")、cloud_name、is_cloud、is_fraud、is_honeypot
|
||||
- 网站类(type=subdomain):title、header、header_hash、body、body_hash、js_name、js_md5、cname、cname_domain、icon_hash、status_code、icp、sdk_hash
|
||||
- 地理位置(Location):country、region、city
|
||||
- 证书类(Certificate):cert、cert.subject、cert.issuer、cert.subject.org、cert.subject.cn、cert.issuer.org、cert.issuer.cn、cert.domain、
|
||||
cert.is_equal、cert.is_valid、cert.is_match、cert.is_expired、jarm
|
||||
|
||||
生成规则(务必遵守):
|
||||
- 字符串值一律用英文双引号包裹,例如 title="登录"、country="CN"
|
||||
- 不要捏造不存在的 FOFA 字段;不确定时把不确定点写进 warnings,并输出一个保守的 query
|
||||
- 当用户描述里有“多个与/或条件”,优先加 () 明确优先级,例如:(app="Apache" || app="Nginx") && country="CN"
|
||||
- 当用户缺少关键条件导致范围过大或歧义(如地点/协议/端口/服务类型未说明),允许 query 为空字符串,并在 warnings 里明确需要补充的信息
|
||||
`)
|
||||
|
||||
userPrompt := fmt.Sprintf("自然语言意图:%s", req.Text)
|
||||
|
||||
requestBody := map[string]interface{}{
|
||||
"model": h.cfg.OpenAI.Model,
|
||||
"messages": []map[string]interface{}{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 1200,
|
||||
}
|
||||
|
||||
// OpenAI 返回结构:只需要 choices[0].message.content
|
||||
var apiResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.openAIClient.ChatCompletion(ctx, requestBody, &apiResponse); err != nil {
|
||||
var apiErr *openaiClient.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
h.logger.Warn("FOFA自然语言解析:LLM返回错误", zap.Int("status", apiErr.StatusCode))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 解析失败(上游返回非 200),请检查模型配置或稍后重试"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 解析失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(apiResponse.Choices) == 0 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 未返回有效结果"})
|
||||
return
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
|
||||
// 兼容模型偶尔返回 ```json ... ``` 的情况
|
||||
content = strings.TrimPrefix(content, "```json")
|
||||
content = strings.TrimPrefix(content, "```")
|
||||
content = strings.TrimSuffix(content, "```")
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
var parsed fofaParseResponse
|
||||
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
||||
// 直接回传一部分原文,方便排查,但避免太大
|
||||
snippet := content
|
||||
if len(snippet) > 1200 {
|
||||
snippet = snippet[:1200]
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": "AI 返回内容无法解析为 JSON,请稍后重试或换个描述方式",
|
||||
"snippet": snippet,
|
||||
})
|
||||
return
|
||||
}
|
||||
parsed.Query = strings.TrimSpace(parsed.Query)
|
||||
if parsed.Query == "" {
|
||||
// query 允许为空(表示需求不明确),但前端需要明确提示
|
||||
if len(parsed.Warnings) == 0 {
|
||||
parsed.Warnings = []string{"需求信息不足,未能生成可用的 FOFA 查询语法,请补充关键条件(如国家/端口/产品/域名等)。"}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, parsed)
|
||||
}
|
||||
|
||||
// Search FOFA 查询(后端代理,避免前端暴露 key)
|
||||
func (h *FofaHandler) Search(c *gin.Context) {
|
||||
var req fofaSearchRequest
|
||||
|
||||
@@ -3854,6 +3854,36 @@ header {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 通用按钮加载态(用于耗时请求:AI 解析等) */
|
||||
.btn-loading {
|
||||
opacity: 0.9;
|
||||
cursor: progress;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-loading::before {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.18);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: btnSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes btnSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.fofa-nl-status {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
padding: 10px 20px;
|
||||
background: rgba(220, 53, 69, 0.08);
|
||||
@@ -10952,10 +10982,55 @@ header {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 表单整体增加纵向留白,避免“挤在一起” */
|
||||
.info-collect-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 将每个表单块做成轻量卡片分组(只作用于 info-collect 顶层 form-group) */
|
||||
.info-collect-form > .form-group,
|
||||
.info-collect-form > .info-collect-form-row {
|
||||
padding: 14px 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
|
||||
}
|
||||
|
||||
/* 覆盖全局 .form-group textarea(min-height:200px) 的默认大留白 */
|
||||
.form-group textarea.info-collect-query-input {
|
||||
min-height: 36px;
|
||||
max-height: 96px;
|
||||
overflow: hidden; /* 配合 JS 自动增高 */
|
||||
resize: none;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.info-collect-nl-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-collect-nl-row .info-collect-query-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* 允许在 flex 中收缩,避免撑破布局 */
|
||||
}
|
||||
|
||||
.info-collect-nl-row button {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.info-collect-form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.info-collect-nl-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.info-collect-col-actions {
|
||||
width: 140px;
|
||||
}
|
||||
@@ -10964,7 +11039,8 @@ header {
|
||||
.info-collect-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.info-collect-form-row .form-group {
|
||||
@@ -11106,7 +11182,7 @@ header {
|
||||
|
||||
.info-collect-presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -11123,7 +11199,7 @@ header {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 10px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
+264
-12
@@ -10,6 +10,11 @@ const infoCollectState = {
|
||||
tableBound: false
|
||||
};
|
||||
|
||||
// AI 解析(自然语言 -> FOFA)交互状态
|
||||
let fofaParseAbortController = null;
|
||||
let fofaParseSlowTimer = null;
|
||||
let fofaParseToastHandle = null;
|
||||
|
||||
// HTML转义(如果未定义)
|
||||
if (typeof escapeHtml === 'undefined') {
|
||||
function escapeHtml(text) {
|
||||
@@ -23,6 +28,7 @@ if (typeof escapeHtml === 'undefined') {
|
||||
function getFofaFormElements() {
|
||||
return {
|
||||
query: document.getElementById('fofa-query'),
|
||||
nl: document.getElementById('fofa-nl'),
|
||||
size: document.getElementById('fofa-size'),
|
||||
page: document.getElementById('fofa-page'),
|
||||
fields: document.getElementById('fofa-fields'),
|
||||
@@ -101,20 +107,35 @@ function initInfoCollectPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// 单行输入:按内容自动增高(避免默认留空白行)
|
||||
const autoGrow = () => {
|
||||
// 自然语言输入:Ctrl/Cmd+Enter 触发解析
|
||||
if (els.nl) {
|
||||
els.nl.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
parseFofaNaturalLanguage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// textarea:按内容自动增高(避免默认留空白行)
|
||||
const autoGrowTextarea = (el) => {
|
||||
if (!el) return;
|
||||
try {
|
||||
els.query.style.height = '40px';
|
||||
const max = 110;
|
||||
const h = Math.min(max, els.query.scrollHeight);
|
||||
els.query.style.height = `${h}px`;
|
||||
el.style.height = '36px';
|
||||
const max = 96;
|
||||
const h = Math.min(max, el.scrollHeight);
|
||||
el.style.height = `${h}px`;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
els.query.addEventListener('input', autoGrow);
|
||||
els.query.addEventListener('input', () => autoGrowTextarea(els.query));
|
||||
if (els.nl) els.nl.addEventListener('input', () => autoGrowTextarea(els.nl));
|
||||
// 初始化时也执行一次
|
||||
setTimeout(autoGrow, 0);
|
||||
setTimeout(() => {
|
||||
autoGrowTextarea(els.query);
|
||||
autoGrowTextarea(els.nl);
|
||||
}, 0);
|
||||
|
||||
// 绑定表格事件(事件委托,只绑定一次)
|
||||
bindFofaTableEvents();
|
||||
@@ -206,6 +227,213 @@ async function submitFofaSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function parseFofaNaturalLanguage() {
|
||||
const els = getFofaFormElements();
|
||||
const text = (els.nl?.value || '').trim();
|
||||
if (!text) {
|
||||
alert('请输入自然语言描述');
|
||||
return;
|
||||
}
|
||||
|
||||
// 二次点击:取消进行中的解析(避免“以为卡死/失败”)
|
||||
if (fofaParseAbortController) {
|
||||
try { fofaParseAbortController.abort(); } catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// 先创建 controller,避免极快的重复点击触发并发请求
|
||||
fofaParseAbortController = new AbortController();
|
||||
setFofaParseLoading(true, 'AI 解析中...');
|
||||
|
||||
// 持续提示:直到请求完成/取消/失败才消失
|
||||
fofaParseToastHandle = showInlineToast('AI 解析中...(点击按钮可取消)', { duration: 0, id: 'fofa-parse-pending' });
|
||||
|
||||
// 如果超过一小段时间还没返回,再强调“仍在进行中”,降低误判为失败的概率
|
||||
fofaParseSlowTimer = setTimeout(() => {
|
||||
const status = document.getElementById('fofa-nl-status');
|
||||
if (status) {
|
||||
status.textContent = 'AI 解析耗时较长,仍在处理中…';
|
||||
status.style.display = 'block';
|
||||
}
|
||||
}, 1800);
|
||||
|
||||
try {
|
||||
const resp = await apiFetch('/api/fofa/parse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
signal: fofaParseAbortController.signal
|
||||
});
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
throw new Error(result.error || `请求失败: ${resp.status}`);
|
||||
}
|
||||
showFofaParseModal(text, result);
|
||||
showInlineToast('AI 解析完成');
|
||||
} catch (e) {
|
||||
// AbortController 取消:不视为失败
|
||||
if (e && (e.name === 'AbortError' || String(e).includes('AbortError'))) {
|
||||
showInlineToast('已取消 AI 解析');
|
||||
return;
|
||||
}
|
||||
console.error('FOFA 自然语言解析失败:', e);
|
||||
showInlineToast('AI 解析失败:' + (e && e.message ? e.message : String(e)), { duration: 2800 });
|
||||
}
|
||||
finally {
|
||||
fofaParseAbortController = null;
|
||||
if (fofaParseSlowTimer) {
|
||||
clearTimeout(fofaParseSlowTimer);
|
||||
fofaParseSlowTimer = null;
|
||||
}
|
||||
if (fofaParseToastHandle && typeof fofaParseToastHandle.remove === 'function') {
|
||||
fofaParseToastHandle.remove();
|
||||
}
|
||||
fofaParseToastHandle = null;
|
||||
setFofaParseLoading(false, '');
|
||||
}
|
||||
}
|
||||
|
||||
function setFofaParseLoading(loading, statusText) {
|
||||
const btn = document.getElementById('fofa-nl-parse-btn');
|
||||
const status = document.getElementById('fofa-nl-status');
|
||||
if (btn) {
|
||||
if (loading) {
|
||||
if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent || 'AI 解析';
|
||||
btn.classList.add('btn-loading');
|
||||
btn.textContent = '取消解析';
|
||||
btn.title = '点击取消 AI 解析';
|
||||
btn.dataset.loading = '1';
|
||||
btn.setAttribute('aria-busy', 'true');
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.textContent = btn.dataset.originalText || 'AI 解析';
|
||||
btn.title = '将自然语言解析为 FOFA 查询语法';
|
||||
btn.disabled = false;
|
||||
delete btn.dataset.loading;
|
||||
btn.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
if (status) {
|
||||
const text = (statusText || '').trim();
|
||||
if (loading && text) {
|
||||
status.textContent = text;
|
||||
status.style.display = 'block';
|
||||
} else {
|
||||
status.textContent = '';
|
||||
status.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showFofaParseModal(nlText, parsed) {
|
||||
const existing = document.getElementById('fofa-parse-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const safeNL = escapeHtml((nlText || '').trim());
|
||||
const warnings = Array.isArray(parsed?.warnings) ? parsed.warnings.filter(Boolean).map(x => String(x)) : [];
|
||||
const explanation = parsed?.explanation != null ? String(parsed.explanation) : '';
|
||||
|
||||
const warningsHtml = warnings.length
|
||||
? `<ul style="margin: 8px 0 0 18px;">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
|
||||
: `<div class="muted" style="margin-top: 8px;">无</div>`;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'fofa-parse-modal';
|
||||
modal.className = 'modal';
|
||||
modal.style.display = 'block';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>AI 解析结果</h2>
|
||||
<span class="modal-close" id="fofa-parse-modal-close" title="关闭">×</span>
|
||||
</div>
|
||||
<div style="padding: 18px 28px; overflow: auto;">
|
||||
<div class="form-group">
|
||||
<label>自然语言</label>
|
||||
<div class="muted" style="margin-top: 6px; white-space: pre-wrap;">${safeNL || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label for="fofa-parse-query">FOFA 查询语法(可编辑)</label>
|
||||
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder='例如:app="Apache" && country="CN"'></textarea>
|
||||
<small class="form-hint">请人工确认语法与范围无误后再执行查询。</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label>提醒</label>
|
||||
<div style="background: #fff8e1; border: 1px solid #ffe8a3; border-radius: 10px; padding: 10px 12px;">
|
||||
${warningsHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${explanation ? `
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label>解析说明</label>
|
||||
<pre style="margin-top: 8px; white-space: pre-wrap; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; padding: 10px 12px; font-size: 13px;">${escapeHtml(explanation)}</pre>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 18px 28px;">
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-cancel">取消</button>
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-apply">填入查询框</button>
|
||||
<button class="btn-primary" type="button" id="fofa-parse-apply-run">填入并查询</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const queryTextarea = document.getElementById('fofa-parse-query');
|
||||
if (queryTextarea) {
|
||||
queryTextarea.value = (parsed?.query || '').trim();
|
||||
setTimeout(() => {
|
||||
try { queryTextarea.focus(); } catch (e) { /* ignore */ }
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const close = () => modal.remove();
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) close();
|
||||
});
|
||||
document.getElementById('fofa-parse-modal-close')?.addEventListener('click', close);
|
||||
document.getElementById('fofa-parse-cancel')?.addEventListener('click', close);
|
||||
|
||||
const applyToQuery = (run) => {
|
||||
const els = getFofaFormElements();
|
||||
const q = (queryTextarea?.value || '').trim();
|
||||
if (!q) {
|
||||
showInlineToast('解析结果为空:请在弹窗中补充/修改 FOFA 查询语法', { duration: 2600 });
|
||||
return;
|
||||
}
|
||||
if (els.query) {
|
||||
els.query.value = q;
|
||||
try { els.query.focus(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
// 写入表单缓存(与现有“直接查询”一致)
|
||||
saveFofaFormToStorage({
|
||||
query: q,
|
||||
size: parseInt(els.size?.value, 10) || 100,
|
||||
page: parseInt(els.page?.value, 10) || 1,
|
||||
fields: (els.fields?.value || '').trim(),
|
||||
full: !!els.full?.checked
|
||||
});
|
||||
close();
|
||||
if (run) submitFofaSearch();
|
||||
};
|
||||
|
||||
document.getElementById('fofa-parse-apply')?.addEventListener('click', () => applyToQuery(false));
|
||||
document.getElementById('fofa-parse-apply-run')?.addEventListener('click', () => applyToQuery(true));
|
||||
|
||||
// Esc 关闭
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
function setFofaMeta(text) {
|
||||
const els = getFofaFormElements();
|
||||
if (els.meta) {
|
||||
@@ -393,12 +621,35 @@ function copyFofaTargetEncoded(encodedTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
function showInlineToast(text) {
|
||||
// showInlineToast('xxx');也支持 showInlineToast('xxx', { duration: 0, id: '...' })
|
||||
function showInlineToast(text, options) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
const duration = typeof opts.duration === 'number' ? opts.duration : 1200;
|
||||
const id = typeof opts.id === 'string' && opts.id.trim() ? opts.id.trim() : '';
|
||||
const replace = opts.replace !== false;
|
||||
|
||||
if (id && replace) {
|
||||
document.getElementById(id)?.remove();
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = text;
|
||||
toast.style.cssText = 'position: fixed; top: 24px; right: 24px; background: rgba(0,0,0,0.85); color: #fff; padding: 10px 12px; border-radius: 8px; z-index: 10000; font-size: 13px;';
|
||||
if (id) toast.id = id;
|
||||
toast.textContent = String(text == null ? '' : text);
|
||||
toast.style.cssText = 'position: fixed; top: 24px; right: 24px; background: rgba(0,0,0,0.85); color: #fff; padding: 10px 12px; border-radius: 8px; z-index: 10000; font-size: 13px; max-width: 420px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,0.22);';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 1200);
|
||||
|
||||
let timer = null;
|
||||
const remove = () => {
|
||||
try { if (timer) clearTimeout(timer); } catch (e) { /* ignore */ }
|
||||
timer = null;
|
||||
try { toast.remove(); } catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
if (duration > 0) {
|
||||
timer = setTimeout(remove, duration);
|
||||
}
|
||||
|
||||
return { el: toast, remove };
|
||||
}
|
||||
|
||||
function scanFofaRow(encodedRowJson, clickEvent) {
|
||||
@@ -787,6 +1038,7 @@ function showCellDetailModal(field, fullText) {
|
||||
window.initInfoCollectPage = initInfoCollectPage;
|
||||
window.resetFofaForm = resetFofaForm;
|
||||
window.submitFofaSearch = submitFofaSearch;
|
||||
window.parseFofaNaturalLanguage = parseFofaNaturalLanguage;
|
||||
window.scanFofaRow = scanFofaRow;
|
||||
window.copyFofaTarget = copyFofaTarget;
|
||||
window.copyFofaTargetEncoded = copyFofaTargetEncoded;
|
||||
|
||||
@@ -756,6 +756,15 @@
|
||||
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('ip="1.1.1.1"')" title="填入示例">指定 IP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fofa-nl">自然语言(AI 解析为 FOFA 语法)</label>
|
||||
<div class="info-collect-nl-row">
|
||||
<textarea id="fofa-nl" class="info-collect-query-input" rows="1" placeholder="例如:找美国 Missouri 的 Apache 站点,标题包含 Home"></textarea>
|
||||
<button id="fofa-nl-parse-btn" class="btn-secondary" type="button" onclick="parseFofaNaturalLanguage()" title="将自然语言解析为 FOFA 查询语法">AI 解析</button>
|
||||
</div>
|
||||
<div id="fofa-nl-status" class="fofa-nl-status muted" style="display: none;" aria-live="polite"></div>
|
||||
<small class="form-hint">解析后会弹窗展示 FOFA 语法(可编辑),确认无误后再填入查询框并执行查询。</small>
|
||||
</div>
|
||||
<div class="info-collect-form-row">
|
||||
<div class="form-group">
|
||||
<label for="fofa-size">返回数量</label>
|
||||
|
||||
Reference in New Issue
Block a user