Files
CyberStrikeAI/web/static/js/info-collect.js
T
2026-07-20 16:49:19 +08:00

1745 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 信息收集页面(FOFA
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
const FOFA_FORM_STORAGE_KEY = 'info-collect-fofa-form';
const FOFA_HIDDEN_FIELDS_STORAGE_KEY = 'info-collect-fofa-hidden-fields';
const INFO_COLLECT_PROVIDERS = {
fofa: {
label: 'FOFA',
placeholder: '例如:app="Apache" && country="CN"',
nlPlaceholder: '例如:找美国 Missouri 的 Apache 站点,标题包含 Home',
hint: '查询语法参考 FOFA 文档,支持 && / || / () 等。',
parseHint: '解析后会弹窗展示 FOFA 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'FOFA 返回数量上限与账号权限相关,前端最多允许 10000。',
fullOption: {
label: '完整模式',
hint: '向 FOFA 传 full=true,返回更完整/更实时的数据,可能消耗更多额度。'
},
fields: 'host,ip,port,domain,title,protocol,country,province,city,server',
presets: [
['Apache + 中国', 'app="Apache" && country="CN"'],
['登录页 + 中国', 'title="登录" && country="CN"'],
['指定域名', 'domain="example.com"'],
['指定 IP', 'ip="1.1.1.1"']
],
fieldPresets: [
['最小字段', 'host,ip,port,domain'],
['Web 常用', 'host,title,ip,port,domain,protocol,server,icp,country,province,city'],
['情报增强', 'host,ip,port,domain,title,protocol,country,province,city,server,as_number,as_organization,icp,header,banner']
],
syntaxGuide: {
summary: 'FOFA 使用 field="value" 精确匹配,支持 &&、||、! 和括号组合;字符串建议用双引号包裹。',
docsUrl: 'https://en.fofa.info/api',
sections: [
['常用字段', ['app="Apache"', 'title="后台管理"', 'body="Powered by"', 'domain="example.com"', 'host="https://example.com"', 'ip="1.1.1.1"', 'port="443"', 'country="CN"', 'city="Hangzhou"', 'server="nginx"']],
['组合写法', ['app="nginx" && country="CN"', 'title="login" || title="登录"', '(app="Apache" || app="nginx") && port="443"', 'domain="example.com" && !title="404"']],
['场景示例', ['cert="example.com" && port="443"', 'header="JSESSIONID" && country="CN"', 'icon_hash="-247388890"', 'fid="sZyXkR9e" && domain="example.com"']]
]
}
},
zoomeye: {
label: 'ZoomEye',
placeholder: '例如:app="Apache" && country="CN"',
nlPlaceholder: '例如:找中国的 SSH 服务,排除蜜罐',
hint: 'ZoomEye 支持 app/title/domain/ip/port/country/city 等语法。',
parseHint: '解析后会弹窗展示 ZoomEye 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'ZoomEye pagesize 最高支持到 10000,实际额度以账号为准。',
fullOption: null,
fields: 'ip,port,domain,hostname,title,service,app,country,city',
presets: [
['Apache + 中国', 'app="Apache" && country="CN"'],
['SSH 服务', 'service="ssh"'],
['指定域名', 'domain="example.com"'],
['指定 IP', 'ip="1.1.1.1"']
],
fieldPresets: [
['最小字段', 'ip,port,domain,hostname'],
['Web 常用', 'ip,port,domain,hostname,title,service,app,country,city'],
['情报增强', 'ip,port,domain,hostname,title,service,app,country,city,org,isp,ssl']
],
syntaxGuide: {
summary: 'ZoomEye 支持字段检索、引号短语、AND/OR/NOT 与括号组合;字段名以官方控制台实际支持为准。',
docsUrl: 'https://www.zoomeye.ai/help',
sections: [
['常用字段', ['app="Apache"', 'service="ssh"', 'title="登录"', 'domain="example.com"', 'hostname="example.com"', 'ip="1.1.1.1"', 'port=443', 'country="CN"', 'city="Beijing"', 'org="Tencent"']],
['组合写法', ['app="nginx" AND country="CN"', 'service="http" AND (title="login" OR title="登录")', 'domain="example.com" AND NOT app="cloudflare"', 'port=443 AND country="US"']],
['场景示例', ['ssl.cert.fingerprint="SHA256值"', 'iconhash="-247388890"', 'service="rdp" AND country="CN"', 'app="Elasticsearch" AND port=9200']]
]
}
},
quake: {
label: 'Quake',
placeholder: '例如:service.name:"http" AND country_cn:"中国"',
nlPlaceholder: '例如:找中国的 HTTP 服务,标题包含登录',
hint: 'Quake 使用 DSL 语法,常见字段如 service.name、domain、ip、port、country_cn。',
parseHint: '解析后会弹窗展示 Quake DSL(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'Quake size 会消耗积分,建议按需控制返回数量。',
fullOption: {
label: '最新数据',
hint: '向 Quake 传 latest=true,优先查询最新数据。'
},
fields: 'ip,port,domain,service.name,service.http.title,location.country_cn,location.province_cn,location.city_cn',
presets: [
['HTTP + 中国', 'service.name:"http" AND country_cn:"中国"'],
['443 端口', 'port:443'],
['指定域名', 'domain:"example.com"'],
['指定 IP', 'ip:"1.1.1.1"']
],
fieldPresets: [
['最小字段', 'ip,port,domain'],
['Web 常用', 'ip,port,domain,service.name,service.http.title,location.country_cn,location.city_cn'],
['情报增强', 'ip,port,domain,service.name,service.http.title,service.http.server,location.country_cn,location.province_cn,location.city_cn,asn']
],
syntaxGuide: {
summary: 'Quake 使用 Lucene/DSL 风格查询,常见形式是 field:"value",逻辑运算符通常使用 AND、OR、NOT。',
docsUrl: 'https://quake.360.net/quake/#/help',
sections: [
['常用字段', ['service.name:"http"', 'service.http.title:"登录"', 'service.http.server:"nginx"', 'domain:"example.com"', 'ip:"1.1.1.1"', 'port:443', 'country_cn:"中国"', 'province_cn:"浙江"', 'city_cn:"杭州"']],
['组合写法', ['service.name:"http" AND country_cn:"中国"', '(service.name:"http" OR service.name:"https") AND port:443', 'domain:"example.com" AND NOT service.http.title:"404"', 'service.http.title:"login" AND port:443']],
['场景示例', ['service.http.favicon.hash:"-247388890"', 'service.http.response.header:"JSESSIONID"', 'service.name:"ssh" AND country_cn:"中国"', 'service.http.title:"Dashboard" AND NOT ip:"127.0.0.1"']]
]
}
},
shodan: {
label: 'Shodan',
placeholder: '例如:product:nginx country:CN',
nlPlaceholder: '例如:找中国的 nginx 资产,端口 443',
hint: 'Shodan 使用 filter:value 语法,常见字段如 product、port、country、org。',
parseHint: '解析后会弹窗展示 Shodan filter 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 1000,
sizeHint: 'Shodan 官方每页 100 条;后端会自动翻页聚合,单次最多 1000 条以控制额度消耗。',
fullOption: null,
fields: 'ip_str,port,hostnames,domains,org,isp,location.country_name,location.city,product,transport',
presets: [
['Nginx + 中国', 'product:nginx country:CN'],
['SSH 服务', 'port:22'],
['证书域名', 'ssl.cert.subject.cn:example.com'],
['Amazon 443', 'org:"Amazon" port:443']
],
fieldPresets: [
['最小字段', 'ip_str,port,hostnames,domains'],
['Web 常用', 'ip_str,port,hostnames,domains,product,org,location.country_name,location.city'],
['情报增强', 'ip_str,port,hostnames,domains,org,isp,asn,location.country_name,location.city,product,transport,ssl.cert.subject.cn']
],
syntaxGuide: {
summary: 'Shodan 默认搜索 banner data;精确条件使用 filter:value,值含空格时用双引号,多个过滤器并列表示收窄结果。',
docsUrl: 'https://help.shodan.io/the-basics/search-query-fundamentals',
sections: [
['常用过滤器', ['product:nginx', 'port:443', 'country:CN', 'city:Shanghai', 'org:"Amazon"', 'asn:AS15169', 'hostname:example.com', 'ssl.cert.subject.cn:example.com', 'http.title:"Dashboard"']],
['组合写法', ['product:nginx country:CN', 'apache port:443 country:DE', 'org:"Amazon" port:443', 'ssl.cert.subject.cn:example.com port:443']],
['场景示例', ['http.title:"login" country:CN', 'ssl:true port:443 hostname:example.com', 'vuln:CVE-2021-41773', 'has_screenshot:true product:nginx']]
]
}
}
};
const infoCollectState = {
currentPayload: null, // { fields, results, query, total, page, size }
hiddenFields: new Set(),
selectedRowIndexes: new Set(),
tableBound: false,
providerSelectBound: false,
presetEventsBound: false,
syntaxGuideExpanded: false,
queryHeightFrame: null,
queryHeightResizeBound: false
};
// AI 解析(自然语言 -> FOFA)交互状态
let fofaParseAbortController = null;
let fofaParseSlowTimer = null;
let fofaParseToastHandle = null;
// HTML转义(如果未定义)
if (typeof escapeHtml === 'undefined') {
function escapeHtml(text) {
if (text == null) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
}
function getFofaFormElements() {
return {
query: document.getElementById('fofa-query'),
provider: document.getElementById('fofa-provider'),
nl: document.getElementById('fofa-nl'),
size: document.getElementById('fofa-size'),
page: document.getElementById('fofa-page'),
fields: document.getElementById('fofa-fields'),
full: document.getElementById('fofa-full'),
meta: document.getElementById('fofa-results-meta'),
selectedMeta: document.getElementById('fofa-selected-meta'),
thead: document.getElementById('fofa-results-thead'),
tbody: document.getElementById('fofa-results-tbody'),
columnsPanel: document.getElementById('fofa-columns-panel'),
columnsList: document.getElementById('fofa-columns-list')
};
}
function getInfoCollectProvider() {
const provider = (document.getElementById('fofa-provider')?.value || 'fofa').trim().toLowerCase();
return INFO_COLLECT_PROVIDERS[provider] ? provider : 'fofa';
}
function providerLabel(provider) {
return (INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa).label;
}
function getInfoCollectFullOption(provider) {
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
return cfg.fullOption || null;
}
function isInfoCollectFullEnabled(provider) {
const els = getFofaFormElements();
return !!(getInfoCollectFullOption(provider) && els.full && els.full.checked);
}
function loadHiddenFieldsFromStorage() {
try {
const raw = localStorage.getItem(FOFA_HIDDEN_FIELDS_STORAGE_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr.filter(x => typeof x === 'string');
} catch (e) {
return [];
}
}
function saveHiddenFieldsToStorage() {
try {
localStorage.setItem(FOFA_HIDDEN_FIELDS_STORAGE_KEY, JSON.stringify(Array.from(infoCollectState.hiddenFields)));
} catch (e) {
// ignore
}
}
function loadFofaFormFromStorage() {
try {
const raw = localStorage.getItem(FOFA_FORM_STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (!data || typeof data !== 'object') return null;
return data;
} catch (e) {
return null;
}
}
function saveFofaFormToStorage(payload) {
try {
localStorage.setItem(FOFA_FORM_STORAGE_KEY, JSON.stringify(payload));
} catch (e) {
// ignore
}
}
function initInfoCollectPage() {
const els = getFofaFormElements();
if (!els.query || !els.size || !els.fields || !els.tbody) return;
// 恢复隐藏字段
infoCollectState.hiddenFields = new Set(loadHiddenFieldsFromStorage());
// 恢复上次输入
const saved = loadFofaFormFromStorage();
let shouldResetProviderFields = false;
if (saved) {
if (typeof saved.provider === 'string' && els.provider && INFO_COLLECT_PROVIDERS[saved.provider]) els.provider.value = saved.provider;
if (typeof saved.query === 'string') els.query.value = saved.query;
if (typeof saved.size === 'number' || typeof saved.size === 'string') els.size.value = saved.size;
if (typeof saved.page === 'number' || typeof saved.page === 'string') els.page.value = saved.page;
if (typeof saved.fields === 'string') els.fields.value = saved.fields;
if (typeof saved.full === 'boolean') els.full.checked = saved.full;
const provider = getInfoCollectProvider();
const savedFields = String(saved.fields || '').trim();
shouldResetProviderFields = provider !== 'fofa' && (
savedFields === INFO_COLLECT_PROVIDERS.fofa.fields ||
savedFields === 'host,ip,port,domain'
);
}
initInfoCollectProviderSelect();
bindInfoCollectPresetEvents();
refreshInfoCollectProviderUI(shouldResetProviderFields);
// 绑定 Enter 快捷查询(在 query 里用 Ctrl/Cmd+Enter
els.query.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
submitFofaSearch();
}
});
// 自然语言输入: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 {
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', () => autoGrowTextarea(els.query));
if (els.nl) els.nl.addEventListener('input', () => autoGrowTextarea(els.nl));
// 初始化时也执行一次
setTimeout(() => {
autoGrowTextarea(els.query);
autoGrowTextarea(els.nl);
}, 0);
setInfoCollectQueryMode('syntax', { focus: false });
if (!infoCollectState.queryHeightResizeBound) {
infoCollectState.queryHeightResizeBound = true;
window.addEventListener('resize', scheduleInfoCollectQueryCardHeightStabilize);
}
// 绑定表格事件(事件委托,只绑定一次)
bindFofaTableEvents();
updateSelectedMeta();
}
function handleInfoCollectProviderChange() {
infoCollectState.syntaxGuideExpanded = false;
refreshInfoCollectProviderUI(true);
}
function setInfoCollectQueryMode(mode, options) {
const shouldFocus = options?.focus !== false;
const syntaxPanel = document.getElementById('info-collect-syntax-panel');
const naturalPanel = document.getElementById('info-collect-natural-panel');
if (syntaxPanel) {
syntaxPanel.hidden = false;
syntaxPanel.classList.add('is-active');
syntaxPanel.classList.add('is-generated-target');
}
if (naturalPanel) {
naturalPanel.hidden = false;
naturalPanel.classList.add('is-active');
}
const queryLabel = document.getElementById('info-collect-query-label');
const cfg = INFO_COLLECT_PROVIDERS[getInfoCollectProvider()] || INFO_COLLECT_PROVIDERS.fofa;
if (queryLabel) {
queryLabel.textContent = cfg.label + ' 查询语法(可编辑,可直接查询)';
}
const nlLabel = document.getElementById('info-collect-nl-label');
if (nlLabel) {
nlLabel.textContent = '自然语言(可选,AI 解析为 ' + cfg.label + ' 语法)';
}
if (shouldFocus) {
const focusTarget = mode === 'natural' ? document.getElementById('fofa-nl') : document.getElementById('fofa-query');
try { focusTarget?.focus(); } catch (e) { /* ignore */ }
}
scheduleInfoCollectQueryCardHeightStabilize();
}
function scheduleInfoCollectQueryCardHeightStabilize() {
if (infoCollectState.queryHeightFrame) {
cancelAnimationFrame(infoCollectState.queryHeightFrame);
}
infoCollectState.queryHeightFrame = requestAnimationFrame(() => {
infoCollectState.queryHeightFrame = null;
stabilizeInfoCollectQueryCardHeight();
});
}
function stabilizeInfoCollectQueryCardHeight() {
const card = document.querySelector('.info-collect-query-card');
if (!card) return;
const rect = card.getBoundingClientRect();
if (!rect.width) return;
const clone = card.cloneNode(true);
clone.style.position = 'absolute';
clone.style.visibility = 'hidden';
clone.style.pointerEvents = 'none';
clone.style.left = '-10000px';
clone.style.top = '0';
clone.style.width = rect.width + 'px';
clone.style.height = 'auto';
clone.style.minHeight = '0';
clone.style.maxHeight = 'none';
const naturalPanel = clone.querySelector('#info-collect-natural-panel');
if (naturalPanel) {
naturalPanel.hidden = false;
naturalPanel.classList.add('is-active');
}
const syntaxPanel = clone.querySelector('#info-collect-syntax-panel');
if (syntaxPanel) {
syntaxPanel.hidden = false;
syntaxPanel.classList.add('is-active', 'is-generated-target');
}
const queryLabel = clone.querySelector('#info-collect-query-label');
if (queryLabel) {
const cfg = INFO_COLLECT_PROVIDERS[getInfoCollectProvider()] || INFO_COLLECT_PROVIDERS.fofa;
queryLabel.textContent = cfg.label + ' 查询语法(可编辑,可直接查询)';
}
document.body.appendChild(clone);
const stableHeight = Math.ceil(clone.getBoundingClientRect().height);
clone.remove();
if (stableHeight > 0) {
card.style.minHeight = stableHeight + 'px';
}
}
function presetDataAttr(value) {
return escapeHtml(String(value == null ? '' : value))
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function bindInfoCollectPresetEvents() {
if (infoCollectState.presetEventsBound) return;
infoCollectState.presetEventsBound = true;
document.addEventListener('click', (event) => {
const queryBtn = event.target.closest?.('[data-info-query-preset]');
if (queryBtn) {
event.preventDefault();
applyFofaQueryPreset(queryBtn.getAttribute('data-info-query-preset') || '');
return;
}
const fieldsBtn = event.target.closest?.('[data-info-fields-preset]');
if (fieldsBtn) {
event.preventDefault();
applyFofaFieldsPreset(fieldsBtn.getAttribute('data-info-fields-preset') || '');
return;
}
const guideToggle = event.target.closest?.('[data-info-syntax-guide-toggle]');
if (guideToggle) {
event.preventDefault();
toggleInfoCollectSyntaxGuide();
}
});
}
function initInfoCollectProviderSelect() {
const els = getFofaFormElements();
const select = els.provider;
if (!select || infoCollectState.providerSelectBound) {
syncInfoCollectProviderSelect();
return;
}
infoCollectState.providerSelectBound = true;
select.classList.add('settings-native-select');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'settings-custom-select info-collect-provider-select';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'settings-custom-select-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const value = document.createElement('span');
value.className = 'settings-custom-select-value';
value.id = 'info-collect-provider-select-value';
const caret = document.createElement('span');
caret.className = 'settings-custom-select-caret';
caret.setAttribute('aria-hidden', 'true');
caret.textContent = '▾';
const menu = document.createElement('div');
menu.className = 'settings-custom-select-menu';
menu.id = 'info-collect-provider-select-menu';
menu.setAttribute('role', 'listbox');
trigger.appendChild(value);
trigger.appendChild(caret);
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(menu);
wrapper.appendChild(select);
trigger.addEventListener('click', (event) => {
event.stopPropagation();
const willOpen = !wrapper.classList.contains('open');
closeInfoCollectProviderSelect();
wrapper.classList.toggle('open', willOpen);
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
});
trigger.addEventListener('keydown', (event) => {
const options = Array.prototype.filter.call(select.options, (option) => !option.disabled);
if (!options.length) return;
const current = Math.max(0, options.indexOf(select.options[select.selectedIndex]));
let next = current;
if (event.key === 'ArrowDown') next = Math.min(options.length - 1, current + 1);
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1);
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = options.length - 1;
else if (event.key === 'Escape') {
closeInfoCollectProviderSelect();
return;
} else if (event.key === 'Enter' || event.key === ' ') {
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
event.preventDefault();
return;
} else {
return;
}
event.preventDefault();
const nextOption = options[next];
if (nextOption && select.value !== nextOption.value) {
select.value = nextOption.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
syncInfoCollectProviderSelect();
});
menu.addEventListener('click', (event) => {
const item = event.target.closest('.settings-custom-select-option');
if (!item || item.disabled) return;
event.stopPropagation();
const option = select.options[Number(item.dataset.index)];
if (option && !option.disabled && select.value !== option.value) {
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
syncInfoCollectProviderSelect();
closeInfoCollectProviderSelect();
});
select.addEventListener('change', syncInfoCollectProviderSelect);
document.addEventListener('click', closeInfoCollectProviderSelect);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeInfoCollectProviderSelect();
});
syncInfoCollectProviderSelect();
}
function closeInfoCollectProviderSelect() {
const wrapper = document.querySelector('.info-collect-provider-select');
const trigger = wrapper?.querySelector('.settings-custom-select-trigger');
if (!wrapper) return;
wrapper.classList.remove('open');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
}
function syncInfoCollectProviderSelect() {
const select = document.getElementById('fofa-provider');
const wrapper = document.querySelector('.info-collect-provider-select');
if (!select || !wrapper) return;
const value = wrapper.querySelector('.settings-custom-select-value');
const menu = wrapper.querySelector('.settings-custom-select-menu');
const selected = select.options[select.selectedIndex];
if (value) value.textContent = selected ? selected.textContent : '';
if (!menu) return;
menu.innerHTML = '';
Array.prototype.forEach.call(select.options, (option, index) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'settings-custom-select-option';
item.setAttribute('role', 'option');
item.setAttribute('data-index', String(index));
item.setAttribute('aria-selected', option.selected ? 'true' : 'false');
item.classList.toggle('is-selected', option.selected);
item.disabled = !!option.disabled;
const check = document.createElement('span');
check.className = 'settings-custom-select-check';
check.setAttribute('aria-hidden', 'true');
check.textContent = '✓';
const label = document.createElement('span');
label.className = 'settings-custom-select-label';
label.textContent = option.textContent;
item.appendChild(check);
item.appendChild(label);
menu.appendChild(item);
});
}
function renderInfoCollectSyntaxGuide(cfg) {
const container = document.getElementById('info-collect-syntax-guide');
if (!container) return;
const guide = cfg.syntaxGuide;
if (!guide) {
container.innerHTML = '';
container.hidden = true;
return;
}
const docsLink = guide.docsUrl
? `<a class="info-collect-doc-link" href="${presetDataAttr(guide.docsUrl)}" target="_blank" rel="noopener noreferrer">官方文档</a>`
: '';
const expanded = !!infoCollectState.syntaxGuideExpanded;
const sections = (guide.sections || []).map(([title, examples]) => {
const chips = (examples || []).map(example => {
return `<button class="syntax-example-chip" type="button" data-info-query-preset="${presetDataAttr(example)}" title="填入查询框">${escapeHtml(example)}</button>`;
}).join('');
return `<div class="syntax-guide-section"><div class="syntax-guide-title">${escapeHtml(title)}</div><div class="syntax-guide-examples">${chips}</div></div>`;
}).join('');
container.hidden = false;
container.classList.toggle('is-expanded', expanded);
container.innerHTML = `
<div class="syntax-guide-header">
<div class="syntax-guide-summary">${escapeHtml(guide.summary || '')}</div>
<div class="syntax-guide-actions">
${docsLink}
<button class="syntax-guide-toggle" type="button" data-info-syntax-guide-toggle aria-expanded="${expanded ? 'true' : 'false'}">${expanded ? '收起示例' : '展开示例'}</button>
</div>
</div>
<div class="syntax-guide-body"${expanded ? '' : ' hidden'}>${sections}</div>
`;
}
function toggleInfoCollectSyntaxGuide() {
infoCollectState.syntaxGuideExpanded = !infoCollectState.syntaxGuideExpanded;
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
renderInfoCollectSyntaxGuide(cfg);
scheduleInfoCollectQueryCardHeightStabilize();
}
function refreshInfoCollectProviderUI(resetProviderFields) {
const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
const queryLabel = document.getElementById('info-collect-query-label');
const nlLabel = document.getElementById('info-collect-nl-label');
const queryHint = document.getElementById('info-collect-query-hint');
const parseHint = document.getElementById('info-collect-parse-hint');
const sizeHint = document.getElementById('info-collect-size-hint');
const parseBtn = document.getElementById('fofa-nl-parse-btn');
const presets = document.getElementById('info-collect-query-presets');
const fieldPresets = document.getElementById('info-collect-fields-presets');
const fullOption = document.getElementById('info-collect-full-option');
const fullText = fullOption ? fullOption.querySelector('.checkbox-text') : null;
const fullConfig = getInfoCollectFullOption(provider);
if (queryLabel) queryLabel.textContent = cfg.label + ' 查询语法';
if (nlLabel) nlLabel.textContent = '自然语言(AI 解析为 ' + cfg.label + ' 语法)';
if (queryHint) queryHint.textContent = cfg.hint;
if (parseHint) parseHint.textContent = cfg.parseHint;
if (sizeHint) sizeHint.textContent = cfg.sizeHint;
if (parseBtn && parseBtn.dataset.loading !== '1') parseBtn.title = '将自然语言解析为 ' + cfg.label + ' 查询语法';
if (els.query) els.query.placeholder = cfg.placeholder;
if (els.nl) els.nl.placeholder = cfg.nlPlaceholder;
if (els.size) {
els.size.max = String(cfg.maxSize || 10000);
const currentSize = parseInt(els.size.value, 10) || 100;
if (cfg.maxSize && currentSize > cfg.maxSize) els.size.value = cfg.maxSize;
}
if (fullOption) {
if (fullConfig) {
fullOption.hidden = false;
fullOption.title = fullConfig.hint || '';
if (fullText) fullText.textContent = fullConfig.label || _t('infoCollectPage.fullLabel');
} else {
fullOption.hidden = true;
fullOption.title = '';
if (els.full) els.full.checked = false;
}
}
if (els.fields && (resetProviderFields || !els.fields.value.trim())) els.fields.value = cfg.fields;
if (presets) {
presets.innerHTML = cfg.presets.map(([label, query]) => {
return `<button class="preset-chip" type="button" data-info-query-preset="${presetDataAttr(query)}" title="填入示例">${escapeHtml(label)}</button>`;
}).join('');
}
if (fieldPresets) {
fieldPresets.innerHTML = cfg.fieldPresets.map(([label, fields]) => {
return `<button class="preset-chip" type="button" data-info-fields-preset="${presetDataAttr(fields)}" title="填入字段模板">${escapeHtml(label)}</button>`;
}).join('');
}
renderInfoCollectSyntaxGuide(cfg);
saveFofaFormToStorage({
provider,
query: (els.query?.value || '').trim(),
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '',
full: isInfoCollectFullEnabled(provider)
});
setInfoCollectQueryMode('syntax', { focus: false });
scheduleInfoCollectQueryCardHeightStabilize();
}
function applyFofaQueryPreset(preset) {
const els = getFofaFormElements();
if (!els.query) return;
setInfoCollectQueryMode('syntax');
els.query.value = (preset || '').trim();
els.query.focus();
saveFofaFormToStorage({
provider: getInfoCollectProvider(),
query: els.query.value,
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '',
full: isInfoCollectFullEnabled(getInfoCollectProvider())
});
}
function applyFofaFieldsPreset(preset) {
const els = getFofaFormElements();
if (!els.fields) return;
els.fields.value = (preset || '').trim();
els.fields.focus();
saveFofaFormToStorage({
provider: getInfoCollectProvider(),
query: (els.query?.value || '').trim(),
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields.value,
full: isInfoCollectFullEnabled(getInfoCollectProvider())
});
}
function resetFofaForm() {
const els = getFofaFormElements();
if (!els.query) return;
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
els.query.value = '';
if (els.size) els.size.value = 100;
if (els.page) els.page.value = 1;
if (els.fields) els.fields.value = cfg.fields;
if (els.full) els.full.checked = false;
if (els.nl) els.nl.value = '';
setInfoCollectQueryMode('syntax');
saveFofaFormToStorage({
provider,
query: els.query.value,
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '',
full: isInfoCollectFullEnabled(provider)
});
renderFofaResults({ query: '', fields: [], results: [], total: 0, page: 1, size: 0 });
}
async function submitFofaSearch() {
const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const query = (els.query?.value || '').trim();
const providerCfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
const maxSize = providerCfg.maxSize || 10000;
let size = parseInt(els.size?.value, 10) || 100;
if (size > maxSize) {
size = maxSize;
if (els.size) els.size.value = String(maxSize);
showInlineToast(providerCfg.label + ' 单次最多返回 ' + maxSize + ' 条,已自动调整。');
}
const page = parseInt(els.page?.value, 10) || 1;
const fields = (els.fields?.value || '').trim();
const full = isInfoCollectFullEnabled(provider);
if (!query) {
alert(_t('infoCollect.enterFofaQuery'));
return;
}
saveFofaFormToStorage({ provider, query, size, page, fields, full });
setFofaMeta(providerLabel(provider) + ' ' + _t('infoCollect.querying'));
setFofaLoading(true);
try {
const response = await apiFetch('/api/fofa/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, query, size, page, fields, full })
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || `请求失败: ${response.status}`);
}
renderFofaResults(result);
} catch (e) {
console.error(providerLabel(provider) + ' 查询失败:', e);
setFofaMeta(_t('infoCollect.queryFailed'));
renderFofaResults({ provider, query, fields: [], results: [], total: 0, page: 1, size: 0 });
alert(_t('infoCollect.queryFailed') + ': ' + (e && e.message ? e.message : String(e)));
} finally {
setFofaLoading(false);
}
}
async function parseFofaNaturalLanguage() {
const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const text = (els.nl?.value || '').trim();
if (!text) {
alert(_t('infoCollect.enterNaturalLanguage'));
return;
}
// 二次点击:取消进行中的解析(避免“以为卡死/失败”)
if (fofaParseAbortController) {
try { fofaParseAbortController.abort(); } catch (e) { /* ignore */ }
return;
}
// 先创建 controller,避免极快的重复点击触发并发请求
fofaParseAbortController = new AbortController();
setFofaParseLoading(true, _t('infoCollect.parsePending'));
// 持续提示:直到请求完成/取消/失败才消失
fofaParseToastHandle = showInlineToast(_t('infoCollect.parsePendingClickCancel'), { duration: 0, id: 'fofa-parse-pending' });
// 如果超过一小段时间还没返回,再强调“仍在进行中”,降低误判为失败的概率
fofaParseSlowTimer = setTimeout(() => {
const status = document.getElementById('fofa-nl-status');
if (status) {
status.textContent = _t('infoCollect.parseSlow');
status.style.display = 'block';
}
}, 1800);
try {
const resp = await apiFetch('/api/fofa/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, text }),
signal: fofaParseAbortController.signal
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(result.error || `请求失败: ${resp.status}`);
}
showFofaParseModal(text, result);
showInlineToast(_t('infoCollect.parseDone'));
} catch (e) {
// AbortController 取消:不视为失败
if (e && (e.name === 'AbortError' || String(e).includes('AbortError'))) {
showInlineToast(_t('infoCollect.parseCancelled'));
return;
}
console.error('FOFA 自然语言解析失败:', e);
showInlineToast(_t('infoCollect.parseFailed') + (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 || _t('infoCollectPage.parseBtn');
btn.classList.add('btn-loading');
btn.textContent = _t('infoCollect.cancelParse');
btn.title = _t('infoCollect.clickToCancelParse');
btn.dataset.loading = '1';
btn.setAttribute('aria-busy', 'true');
btn.disabled = false;
} else {
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
btn.classList.remove('btn-loading');
btn.textContent = btn.dataset.originalText || _t('infoCollectPage.parseBtn');
btn.title = '将自然语言解析为 ' + cfg.label + ' 查询语法';
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 provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
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 class="info-collect-parse-warnings-list">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
: '<div class="muted info-collect-parse-warnings-empty">' + _t('infoCollect.none') + '</div>';
const modal = document.createElement('div');
modal.id = 'fofa-parse-modal';
modal.className = 'modal';
document.body.appendChild(modal);
openAppModal(modal, { focus: false });
deferModalContent(function () {
modal.innerHTML = `
<div class="modal-content info-collect-parse-modal-content" style="max-width: 900px;">
<div class="modal-header">
<h2>${_t('infoCollect.parseResultTitle')}</h2>
<span class="modal-close" id="fofa-parse-modal-close" title="${_t('common.close')}">&times;</span>
</div>
<div class="info-collect-parse-modal-body">
<div class="form-group">
<label>${_t('infoCollect.naturalLanguageLabel')}</label>
<div class="muted info-collect-parse-nl-text">${safeNL || '-'}</div>
</div>
<div class="form-group info-collect-parse-form-group">
<label for="fofa-parse-query">${escapeHtml(cfg.label)} 查询语法(可编辑)</label>
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${escapeHtml(cfg.placeholder)}"></textarea>
<small class="form-hint">${_t('infoCollect.confirmBeforeQuery')}</small>
</div>
<div class="form-group info-collect-parse-form-group">
<label>${_t('infoCollect.reminder')}</label>
<div class="info-collect-parse-warnings">
${warningsHtml}
</div>
</div>
${explanation ? `
<div class="form-group info-collect-parse-form-group">
<label>${_t('infoCollect.explanation')}</label>
<pre class="info-collect-parse-explanation">${escapeHtml(explanation)}</pre>
</div>` : ''}
</div>
<div class="modal-footer info-collect-parse-modal-footer">
<button class="btn-secondary" type="button" id="fofa-parse-cancel">${_t('infoCollect.parseModalCancel')}</button>
<button class="btn-secondary" type="button" id="fofa-parse-apply">${_t('infoCollect.parseModalApply')}</button>
<button class="btn-primary" type="button" id="fofa-parse-apply-run">${_t('infoCollect.parseModalApplyRun')}</button>
</div>
</div>
`;
const queryTextarea = document.getElementById('fofa-parse-query');
if (queryTextarea) {
queryTextarea.value = (parsed?.query || '').trim();
queryTextarea.focus();
}
const close = function () {
closeAppModal(modal);
modal.remove();
syncAppModalBodyLock();
};
modal.addEventListener('click', function (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 = function (run) {
const els = getFofaFormElements();
const q = (queryTextarea?.value || '').trim();
if (!q) {
showInlineToast(_t('infoCollect.parseResultEmpty'), { 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: isInfoCollectFullEnabled(getInfoCollectProvider())
});
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) {
els.meta.textContent = text || '-';
}
}
function buildInfoCollectResultsMeta(provider, total, count, page, size, expectedCount, shortfall) {
let text = providerLabel(provider) + ' · ' + _t('infoCollect.resultsMeta', { total, count, page, size });
if (provider === 'shodan') {
let expected = Number(expectedCount || 0);
if (!Number.isFinite(expected) || expected <= 0) {
const startOffset = Math.max(0, (Number(page) || 1) - 1) * 100;
expected = Math.min(Number(size) || 0, Math.max(0, (Number(total) || 0) - startOffset));
}
const missing = Number(shortfall || 0);
if (expected > 0 && (missing > 0 || count < expected)) {
text += ' · ' + _t('infoCollect.providerReturnedFewer', { expected, count });
}
}
return text;
}
function updateSelectedMeta() {
const els = getFofaFormElements();
if (els.selectedMeta) {
els.selectedMeta.textContent = _t('infoCollectPage.selectedRows', { count: infoCollectState.selectedRowIndexes.size });
}
}
function setFofaLoading(loading) {
const els = getFofaFormElements();
if (!els.tbody) return;
if (loading) {
const fieldsCount = (document.getElementById('fofa-fields')?.value || '').split(',').filter(Boolean).length;
const colspan = Math.max(1, fieldsCount + 1);
els.tbody.innerHTML = '<tr><td class="muted" style="padding: 16px;" colspan="' + colspan + '">' + escapeHtml(_t('infoCollect.loading')) + '</td></tr>';
}
}
function renderFofaResults(payload) {
const els = getFofaFormElements();
if (!els.thead || !els.tbody) return;
const fields = Array.isArray(payload.fields) ? payload.fields : [];
const results = Array.isArray(payload.results) ? payload.results : [];
// 保存当前 payload 到 state
infoCollectState.currentPayload = {
provider: payload.provider || getInfoCollectProvider(),
query: payload.query || '',
total: typeof payload.total === 'number' ? payload.total : 0,
page: typeof payload.page === 'number' ? payload.page : 1,
size: typeof payload.size === 'number' ? payload.size : 0,
fields,
results
};
// 清理选择(避免字段/结果变化导致错位)
infoCollectState.selectedRowIndexes.clear();
updateSelectedMeta();
// 修剪隐藏字段:只保留当前 fields 中存在的
const allowed = new Set(fields);
infoCollectState.hiddenFields.forEach(f => {
if (!allowed.has(f)) infoCollectState.hiddenFields.delete(f);
});
saveHiddenFieldsToStorage();
const total = typeof payload.total === 'number' ? payload.total : 0;
const size = typeof payload.size === 'number' ? payload.size : 0;
const page = typeof payload.page === 'number' ? payload.page : 1;
setFofaMeta(buildInfoCollectResultsMeta(
infoCollectState.currentPayload.provider,
total,
results.length,
page,
size,
typeof payload.expected_count === 'number' ? payload.expected_count : 0,
typeof payload.shortfall === 'number' ? payload.shortfall : 0
));
// 可见字段
const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f));
// 列面板
renderFofaColumnsPanel(fields, visibleFields);
// 表头(左:勾选列;右:操作列固定)
const headerCells = [
'<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" class="theme-checkbox" title="' + escapeHtml(_t('infoCollect.selectAll')) + '"/></th>',
...visibleFields.map(f => `<th>${escapeHtml(String(f))}</th>`),
'<th class="info-collect-col-actions">' + escapeHtml(_t('infoCollect.actions')) + '</th>'
].join('');
els.thead.innerHTML = `<tr>${headerCells}</tr>`;
// 表体
if (results.length === 0) {
const colspan = Math.max(1, visibleFields.length + 2);
els.tbody.innerHTML = '<tr><td class="muted" style="padding: 16px;" colspan="' + colspan + '">' + escapeHtml(_t('common.noData')) + '</td></tr>';
return;
}
const rowsHtml = results.map((row, idx) => {
const safeRow = row && typeof row === 'object' ? row : {};
const target = inferTargetFromRow(safeRow, fields);
const encoded = encodeURIComponent(JSON.stringify(safeRow));
const encodedTarget = encodeURIComponent(target || '');
const selectHtml = '<td class="info-collect-col-select"><input class="fofa-row-select theme-checkbox" type="checkbox" data-index="' + idx + '" title="' + escapeHtml(_t('infoCollect.selectRow')) + '"/></td>';
const cellsHtml = visibleFields.map(f => {
const val = safeRow[f];
const text = val == null ? '' : String(val);
// host 字段:尽量渲染为可点击链接
if (f === 'host') {
const href = normalizeHttpLink(text);
if (href) {
const safeHref = escapeHtml(href);
return `<td class="info-collect-cell" data-field="${escapeHtml(f)}" data-full="${escapeHtml(text)}" title="${escapeHtml(text)}"><a class="info-collect-link" href="${safeHref}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation();">${escapeHtml(text)}</a></td>`;
}
}
return `<td class="info-collect-cell" data-field="${escapeHtml(f)}" data-full="${escapeHtml(text)}" title="${escapeHtml(text)}"><span class="info-collect-cell-text">${escapeHtml(text)}</span></td>`;
}).join('');
const actionHtml = `
<div class="info-collect-actions">
<button class="btn-icon" onclick="copyFofaTargetEncoded('${encodedTarget}'); event.stopPropagation();" title="${escapeHtml(_t('infoCollect.copyTarget'))}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<button class="btn-icon" onclick="scanFofaRow('${encoded}', event); event.stopPropagation();" title="${escapeHtml(_t('infoCollect.sendToChat'))}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 13.5l3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M8 8H5a4 4 0 1 0 0 8h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M16 8h3a4 4 0 0 1 0 8h-3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<button class="btn-icon" data-require-permission="asset:write" onclick="importFofaRowAsset(${idx}); event.stopPropagation();" title="${escapeHtml(_t('assets.importOne'))}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M4 19h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
`;
return `<tr data-index="${idx}">${selectHtml}${cellsHtml}<td class="info-collect-col-actions">${actionHtml}</td></tr>`;
}).join('');
els.tbody.innerHTML = rowsHtml;
// 更新全选框状态
syncSelectAllCheckbox();
if (typeof applyRBACToUI === 'function') applyRBACToUI(els.tbody);
}
function inferTargetFromRow(row, fields) {
// 优先 hostFOFA 常见返回 http(s)://...
const host = row.host != null ? String(row.host).trim() : '';
if (host) return host;
const domain = row.domain != null ? String(row.domain).trim() : '';
const ip = row.ip != null ? String(row.ip).trim() : '';
const port = row.port != null ? String(row.port).trim() : '';
const protocol = row.protocol != null ? String(row.protocol).trim().toLowerCase() : '';
const base = domain || ip;
if (!base) return '';
if (port) {
// 仅做一个轻量推断:443 -> https, 80 -> http,其余不强行加 scheme
const p = parseInt(port, 10);
if (!isNaN(p) && (p === 80 || p === 443)) {
const scheme = p === 443 ? 'https' : 'http';
return `${scheme}://${base}:${p}`;
}
if (protocol === 'https' || protocol === 'http') {
return `${protocol}://${base}:${port}`;
}
return `${base}:${port}`;
}
return base;
}
function normalizeHttpLink(raw) {
const v = (raw || '').trim();
if (!v) return '';
if (v.startsWith('http://') || v.startsWith('https://')) return v;
// 某些 host 可能是 domain 或 ip:port;这里不强行拼装,避免误导
return '';
}
function copyFofaTarget(target) {
const text = (target || '').trim();
if (!text) {
alert(_t('infoCollect.noTargetToCopy'));
return;
}
navigator.clipboard.writeText(text).then(() => {
// 简单提示
showInlineToast(_t('infoCollect.targetCopied'));
}).catch(() => {
alert(_t('infoCollect.manualCopyHint') + text);
});
}
function copyFofaTargetEncoded(encodedTarget) {
try {
copyFofaTarget(decodeURIComponent(encodedTarget || ''));
} catch (e) {
copyFofaTarget(encodedTarget || '');
}
}
// 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');
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);
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 truncateForPreview(value, maxLen) {
const s = value == null ? '' : String(value);
if (maxLen <= 0 || s.length <= maxLen) return s;
return s.slice(0, maxLen) + '...(' + _t('infoCollect.truncated') + ')';
}
function formatFofaRowSummary(row, fields) {
const r = row && typeof row === 'object' ? row : {};
const order = [];
const seen = new Set();
const preferred = Array.isArray(fields) ? fields : [];
preferred.forEach(k => {
const key = String(k || '').trim();
if (!key || seen.has(key)) return;
seen.add(key);
order.push(key);
});
Object.keys(r).sort().forEach(k => {
if (seen.has(k)) return;
seen.add(k);
order.push(k);
});
if (order.length === 0) return '-';
const lines = order.map((k) => {
const v = r[k];
let text = '';
if (v === null) text = 'null';
else if (v === undefined) text = '';
else if (typeof v === 'string') text = v === '' ? '""' : v;
else if (typeof v === 'number' || typeof v === 'boolean') text = String(v);
else {
try { text = JSON.stringify(v); } catch (e) { text = String(v); }
}
text = truncateForPreview(text, 800);
return `- ${k}: ${text}`;
});
return lines.join('\n');
}
function scanFofaRow(encodedRowJson, clickEvent) {
let row = {};
try {
row = JSON.parse(decodeURIComponent(encodedRowJson));
} catch (e) {
console.warn('解析行数据失败', e);
}
const fields = (document.getElementById('fofa-fields')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
const target = inferTargetFromRow(row, fields);
if (!target) {
alert(_t('infoCollect.cannotInferTarget'));
return;
}
// 切换到对话页并发送消息(每次点击都新建会话,避免发到历史会话)
if (typeof switchPage === 'function') {
switchPage('chat');
} else {
window.location.hash = 'chat';
}
const message = buildScanMessage(target, row, { fields });
const autoSend = !!(clickEvent && (clickEvent.ctrlKey || clickEvent.metaKey));
setTimeout(async () => {
// 新建会话:必须等待其完成,否则它会在后续把输入框清空
try {
if (typeof startNewConversation === 'function') {
const maybePromise = startNewConversation();
if (maybePromise && typeof maybePromise.then === 'function') {
await maybePromise;
}
}
} catch (e) {
// ignore
}
const input = document.getElementById('chat-input');
if (input) {
input.value = message;
// 触发自动高度调整(chat.js 里如果监听 input
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
}
if (autoSend) {
if (typeof sendMessage === 'function') {
sendMessage();
} else {
alert(_t('infoCollect.noSendMessage'));
}
} else {
showInlineToast(_t('infoCollect.filledToInput'));
}
}, 250);
}
function buildScanMessage(target, row, options) {
const opts = options && typeof options === 'object' ? options : {};
const fields = Array.isArray(opts.fields) ? opts.fields : [];
const summary = formatFofaRowSummary(row || {}, fields);
const provider = providerLabel(infoCollectState.currentPayload?.provider || getInfoCollectProvider());
return `对以下目标做信息收集与基础扫描:\n${target}\n\n要求:\n1) 识别服务/框架与关键指纹\n2) 枚举开放端口与常见管理入口\n3) 用 httpx/指纹/目录探测等方式快速确认可访问面\n4) 输出可复现的命令与结论\n\n已知信息(来自 ${provider} 该行全部字段):\n${summary}`.trim();
}
function bindFofaTableEvents() {
if (infoCollectState.tableBound) return;
infoCollectState.tableBound = true;
const els = getFofaFormElements();
if (!els.tbody) return;
// 事件委托:选择/单元格展开
els.tbody.addEventListener('click', (e) => {
const checkbox = e.target && e.target.classList && e.target.classList.contains('fofa-row-select') ? e.target : null;
if (checkbox) {
const idx = parseInt(checkbox.getAttribute('data-index'), 10);
if (!isNaN(idx)) {
if (checkbox.checked) infoCollectState.selectedRowIndexes.add(idx);
else infoCollectState.selectedRowIndexes.delete(idx);
updateSelectedMeta();
syncSelectAllCheckbox();
}
return;
}
const cell = e.target && e.target.closest ? e.target.closest('.info-collect-cell') : null;
if (cell) {
const full = cell.getAttribute('data-full') || '';
const field = cell.getAttribute('data-field') || '';
// 点击链接不弹窗
if (e.target && e.target.tagName === 'A') return;
if (full && full.length > 0) {
showCellDetailModal(field, full);
}
}
});
// thead 的全选(因为 thead 会重渲染,用事件捕获到 document)
document.addEventListener('change', (e) => {
const t = e.target;
if (!t || t.id !== 'fofa-select-all') return;
const checked = !!t.checked;
toggleSelectAllRows(checked);
});
}
function toggleSelectAllRows(checked) {
const els = getFofaFormElements();
if (!els.tbody) return;
const boxes = els.tbody.querySelectorAll('input.fofa-row-select');
infoCollectState.selectedRowIndexes.clear();
boxes.forEach(b => {
b.checked = checked;
const idx = parseInt(b.getAttribute('data-index'), 10);
if (checked && !isNaN(idx)) infoCollectState.selectedRowIndexes.add(idx);
});
updateSelectedMeta();
syncSelectAllCheckbox();
}
function syncSelectAllCheckbox() {
const selectAll = document.getElementById('fofa-select-all');
const els = getFofaFormElements();
if (!selectAll || !els.tbody) return;
const boxes = els.tbody.querySelectorAll('input.fofa-row-select');
const total = boxes.length;
const selected = infoCollectState.selectedRowIndexes.size;
if (total === 0) {
selectAll.checked = false;
selectAll.indeterminate = false;
return;
}
if (selected === 0) {
selectAll.checked = false;
selectAll.indeterminate = false;
} else if (selected === total) {
selectAll.checked = true;
selectAll.indeterminate = false;
} else {
selectAll.checked = false;
selectAll.indeterminate = true;
}
}
function renderFofaColumnsPanel(allFields, visibleFields) {
const els = getFofaFormElements();
if (!els.columnsList) return;
const currentVisible = new Set(visibleFields);
els.columnsList.innerHTML = allFields.map(f => {
const checked = currentVisible.has(f);
const safe = escapeHtml(f);
return `
<label class="info-collect-col-item" title="${safe}">
<input type="checkbox" ${checked ? 'checked' : ''} onchange="toggleFofaColumn('${safe}', this.checked)" />
<span>${safe}</span>
</label>
`;
}).join('');
}
function toggleFofaColumn(field, visible) {
const f = String(field || '').trim();
if (!f) return;
if (visible) infoCollectState.hiddenFields.delete(f);
else infoCollectState.hiddenFields.add(f);
saveHiddenFieldsToStorage();
// 重新渲染表格(用 state 中缓存的 payload
if (infoCollectState.currentPayload) {
renderFofaResults(infoCollectState.currentPayload);
}
}
function toggleFofaColumnsPanel() {
const els = getFofaFormElements();
if (!els.columnsPanel) return;
const show = els.columnsPanel.style.display === 'none' || !els.columnsPanel.style.display;
els.columnsPanel.style.display = show ? 'block' : 'none';
}
function closeFofaColumnsPanel() {
const els = getFofaFormElements();
if (els.columnsPanel) els.columnsPanel.style.display = 'none';
}
// 点击面板外部关闭(避免一直占着表格顶部)
document.addEventListener('click', (e) => {
const panel = document.getElementById('fofa-columns-panel');
const btn = e.target && e.target.closest ? e.target.closest('button') : null;
const isColumnsBtn = btn && btn.getAttribute && btn.getAttribute('onclick') && String(btn.getAttribute('onclick')).includes('toggleFofaColumnsPanel');
if (!panel || panel.style.display === 'none') return;
if (panel.contains(e.target) || isColumnsBtn) return;
panel.style.display = 'none';
});
function showAllFofaColumns() {
infoCollectState.hiddenFields.clear();
saveHiddenFieldsToStorage();
if (infoCollectState.currentPayload) renderFofaResults(infoCollectState.currentPayload);
}
function hideAllFofaColumns() {
const p = infoCollectState.currentPayload;
if (!p || !Array.isArray(p.fields)) return;
// 允许隐藏全部,但给用户一个最小可用:至少保留 host/ip/domain 中之一(如果存在)
const keep = ['host', 'ip', 'domain'].find(x => p.fields.includes(x));
infoCollectState.hiddenFields = new Set(p.fields.filter(f => f !== keep));
saveHiddenFieldsToStorage();
renderFofaResults(p);
}
function exportFofaResults(format) {
const p = infoCollectState.currentPayload;
if (!p || !Array.isArray(p.results) || p.results.length === 0) {
alert(_t('infoCollect.noExportResult'));
return;
}
const fields = p.fields || [];
const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f));
const provider = p.provider || 'fofa';
const now = new Date();
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
if (format === 'json') {
const payload = {
provider,
query: p.query || '',
total: p.total || 0,
page: p.page || 1,
size: p.size || 0,
fields: fields,
results: p.results
};
downloadBlob(JSON.stringify(payload, null, 2), `${provider}_results_${ts}.json`, 'application/json;charset=utf-8');
return;
}
if (format === 'xlsx') {
// 使用 SheetJS 生成 XLSX(需在页面中引入 xlsx 库)
if (typeof XLSX === 'undefined') {
alert(_t('infoCollect.xlsxNotLoaded'));
return;
}
const aoa = [visibleFields].concat(p.results.map(row => {
const r = row && typeof row === 'object' ? row : {};
return visibleFields.map(f => r[f] != null ? r[f] : '');
}));
const ws = XLSX.utils.aoa_to_sheet(aoa);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, _t('infoCollect.batchScanTitle'));
XLSX.writeFile(wb, `${provider}_results_${ts}.xlsx`);
return;
}
// csv:默认导出可见字段,带 UTF-8 BOM 以兼容 Excel 中文
const header = visibleFields;
const rows = p.results.map(row => {
const r = row && typeof row === 'object' ? row : {};
return header.map(f => csvEscape(r[f]));
});
const csv = [header.map(csvEscape).join(','), ...rows.map(cols => cols.join(','))].join('\n');
const csvWithBom = '\uFEFF' + csv;
downloadBlob(csvWithBom, `${provider}_results_${ts}.csv`, 'text/csv;charset=utf-8');
}
function csvEscape(value) {
if (value == null) return '""';
const s = String(value).replace(/"/g, '""');
return `"${s}"`;
}
function downloadBlob(content, filename, mime) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function batchScanSelectedFofaRows() {
const p = infoCollectState.currentPayload;
if (!p || !Array.isArray(p.results) || p.results.length === 0) {
alert(_t('infoCollect.noResults'));
return;
}
const selected = Array.from(infoCollectState.selectedRowIndexes).sort((a, b) => a - b);
if (selected.length === 0) {
alert(_t('infoCollect.selectRowsFirst'));
return;
}
const fields = p.fields || [];
const tasks = [];
const skipped = [];
selected.forEach(idx => {
const row = p.results[idx];
const target = inferTargetFromRow(row || {}, fields);
if (!target) {
skipped.push(idx + 1);
return;
}
// 批量任务:与单条一致,只带“该行全部字段”的摘要(避免重复与超长)
tasks.push(buildScanMessage(target, row || {}, {
fields
}));
});
if (tasks.length === 0) {
alert(_t('infoCollect.noScanTarget'));
return;
}
const title = (p.query ? _t('infoCollect.batchScanTitle') + '' + p.query : _t('infoCollect.batchScanTitle')).slice(0, 80);
try {
// 不强制切换到“信息收集”角色:沿用当前已选角色;若为默认则传空字符串交给后端走默认逻辑
let role = '';
if (typeof getCurrentRole === 'function') {
try { role = getCurrentRole() || ''; } catch (e) { /* ignore */ }
}
if (role === '默认') role = '';
const resp = await apiFetch('/api/batch-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
tasks,
role,
projectId: typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '',
})
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(result.error || _t('infoCollect.createQueueFailed') + ': ' + resp.status);
}
const queueId = result.queueId;
if (!queueId) {
throw new Error('创建成功但未返回 queueId');
}
// 跳到任务管理并打开队列详情
if (typeof switchPage === 'function') switchPage('tasks');
setTimeout(() => {
if (typeof showBatchQueueDetail === 'function') {
showBatchQueueDetail(queueId);
}
}, 250);
if (skipped.length > 0) {
showInlineToast(_t('infoCollect.queueCreatedSkipped', { n: skipped.length }));
} else {
showInlineToast(_t('infoCollect.batchQueueCreated'));
}
} catch (e) {
console.error('批量扫描失败:', e);
alert(_t('infoCollect.batchScanFailed') + ': ' + (e && e.message ? e.message : String(e)));
}
}
function showCellDetailModal(field, fullText) {
const existing = document.getElementById('info-collect-cell-modal');
if (existing) existing.remove();
const text = fullText == null ? '' : String(fullText);
const fieldName = field || _t('infoCollect.field');
const charCountLabel = _t('infoCollect.cellValueLength', { count: Array.from(text).length });
const modal = document.createElement('div');
modal.id = 'info-collect-cell-modal';
modal.className = 'info-collect-cell-modal';
modal.innerHTML = `
<div class="info-collect-cell-modal-content" role="dialog" aria-modal="true">
<div class="info-collect-cell-modal-header">
<div class="info-collect-cell-modal-heading">
<div class="info-collect-cell-modal-title">${escapeHtml(fieldName)}</div>
<div class="info-collect-cell-modal-subtitle">${escapeHtml(charCountLabel)}</div>
</div>
<button class="btn-icon" type="button" id="info-collect-cell-modal-close" title="${_t('common.close')}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="info-collect-cell-modal-body">
<pre class="info-collect-cell-modal-pre">${escapeHtml(text)}</pre>
</div>
<div class="info-collect-cell-modal-footer">
<button class="btn-secondary" type="button" id="info-collect-cell-modal-copy">${_t('common.copy')}</button>
<button class="btn-primary" type="button" id="info-collect-cell-modal-ok">${_t('common.close')}</button>
</div>
</div>
`;
document.body.appendChild(modal);
openAppModal(modal);
const onKey = (e) => {
if (e.key === 'Escape') {
close();
}
};
const close = function () {
document.removeEventListener('keydown', onKey);
closeAppModal(modal);
modal.remove();
syncAppModalBodyLock();
};
modal.addEventListener('click', (e) => {
if (e.target === modal) close();
});
document.getElementById('info-collect-cell-modal-close')?.addEventListener('click', close);
document.getElementById('info-collect-cell-modal-ok')?.addEventListener('click', close);
document.getElementById('info-collect-cell-modal-copy')?.addEventListener('click', () => {
navigator.clipboard.writeText(text).then(() => showInlineToast(_t('common.copied'))).catch(() => alert(_t('common.copyFailed')));
});
// Esc 关闭
document.addEventListener('keydown', onKey);
}
// 暴露到全局(供 index.html onclick 调用)
window.initInfoCollectPage = initInfoCollectPage;
window.resetFofaForm = resetFofaForm;
window.submitFofaSearch = submitFofaSearch;
window.parseFofaNaturalLanguage = parseFofaNaturalLanguage;
window.setInfoCollectQueryMode = setInfoCollectQueryMode;
window.scanFofaRow = scanFofaRow;
window.copyFofaTarget = copyFofaTarget;
window.copyFofaTargetEncoded = copyFofaTargetEncoded;
window.applyFofaQueryPreset = applyFofaQueryPreset;
window.applyFofaFieldsPreset = applyFofaFieldsPreset;
window.toggleFofaColumnsPanel = toggleFofaColumnsPanel;
window.closeFofaColumnsPanel = closeFofaColumnsPanel;
window.showAllFofaColumns = showAllFofaColumns;
window.hideAllFofaColumns = hideAllFofaColumns;
window.toggleFofaColumn = toggleFofaColumn;
window.exportFofaResults = exportFofaResults;
window.batchScanSelectedFofaRows = batchScanSelectedFofaRows;
document.addEventListener('languagechange', function () {
updateSelectedMeta();
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { updateSelectedMeta(); });
} else {
updateSelectedMeta();
}