Files
tianxuan/simple_analysis/views.py
T
TianXuan Developer 32c15f5365 feat: v3 refactor with MMDB GeoIP, network clustering, node/edge viz, offline tile cache, dual export
- GeoIP: MMDB (GeoLite2-City + ASN) support with text fallback
- Upload: two-phase flow with advanced preset filter builder
- Clustering: IP communication network community discovery (/24 + HDBSCAN)
- Map: per-IP node rendering, node click detail, edge click detail
- Offline: Cache API tile caching with fallback
- Export: node table + edge table CSV download
- Fix: null-guard DOM operations throughout

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 19:35:45 +08:00

1423 lines
55 KiB
Python
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.
"""简单分析模块 v2 — 离线 GeoIP + 高级筛选 + 高性能聚类 + 丰富可视化。
改进:
- 使用离线 GeoIP 数据库(analysis.geoip)解析 IP 经纬度,抛弃 CSV 自带列
- 列名统一规范化,内置 50+ 列映射字典
- 上传预设筛选 + 高级 AND/OR/NOT 筛选
- HDBSCAN 性能优化(float32、降采样、DBSCAN 回退)
- 全噪声时可视化、点属性、边关系
"""
import json
import logging
import os
import re
import shutil
import time
import uuid
from pathlib import Path
from collections import Counter
import numpy as np
import polars as pl
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST, require_GET
from sklearn.cluster import HDBSCAN, DBSCAN
from sklearn.preprocessing import StandardScaler
# 复用天璇原有离线 GeoIP 模块(data/geoip_data.txt 覆盖 23 城市 ISP 段)
from analysis.geoip import lookup as geoip_lookup
logger = logging.getLogger(__name__)
# ── In-memory cache ──────────────────────────────────────────────────────────
_cache: dict[str, dict] = {}
_TEMP_DIR = Path(settings.FILE_UPLOAD_TEMP_DIR) / 'simple_analysis'
_TEMP_DIR.mkdir(parents=True, exist_ok=True)
def _new_id() -> str:
return uuid.uuid4().hex[:12]
def _cached(key: str) -> dict | None:
return _cache.get(key)
def _store(key: str, data: dict) -> None:
_cache[key] = data
# ═══════════════════════════════════════════════════════════════════════════════
# 列名规范化 — 完整映射字典
# ═══════════════════════════════════════════════════════════════════════════════
# 标准内部名称 → 可能的 CSV 原始列名(按优先级)
_COLUMN_ALIASES: dict[str, list[str]] = {
# IP 地址
'ips': [':ips', 'src_ip', 'source_ip', 'ip_src', 'client-ip', 'client_ip'],
'ipd': [':ipd', 'dst_ip', 'dest_ip', 'destination_ip', 'ip_dst', 'server-ip', 'server_ip'],
# 布尔/状态
'cnrs': ['cnrs', 'conn_success', 'result', '4srs'],
'isrs': ['isrs', 'is_resolved', 'resolved', '8ses'],
# 类别/名称
'cnam': ['cnam', 'snam', 'class_name', 'category', 'target', 'name', 'tabl'],
'snam': ['snam', 'server_name', 'tls_sni', 'sni'],
# 经纬度(仍保留检测,但聚类不再依赖)
'src_lat': [':ips.latd', 'src_latitude', 'src_lat', 'source_lat'],
'src_lon': [':ips.lond', 'src_longitude', 'src_lon', 'source_lon'],
'dst_lat': [':ipd.latd', 'dst_latitude', 'dst_lat', 'dest_lat'],
'dst_lon': [':ipd.lond', 'dst_longitude', 'dst_lon', 'dest_lon'],
# 端口/协议
'dport': ['1ipp', 'dst_port', 'dest_port', 'port', 'dport'],
'proto': ['proto', 'protocol', 'l4_proto'],
# TLS
'tls_ver': ['0ver', 'tls_version', 'version', 'tlsver'],
'tls_cph': ['0cph', 'cipher_suite', 'cipher', 'tls_cipher'],
'tls_rnd': ['0rnd', 'tls_random', 'random'],
'tls_rnt': ['0rnt', 'tls_rnt'],
# 流量
'bytes': ['8byt', 'bytes_sent', 'bytes', 'src_bytes', '8ppk'],
'duration': ['4dur', 'duration', 'dur', 'flow_duration'],
'packets': ['8pak', 'packets', 'pkt_count'],
# 时序
'time': ['time', 'timestamp', '4dbn'],
'seq': ['8seq', 'seq', 'sequence'],
'ack': ['8ack', 'ack', 'acknowledgement'],
# 地理信息
'ispn': [':ips.ispn', 'isp_name', 'isp'],
'orgn': [':ips.orgn', 'organization', 'org'],
'city': [':ips.city', 'city', 'src_city'],
'ipd_ispn': [':ipd.ispn', 'dst_ispn', 'dst_isp'],
'ipd_orgn': [':ipd.orgn', 'dst_organization', 'dst_org'],
'ipd_city': [':ipd.city', 'dst_city'],
# 其他
'scnt': ['scnt', 'country', 'country_code'],
'dcnt': ['dcnt', 'dst_country'],
'source_node': ['source-node', 'source_node'],
'prd': [':prd', 'prd', 'product'],
'prs': [':prs', 'prs', 'process'],
'ipd_anon': [':ipd.anon', 'dst_anon', 'anon'],
'8did': ['8did', 'did', 'device_id'],
'8dbd': ['8dbd', 'dbd'],
'2tmo': ['2tmo', 'tmo', 'timeout'],
'text': ['text', 'payload', 'content'],
}
def _norm(name: str) -> str:
"""归一化列名:去首尾空格,特殊字符→下划线,转小写。"""
n = name.strip().lower()
n = re.sub(r'[-:.\s]+', '_', n)
return n
def normalize_columns(df: pl.DataFrame) -> tuple[pl.DataFrame, dict[str, str]]:
"""对 DataFrame 列名进行统一规范化,返回 (新df, 规范化映射 {原始名→标准内部名})。
规范化规则:
1. 去首尾空格
2. 连续特殊字符(- : . 空格)→ 下划线
3. 全部小写
然后根据 _COLUMN_ALIASES 模糊匹配到标准内部名称。
"""
col_map = {} # 原始名 → 标准内部名
reverse_map = {} # norm(原始名) → 原始名
for c in df.columns:
nc = _norm(c)
reverse_map[nc] = c
# 遍历别名表,尝试匹配
used_originals = set()
for std_name, aliases in _COLUMN_ALIASES.items():
for alias in aliases:
n_alias = _norm(alias)
# 精确匹配
if n_alias in reverse_map and reverse_map[n_alias] not in used_originals:
col_map[reverse_map[n_alias]] = std_name
used_originals.add(reverse_map[n_alias])
break
# 后缀匹配(如列名 _ips 匹配 :ips)
for orig, n_orig in reverse_map.items():
if orig in used_originals:
continue
if n_orig == n_alias or n_orig.endswith('_' + n_alias) or n_orig.startswith(n_alias + '_'):
col_map[orig] = std_name
used_originals.add(orig)
break
if std_name in col_map.values():
break
# 未匹配的保留原名
for c in df.columns:
if c not in col_map:
col_map[c] = c
# 重命名
rename_dict = {orig: std for orig, std in col_map.items() if orig != std}
if rename_dict:
df = df.rename(rename_dict)
return df, col_map
def find_col(norm_col_map: dict[str, str], *std_names: str) -> str | None:
"""在规范化映射中查找第一个匹配的标准列名,返回原始列名。"""
for std in std_names:
for orig, mapped in norm_col_map.items():
if mapped == std:
return orig
return None
# ═══════════════════════════════════════════════════════════════════════════════
# GeoIP 辅助(复用天璇离线库)
# ═══════════════════════════════════════════════════════════════════════════════
def resolve_ip_geo(ip_str: str | None) -> dict:
"""用离线 GeoIP 库解析单个 IP,返回 {lat, lon, city, country} 或空字典。"""
if not ip_str or ip_str in ('None', '', '+', '-'):
return {}
try:
result = geoip_lookup(ip_str)
if result:
return result
except Exception:
pass
return {}
def batch_geoip_resolve(ip_list: list[str]) -> dict[str, dict]:
"""批量解析 IP 地理信息,返回 {ip: {lat, lon, city, country}}。"""
cache: dict[str, dict] = {}
for ip in set(ip_list):
if ip and ip not in ('None', '', '+', '-'):
result = geoip_lookup(ip)
if result:
cache[ip] = result
return cache
# ═══════════════════════════════════════════════════════════════════════════════
# Step 0: Quick column scan (header-only, no data load)
# ═══════════════════════════════════════════════════════════════════════════════
@csrf_exempt
@require_POST
def quick_scan(request):
"""仅读取 CSV 表头,返回列名(用于上传前配置预设筛选器)。
接收上传文件,只扫描第一行(header),立即返回列名列表。
文件保存在临时目录,后续调用 upload_csv 时会覆盖/重用。
"""
files = request.FILES.getlist('files')
if not files:
return JsonResponse({'error': '未上传任何文件'}, status=400)
scan_id = _new_id()
scan_dir = _TEMP_DIR / scan_id
scan_dir.mkdir(parents=True, exist_ok=True)
saved_paths = []
for f in files:
dest = scan_dir / f.name
with open(dest, 'wb') as out:
for chunk in f.chunks():
out.write(chunk)
saved_paths.append(str(dest))
try:
# Read header only (n_rows=0 to read just the schema)
lf = pl.scan_csv(saved_paths[0], infer_schema_length=0)
if len(saved_paths) > 1:
frames = [pl.scan_csv(p, infer_schema_length=0) for p in saved_paths]
lf = pl.concat(frames, how='diagonal_relaxed')
# Read only 1 row to get columns
df = lf.head(1).collect()
except Exception as e:
shutil.rmtree(scan_dir, ignore_errors=True)
return JsonResponse({'error': f'CSV 表头读取失败: {e}'}, status=400)
# Normalize column names
df_norm, col_map = normalize_columns(df)
# Cleanup scan dir — upload_csv will re-save
shutil.rmtree(scan_dir, ignore_errors=True)
return JsonResponse({
'scan_id': scan_id,
'columns': list(df_norm.columns),
'columns_std': list(df_norm.columns),
'col_map': {orig: std for orig, std in col_map.items() if orig != std},
})
# ═══════════════════════════════════════════════════════════════════════════════
# Step 1: Upload + 预设筛选 + GeoIP 解析
# ═══════════════════════════════════════════════════════════════════════════════
@csrf_exempt
@require_POST
def upload_csv(request):
"""上传 CSV,列归一化,预设筛选(支持树状高级筛选),GeoIP 解析,返回摘要。"""
files = request.FILES.getlist('files')
if not files:
return JsonResponse({'error': '未上传任何文件'}, status=400)
session_id = _new_id()
upload_dir = _TEMP_DIR / session_id
upload_dir.mkdir(parents=True, exist_ok=True)
saved_paths = []
for f in files:
dest = upload_dir / f.name
with open(dest, 'wb') as out:
for chunk in f.chunks():
out.write(chunk)
saved_paths.append(str(dest))
# 解析预设筛选条件(支持树状结构 + 旧格式兼容)
preset_root = None
try:
pf_raw = request.POST.get('preset_filters', request.GET.get('preset_filters', '{}'))
if pf_raw:
pf = json.loads(pf_raw)
# 新格式:树状 root
if 'root' in pf:
preset_root = pf['root']
# 旧格式兼容:{cnrs: "+", ...}
elif any(k in pf for k in ('cnrs', 'isrs', 'cnam', 'snam')):
# Convert legacy to tree
items = []
for key in ('cnrs', 'isrs', 'cnam', 'snam'):
if key in pf and pf[key]:
if key in ('cnrs', 'isrs'):
items.append({'type': 'condition', 'col': key, 'op': 'eq', 'val': pf[key]})
else:
items.append({'type': 'condition', 'col': key, 'op': 'contains', 'val': pf[key]})
if items:
preset_root = {'type': 'group', 'logic': 'AND', 'items': items}
except (json.JSONDecodeError, Exception):
pass
# Load & merge CSV
try:
lf = pl.scan_csv(
saved_paths[0] if len(saved_paths) == 1 else str(upload_dir / '*.csv'),
infer_schema_length=10000,
try_parse_dates=True,
)
if len(saved_paths) > 1:
frames = [pl.scan_csv(p, infer_schema_length=10000) for p in saved_paths]
lf = pl.concat(frames, how='diagonal_relaxed')
df = lf.collect(streaming=True)
except Exception as e:
shutil.rmtree(upload_dir, ignore_errors=True)
return JsonResponse({'error': f'CSV 解析失败: {e}'}, status=400)
# 列名规范化
df, col_map = normalize_columns(df)
total_rows = len(df)
# 获取各关键列的 标准内部名(即重命名后的列名)
def _get_std(*names):
"""从 col_map 中查找第一个匹配的标准名。"""
for n in names:
for orig, std in col_map.items():
if std == n:
return std
return None
ips_col = _get_std('ips')
ipd_col = _get_std('ipd')
cnam_col = _get_std('cnam')
cnrs_col = _get_std('cnrs')
isrs_col = _get_std('isrs')
snam_col = _get_std('snam')
dport_col = _get_std('dport')
proto_col = _get_std('proto')
tls_ver_col = _get_std('tls_ver')
tls_cph_col = _get_std('tls_cph')
bytes_col = _get_std('bytes')
dur_col = _get_std('duration')
time_col = _get_std('time')
ispn_col = _get_std('ispn')
orgn_col = _get_std('orgn')
city_col = _get_std('city')
scnt_col = _get_std('scnt')
text_col = _get_std('text')
# ── 预设筛选(树状高级筛选) ──────────────────────────────────────────
preset_filtered = total_rows
if preset_root is not None:
try:
cond = _build_tree_condition(preset_root, df)
if cond is not None:
df = df.filter(cond)
preset_filtered = len(df)
except Exception as e:
logger.warning('Preset filter error: %s', e)
# ── GeoIP 批量解析(使用标准化列名) ────────────────────────────────────
geo_cache: dict[str, dict] = {}
geo_skipped = 0
if ips_col and ips_col in df.columns:
all_ips = df[ips_col].cast(pl.Utf8).unique().to_list()
geo_cache = batch_geoip_resolve([ip for ip in all_ips if ip not in (None, '', 'None', '+', '-')])
logger.info('GeoIP resolved %d IPs', len(geo_cache))
# ── CNAM 频率 ────────────────────────────────────────────────────────────
cnam_freq = []
top_cnam = None
if cnam_col:
try:
cnam_series = df[cnam_col].cast(pl.Utf8).fill_null('')
counts = Counter(cnam_series.to_list())
total_cnam = sum(counts.values())
for val, cnt in counts.most_common():
cnam_freq.append({
'value': val if val else '(空)',
'count': cnt,
'pct': round(cnt / total_cnam * 100, 1),
})
if cnam_freq:
top_cnam = cnam_freq[0]
except Exception as e:
logger.warning('cnam frequency failed: %s', e)
# 存储元数据
meta = {
'session_id': session_id,
'total_rows': total_rows,
'preset_filtered': preset_filtered,
'col_names': list(df.columns),
'col_map': col_map, # {原始名→标准内部名}
'geo_cache': geo_cache, # {ip: {lat, lon, city, country}}
'geo_skipped': geo_skipped,
'ips_col': ips_col,
'ipd_col': ipd_col,
'cnam_col': cnam_col,
'cnrs_col': cnrs_col,
'isrs_col': isrs_col,
'snam_col': snam_col,
'dport_col': dport_col,
'proto_col': proto_col,
'tls_ver_col': tls_ver_col,
'tls_cph_col': tls_cph_col,
'bytes_col': bytes_col,
'dur_col': dur_col,
'time_col': time_col,
'ispn_col': ispn_col,
'orgn_col': orgn_col,
'city_col': city_col,
'scnt_col': scnt_col,
'text_col': text_col,
}
_store(session_id, {'meta': meta, 'df': df, 'geo_cache': geo_cache})
return JsonResponse({
'session_id': session_id,
'total_rows': total_rows,
'preset_filtered': preset_filtered,
'columns': list(df.columns)[:50],
'columns_std': list(df.columns), # 标准化后列名,供前端筛选器使用
'col_map': {orig: std for orig, std in col_map.items() if orig != std},
'detected_columns': {
'cnam': cnam_col,
'snam': snam_col,
'cnrs': cnrs_col,
'isrs': isrs_col,
'ips': ips_col,
'ipd': ipd_col,
'ispn': ispn_col,
'orgn': orgn_col,
'city': city_col,
},
'cnam_frequency': cnam_freq[:30],
'top_cnam': top_cnam,
'geo_stats': {
'ips_resolved': len(geo_cache),
},
})
# ═══════════════════════════════════════════════════════════════════════════════
# Step 2: 高级筛选构建器(AND/OR/NOT 组合条件)
# ═══════════════════════════════════════════════════════════════════════════════
def _build_condition(col_name: str, operator: str, value: str, logic: str = 'AND'):
"""构建单个筛选条件表达式。"""
col = pl.col(col_name)
v = value.strip()
try:
if operator == 'eq':
cond = col.cast(pl.Utf8) == v
elif operator == 'neq':
cond = col.cast(pl.Utf8) != v
elif operator == 'contains':
cond = col.cast(pl.Utf8).str.contains(v)
elif operator == 'not_contains':
cond = ~col.cast(pl.Utf8).str.contains(v)
elif operator == 'gt':
cond = col.cast(pl.Float64) > float(v)
elif operator == 'lt':
cond = col.cast(pl.Float64) < float(v)
elif operator == 'gte':
cond = col.cast(pl.Float64) >= float(v)
elif operator == 'lte':
cond = col.cast(pl.Float64) <= float(v)
elif operator == 'is_empty':
cond = col.is_null() | (col.cast(pl.Utf8) == '')
elif operator == 'not_empty':
cond = col.is_not_null() & (col.cast(pl.Utf8) != '')
else:
cond = col.cast(pl.Utf8) == v
except Exception:
cond = col.cast(pl.Utf8) == v
return cond
def _build_tree_condition(node, df) -> pl.Expr | None:
"""递归构建条件树,支持嵌套 AND/OR/NOT 组。
JSON 节点格式:
{"type": "condition", "col": "...", "op": "...", "val": "..."}
{"type": "group", "logic": "AND|OR", "items": [...]}
{"type": "group", "logic": "NOT", "items": [...]} # 否定整个组
"""
if node is None:
return None
t = node.get('type', 'condition')
if t == 'condition':
col = node.get('col', '')
if not col or col not in df.columns:
return None
c = _build_condition(col, node.get('op', 'eq'), node.get('val', ''))
if node.get('logic') == 'NOT':
c = ~c
return c
if t == 'group':
items = node.get('items', [])
if not items:
return None
logic = node.get('logic', 'AND')
conds = []
for item in items:
c = _build_tree_condition(item, df)
if c is not None:
conds.append(c)
if not conds:
return None
if logic == 'NOT':
combined = conds[0]
for c in conds[1:]:
combined = combined & c # NOT 组内默认 AND 组合
return ~combined
combined = conds[0]
for c in conds[1:]:
if logic == 'OR':
combined = combined | c
else:
combined = combined & c
return combined
return None
@csrf_exempt
@require_POST
def advanced_filter(request):
"""高级筛选:接受树状 AND/OR/NOT 分组条件,支持嵌套括号语义。"""
body = json.loads(request.body)
session_id = body.get('session_id')
root = body.get('root') # 新格式:树状结构
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期,请重新上传'}, status=400)
df = entry['df']
try:
if root:
# 新格式:递归构建条件树
combined = _build_tree_condition(root, df)
if combined is None:
entry['filtered_df'] = df
entry['meta']['filtered_rows'] = len(df)
return JsonResponse({'filtered_rows': len(df), 'total_before_filter': len(df), 'preview_columns': df.columns[:30], 'preview_rows': []})
else:
# 旧格式(向后兼容)
conditions = body.get('conditions', [])
global_logic = body.get('global_logic', 'AND')
if not conditions:
entry['filtered_df'] = df
entry['meta']['filtered_rows'] = len(df)
return JsonResponse({'filtered_rows': len(df), 'total_before_filter': len(df), 'preview_columns': df.columns[:30], 'preview_rows': []})
group_conds = []
for cond_item in conditions:
col = cond_item.get('col', '')
op = cond_item.get('op', 'eq')
val = cond_item.get('val', '')
if not col or col not in df.columns:
continue
c = _build_condition(col, op, val)
if cond_item.get('logic') == 'NOT':
c = ~c
group_conds.append(c)
if not group_conds:
return JsonResponse({'error': '无可用的筛选条件'}, status=400)
if global_logic == 'OR':
combined = group_conds[0]
for c in group_conds[1:]:
combined = combined | c
else:
combined = group_conds[0]
for c in group_conds[1:]:
combined = combined & c
filtered = df.filter(combined)
except Exception as e:
return JsonResponse({'error': f'筛选构建失败: {e}'}, status=400)
filtered_rows = len(filtered)
entry['meta']['filtered_rows'] = filtered_rows
entry['filtered_df'] = filtered
preview_cols = [c for c in filtered.columns if not c.startswith('_')][:30]
preview_rows = []
for row in filtered.head(20).select(preview_cols).iter_rows(named=True):
clean = {}
for k, v in row.items():
if v is None or (isinstance(v, float) and np.isnan(v)):
clean[k] = None
else:
clean[k] = str(v)[:120]
preview_rows.append(clean)
return JsonResponse({
'filtered_rows': filtered_rows,
'preview_columns': preview_cols,
'preview_rows': preview_rows,
'total_before_filter': len(df),
})
# ── 兼容原 filter 接口 ──────────────────────────────────────────────────────
@csrf_exempt
@require_POST
def filter_data(request):
"""兼容旧版简单筛选接口,内部调用高级筛选。"""
body = json.loads(request.body)
session_id = body.get('session_id')
selected_cnam = body.get('selected_cnam', '')
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期,请重新上传'}, status=400)
df = entry['df']
meta = entry['meta']
cnam_col = meta.get('cnam_col')
cnrs_col = meta.get('cnrs_col')
isrs_col = meta.get('isrs_col')
if not cnam_col:
return JsonResponse({'error': '缺少 cnam/snam 列', 'missing_columns': ['cnam']}, status=400)
try:
conditions = [{'col': cnam_col, 'op': 'eq', 'val': selected_cnam}]
if cnrs_col:
conditions.append({'col': cnrs_col, 'op': 'contains', 'val': '+'})
if isrs_col:
conditions.append({'col': isrs_col, 'op': 'contains', 'val': '+'})
combined = None
for c in conditions:
cond = _build_condition(c['col'], c['op'], c['val'])
combined = cond if combined is None else (combined & cond)
filtered = df.filter(combined) if combined else df
except Exception as e:
return JsonResponse({'error': f'筛选失败: {e}'}, status=400)
filtered_rows = len(filtered)
meta['filtered_rows'] = filtered_rows
entry['filtered_df'] = filtered
preview_cols = [c for c in filtered.columns if not c.startswith('_')][:30]
preview_rows = []
for row in filtered.head(20).select(preview_cols).iter_rows(named=True):
clean = {}
for k, v in row.items():
if v is None or (isinstance(v, float) and np.isnan(v)):
clean[k] = None
else:
clean[k] = str(v)[:120]
preview_rows.append(clean)
return JsonResponse({
'filtered_rows': filtered_rows,
'preview_columns': preview_cols,
'preview_rows': preview_rows,
'total_before_filter': len(df),
})
# ═══════════════════════════════════════════════════════════════════════════════
# Step 3: 聚类 — IP 通信网络社区发现
# ═══════════════════════════════════════════════════════════════════════════════
@csrf_exempt
@require_POST
def run_clustering(request):
"""IP 通信网络社区发现聚类。
构建 IP 通信图(节点=唯一IP,边=聚合通信记录),
按 /24 子网前缀粗分组,子网内用 HDBSCAN (haversine) 二次聚类。
"""
body = json.loads(request.body)
session_id = body.get('session_id')
min_cluster_size = int(body.get('min_cluster_size', 5))
cluster_selection_epsilon = float(body.get('cluster_selection_epsilon', 0.05))
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期,请重新上传'}, status=400)
filtered = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
if filtered is None:
return JsonResponse({'error': '请先执行筛选'}, status=400)
meta = entry['meta']
t_start = time.time()
result_data = _cluster_network(entry, filtered, meta, min_cluster_size, cluster_selection_epsilon)
elapsed = round(time.time() - t_start, 2)
result_data['elapsed_seconds'] = elapsed
_store(session_id, entry)
return JsonResponse(result_data)
def _cluster_network(entry: dict, df: pl.DataFrame, meta: dict,
min_cluster_size: int, cluster_selection_epsilon: float) -> dict:
"""构建 IP 通信网络并进行社区发现聚类。
流程:
1. 从数据中提取所有唯一 IP(源+目的),去重
2. 通过 GeoIP 获取每个 IP 的经纬度和属性
3. 按 /24 子网前缀将 IP 分组
4. 每个子网内,若节点数 > min_cluster_size,用 HDBSCAN (haversine) 二次聚类
5. 不同子网的节点不会被合并
6. 构建边列表(IP 对之间的聚合通信统计)
"""
ips_col = meta.get('ips_col')
ipd_col = meta.get('ipd_col')
time_col = meta.get('time_col')
bytes_col = meta.get('bytes_col')
geo_cache = entry.get('geo_cache', {})
if not ips_col or not ipd_col:
return {'error': '缺少源/目的IP列,无法构建通信网络'}
if ips_col not in df.columns or ipd_col not in df.columns:
return {'error': 'IP列不在数据中'}
# ── 1. 提取唯一 IP 列表 ──────────────────────────────────────────────
src_ips = df[ips_col].cast(pl.Utf8).to_list()
dst_ips = df[ipd_col].cast(pl.Utf8).to_list()
# Collect all unique IPs
unique_ips_set = set()
for ip in src_ips:
if ip and ip not in ('None', '', '+', '-'):
unique_ips_set.add(ip)
for ip in dst_ips:
if ip and ip not in ('None', '', '+', '-'):
unique_ips_set.add(ip)
unique_ips = sorted(unique_ips_set)
n_nodes = len(unique_ips)
ip_to_idx = {ip: i for i, ip in enumerate(unique_ips)}
logger.info('Network clustering: %d unique IPs from %d records', n_nodes, len(df))
if n_nodes < 3:
return {
'n_clusters': 0, 'n_noise': n_nodes, 'n_nodes': n_nodes,
'nodes': [], 'edges': [],
'all_noise': True, 'note': 'IP节点不足(<3),无法聚类',
}
# ── 2. GeoIP 解析所有 IP ───────────────────────────────────────────
node_geo = {} # ip → {lat, lon, city, country, isp, org}
for ip in unique_ips:
info = geo_cache.get(ip)
if not info:
info = geoip_lookup(ip)
if info:
node_geo[ip] = info
geo_resolved = len(node_geo)
logger.info('GeoIP resolved %d / %d IPs', geo_resolved, n_nodes)
# ── 3. 按 /24 子网分组 ─────────────────────────────────────────────
def _subnet24(ip: str) -> str | None:
parts = ip.split('.')
if len(parts) != 4:
return None
try:
return '.'.join(parts[:3]) + '.0/24'
except Exception:
return None
subnet_nodes: dict[str, list[str]] = {}
nodes_without_geo = []
for ip in unique_ips:
sn = _subnet24(ip)
if sn is None:
nodes_without_geo.append(ip)
continue
if sn not in subnet_nodes:
subnet_nodes[sn] = []
subnet_nodes[sn].append(ip)
# ── 4. 每个子网内 HDBSCAN 聚类 ───────────────────────────────────
cluster_labels: dict[str, int] = {} # ip → cluster_id
cluster_id_counter = 0
cluster_info: dict[int, dict] = {} # cluster_id → {size, center_lat, center_lon, subnet, nodes}
for sn, ips in subnet_nodes.items():
# 收集该子网内有 GeoIP 坐标的节点
geo_ips = []
coords_list = []
for ip in ips:
info = node_geo.get(ip)
if info and info.get('lat') is not None and info.get('lon') is not None:
geo_ips.append(ip)
coords_list.append([np.radians(float(info['lat'])), np.radians(float(info['lon']))])
n_in_subnet = len(ips)
n_with_geo = len(geo_ips)
# 小规模子网 → 直接作为一个簇
if n_in_subnet <= min_cluster_size or n_with_geo < 3:
cid = cluster_id_counter
cluster_id_counter += 1
for ip in ips:
cluster_labels[ip] = cid
# 计算中心坐标
center_lat, center_lon = None, None
if coords_list:
coords_arr = np.array(coords_list, dtype=np.float32)
center_lat = round(float(np.degrees(np.median(coords_arr[:, 0]))), 4)
center_lon = round(float(np.degrees(np.median(coords_arr[:, 1]))), 4)
cluster_info[cid] = {
'label': cid, 'size': n_in_subnet, 'n_geo': n_with_geo,
'subnet': sn, 'center_lat': center_lat, 'center_lon': center_lon,
'is_noise': False,
}
continue
# 大规模子网 → HDBSCAN 二次聚类
coords_arr = np.array(coords_list, dtype=np.float32)
n_coords = len(coords_arr)
# 去重坐标加微小抖动
if n_coords > 10:
_, unique_idx = np.unique(coords_arr, axis=0, return_index=True)
if len(unique_idx) < n_coords * 0.8:
jitter = np.random.RandomState(42).normal(0, 1e-6, coords_arr.shape).astype(np.float32)
coords_arr = coords_arr + jitter
try:
sub_clusterer = HDBSCAN(
min_cluster_size=max(2, min(n_coords // 20, min_cluster_size)),
min_samples=2,
metric='haversine',
cluster_selection_epsilon=cluster_selection_epsilon,
)
sub_labels = sub_clusterer.fit_predict(coords_arr)
except Exception as e:
logger.warning('Subnet %s HDBSCAN failed: %s — treating as single cluster', sn, e)
cid = cluster_id_counter
cluster_id_counter += 1
for ip in ips:
cluster_labels[ip] = cid
cluster_info[cid] = {
'label': cid, 'size': n_in_subnet, 'n_geo': n_with_geo,
'subnet': sn, 'center_lat': None, 'center_lon': None,
'is_noise': False,
}
continue
# 分配子标签
unique_sl = set(sub_labels)
for sl in unique_sl:
mask = sub_labels == sl
subset_ips = [geo_ips[i] for i in range(n_coords) if mask[i]]
if sl == -1:
# 噪声:每个噪声节点单独标记为噪声
for ip in subset_ips:
cluster_labels[ip] = -1
else:
cid = cluster_id_counter
cluster_id_counter += 1
for ip in subset_ips:
cluster_labels[ip] = cid
sub_coords = coords_arr[mask]
center_lat = round(float(np.degrees(np.median(sub_coords[:, 0]))), 4)
center_lon = round(float(np.degrees(np.median(sub_coords[:, 1]))), 4)
cluster_info[cid] = {
'label': cid, 'size': len(subset_ips), 'n_geo': len(subset_ips),
'subnet': sn, 'center_lat': center_lat, 'center_lon': center_lon,
'is_noise': False,
}
# 子网内无 GeoIP 的节点 → 分配到子网的主簇(第一个)
non_geo_ips = [ip for ip in ips if ip not in geo_ips]
if non_geo_ips:
subnet_cids = [cid for cid, info in cluster_info.items()
if info.get('subnet') == sn and not info.get('is_noise')]
if subnet_cids:
main_cid = min(subnet_cids, key=lambda c: cluster_info[c]['label'])
for ip in non_geo_ips:
cluster_labels[ip] = main_cid
cluster_info[main_cid]['size'] += len(non_geo_ips)
else:
for ip in non_geo_ips:
cluster_labels[ip] = -1
# 无子网的节点 → 噪声
for ip in nodes_without_geo:
cluster_labels[ip] = -1
# ── 5. 构建节点列表 ──────────────────────────────────────────────
# 检测服务器 IP(在 dst_ip 中出现过的 IP
server_ips = set()
for ip in dst_ips:
if ip and ip not in ('None', '', '+', '-'):
server_ips.add(ip)
nodes = []
for ip in unique_ips:
info = node_geo.get(ip, {})
cid = cluster_labels.get(ip, -1)
nodes.append({
'ip': ip,
'cluster_id': int(cid),
'lat': info.get('lat'),
'lon': info.get('lon'),
'city': info.get('city', ''),
'country': info.get('country', ''),
'region': info.get('region', ''),
'isp': info.get('isp', ''),
'asn': info.get('asn', ''),
'is_server': ip in server_ips,
})
# ── 6. 构建边列表(IP 对聚合通信统计) ──────────────────────────
pair_data: dict[tuple[str, str], dict] = {} # (src, dst) → {count, bytes, times}
bytes_list = None
if bytes_col and bytes_col in df.columns:
try:
bytes_list = df[bytes_col].cast(pl.Float64).to_list()
except Exception:
pass
times_list = None
if time_col and time_col in df.columns:
try:
times_list = df[time_col].cast(pl.Utf8).to_list()
except Exception:
pass
for i in range(len(df)):
s = str(src_ips[i]) if src_ips[i] not in (None, '', 'None') else None
d = str(dst_ips[i]) if dst_ips[i] not in (None, '', 'None') else None
if s is None or d is None:
continue
key = (s, d)
if key not in pair_data:
pair_data[key] = {'count': 0, 'total_bytes': 0.0, 'times': []}
pair_data[key]['count'] += 1
if bytes_list and i < len(bytes_list):
try:
pair_data[key]['total_bytes'] += float(bytes_list[i] or 0)
except (ValueError, TypeError):
pass
if times_list and i < len(times_list):
t = times_list[i]
if t and t not in ('None', ''):
pair_data[key]['times'].append(t)
edges = []
for (src, dst), data in pair_data.items():
edge = {
'source': src,
'target': dst,
'comm_count': data['count'],
'total_bytes': round(data['total_bytes'], 2),
}
# 时间统计
times = data.get('times', [])
if times:
times_sorted = sorted(times)
edge['first_seen'] = times_sorted[0][:25]
edge['last_seen'] = times_sorted[-1][:25]
# 时间规律:按小时统计
hour_counts = [0] * 24
for t_str in times:
try:
# Try ISO format or simple format
t_clean = t_str.strip()
if 'T' in t_clean or ' ' in t_clean:
# Extract hour
sep = 'T' if 'T' in t_clean else ' '
time_part = t_clean.split(sep)[-1]
hour = int(time_part.split(':')[0])
if 0 <= hour < 24:
hour_counts[hour] += 1
except Exception:
pass
peak_hours = sorted(range(24), key=lambda h: hour_counts[h], reverse=True)[:3]
edge['peak_hours'] = ','.join(f'{h:02d}' for h in peak_hours)
edge['time_count'] = len(times)
edges.append(edge)
# 按通信次数排序,限制返回前 500 条边
edges.sort(key=lambda e: e['comm_count'], reverse=True)
edges = edges[:500]
# ── 7. 存储结果 ──────────────────────────────────────────────────
n_clusters = len(cluster_info)
n_noise = sum(1 for v in cluster_labels.values() if v == -1)
entry['cluster_labels'] = cluster_labels # ip → cluster_id
entry['cluster_map'] = cluster_info
entry['cluster_mode'] = 'network'
entry['n_clusters'] = n_clusters
entry['n_noise'] = n_noise
entry['nodes'] = nodes
entry['edges_data'] = edges
entry['cluster_info'] = {
'all_noise': n_clusters == 0,
'note': '',
}
return {
'n_clusters': n_clusters,
'n_noise': n_noise,
'n_nodes': n_nodes,
'n_edges': len(edges),
'geo_resolved': geo_resolved,
'nodes': nodes,
'edges': edges,
'clusters': {str(k): v for k, v in cluster_info.items()},
'all_noise': n_clusters == 0,
'note': f'{n_nodes} 个IP节点, {len(edges)} 条边, {n_clusters} 个社区' if n_clusters > 0 else '未发现有效社区',
}
# ═══════════════════════════════════════════════════════════════════════════════
# Step 4: Cluster detail + Node detail + 边关系
# ═══════════════════════════════════════════════════════════════════════════════
@require_GET
def cluster_detail(request, label: int):
"""返回指定簇的详细画像(节点列表、GeoIP 属性、流量特征等)。"""
session_id = request.GET.get('session_id')
if not session_id:
return JsonResponse({'error': '缺少 session_id'}, status=400)
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期'}, status=400)
nodes = entry.get('nodes', [])
edges_data = entry.get('edges_data', [])
cluster_info = entry.get('cluster_map', {}).get(label, {})
if not nodes:
# Old format fallback
return _cluster_detail_legacy(entry, label)
# Filter nodes by cluster
cluster_nodes = [n for n in nodes if n.get('cluster_id') == label]
if not cluster_nodes:
return JsonResponse({'error': f'{label} 无数据'}, status=404)
# Collect IPs in this cluster
cluster_ips = set(n['ip'] for n in cluster_nodes)
# Find edges where both ends are in this cluster (internal)
internal_edges = []
related_edges = []
for e in edges_data:
src_in = e['source'] in cluster_ips
dst_in = e['target'] in cluster_ips
if src_in and dst_in:
internal_edges.append(e)
elif src_in or dst_in:
related_edges.append(e)
# Geo bounds
lats = [n['lat'] for n in cluster_nodes if n.get('lat') is not None]
lons = [n['lon'] for n in cluster_nodes if n.get('lon') is not None]
geo_bounds = None
if lats and lons:
geo_bounds = {
'lat_min': round(min(lats), 4), 'lat_max': round(max(lats), 4),
'lon_min': round(min(lons), 4), 'lon_max': round(max(lons), 4),
}
# ISP / city / country distribution
def _dist(items, key):
counts = Counter(i.get(key, '') or '(未知)' for i in items)
total = sum(counts.values()) or 1
return [{'value': k, 'count': v, 'pct': round(v / total * 100, 1)}
for k, v in counts.most_common(10)]
# Total internal traffic
total_internal_bytes = sum(e.get('total_bytes', 0) or 0 for e in internal_edges)
total_related_bytes = sum(e.get('total_bytes', 0) or 0 for e in related_edges)
profile = {
'label': label,
'size': len(cluster_nodes),
'cluster_info': cluster_info,
'unique_ips': sorted(cluster_ips)[:100],
'unique_ips_count': len(cluster_ips),
'ip_attributes': [{
'ip': n['ip'], 'lat': n.get('lat'), 'lon': n.get('lon'),
'city': n.get('city', ''), 'country': n.get('country', ''),
'isp': n.get('isp', ''), 'is_server': n.get('is_server', False),
} for n in cluster_nodes[:50]],
'top_cities': _dist(cluster_nodes, 'city'),
'top_countries': _dist(cluster_nodes, 'country'),
'top_isps': _dist(cluster_nodes, 'isp'),
'internal_edges': internal_edges[:100],
'related_edges': related_edges[:100],
'n_internal_edges': len(internal_edges),
'n_related_edges': len(related_edges),
'total_internal_bytes': round(total_internal_bytes, 2),
'total_related_bytes': round(total_related_bytes, 2),
'geo_bounds': geo_bounds,
}
return JsonResponse(profile)
def _cluster_detail_legacy(entry: dict, label: int) -> JsonResponse:
"""Fallback cluster detail for old record-based clustering format."""
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
meta = entry.get('meta', {})
cluster_map = entry.get('cluster_map', {})
geo_cache = entry.get('geo_cache', {})
if df is None:
return JsonResponse({'error': '数据未就绪'}, status=400)
if '_cluster_label' not in df.columns:
return JsonResponse({'error': '未执行聚类'}, status=400)
cluster_df = df.filter(pl.col('_cluster_label') == label)
rows = len(cluster_df)
if rows == 0:
return JsonResponse({'error': f'{label} 无数据'}, status=404)
cluster_info = cluster_map.get(label, {})
def _top_values(col_name: str, top_n: int = 5) -> list:
if not col_name or col_name not in df.columns:
return []
try:
vals = (
cluster_df[col_name].cast(pl.Utf8).fill_null('(空)')
.value_counts().sort('count', descending=True).head(top_n)
)
total = vals['count'].sum()
result = []
for row in vals.iter_rows():
result.append({
'value': str(row[0])[:80],
'count': int(row[1]),
'pct': round(int(row[1]) / total * 100, 1) if total else 0,
})
return result
except Exception:
return []
ips_col = meta.get('ips_col')
ipd_col = meta.get('ipd_col')
dport_col = meta.get('dport_col')
tls_ver_col = meta.get('tls_ver_col')
snam_col = meta.get('snam_col')
ispn_col = meta.get('ispn')
orgn_col = meta.get('orgn')
city_col = meta.get('city')
time_col = meta.get('time_col')
bytes_col = meta.get('bytes_col')
unique_ips = []
if ips_col and ips_col in cluster_df.columns:
try:
ip_vals = cluster_df[ips_col].cast(pl.Utf8).unique().to_list()
unique_ips = sorted(str(v) for v in ip_vals if v not in (None, '', 'None'))
except Exception:
pass
ip_attrs = []
for ip in unique_ips[:50]:
info = geo_cache.get(ip) or geoip_lookup(ip) or {}
ip_attrs.append({
'ip': ip,
'lat': info.get('lat'), 'lon': info.get('lon'),
'city': info.get('city', ''), 'country': info.get('country', ''),
})
profile = {
'label': label, 'size': rows,
'cluster_info': cluster_info,
'unique_ips': unique_ips[:100],
'unique_ips_count': len(unique_ips),
'ip_attributes': ip_attrs,
'top_dst_ips': _top_values(ipd_col) if ipd_col else [],
'top_dst_ports': _top_values(dport_col) if dport_col else [],
'top_tls_versions': _top_values(tls_ver_col) if tls_ver_col else [],
'top_sni': _top_values(snam_col) if snam_col else [],
'top_isp': _top_values(ispn_col) if ispn_col else [],
'top_org': _top_values(orgn_col) if orgn_col else [],
'top_cities': _top_values(city_col) if city_col else [],
'traffic': {},
}
if bytes_col and bytes_col in cluster_df.columns:
try:
profile['traffic']['total_bytes'] = round(float(cluster_df[bytes_col].cast(pl.Float64).sum()), 2)
except Exception:
pass
if time_col and time_col in cluster_df.columns:
try:
times = cluster_df[time_col].cast(pl.Utf8).drop_nulls()
if len(times) > 0:
profile['time_range'] = {'first': str(times.min())[:25], 'last': str(times.max())[:25]}
except Exception:
pass
return JsonResponse(profile)
@require_GET
def node_detail(request):
"""返回单个 IP 节点的详细属性及其关联边。"""
session_id = request.GET.get('session_id')
ip = request.GET.get('ip', '')
if not session_id or not ip:
return JsonResponse({'error': '缺少 session_id 或 ip'}, status=400)
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期'}, status=400)
nodes = entry.get('nodes', [])
edges_data = entry.get('edges_data', [])
# Find the node
node = next((n for n in nodes if n.get('ip') == ip), None)
if not node:
# Fallback: try GeoIP direct lookup
info = geoip_lookup(ip)
if info:
node = {
'ip': ip,
'cluster_id': -1,
'lat': info.get('lat'), 'lon': info.get('lon'),
'city': info.get('city', ''), 'country': info.get('country', ''),
'isp': info.get('isp', ''), 'is_server': False,
}
else:
return JsonResponse({'error': f'IP {ip} 不在网络中'}, status=404)
# Find related edges
related = [e for e in edges_data if e.get('source') == ip or e.get('target') == ip]
related.sort(key=lambda e: e.get('comm_count', 0), reverse=True)
# Peer IPs
peers = set()
for e in related:
if e.get('source') == ip:
peers.add(e.get('target'))
else:
peers.add(e.get('source'))
total_bytes = sum(e.get('total_bytes', 0) or 0 for e in related)
return JsonResponse({
'node': node,
'related_edges': related[:100],
'n_edges': len(related),
'peer_ips': sorted(peers)[:100],
'n_peers': len(peers),
'total_flow_bytes': round(total_bytes, 2),
})
@csrf_exempt
@require_POST
def edge_relationships(request):
"""返回边关系数据(使用预计算的边列表)。"""
body = json.loads(request.body)
session_id = body.get('session_id')
min_freq = int(body.get('min_freq', 1))
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期'}, status=400)
edges_data = entry.get('edges_data', [])
if not edges_data:
# Fallback: compute on-the-fly from old format
return _edge_relationships_legacy(entry, body)
# Filter by min frequency
filtered = [e for e in edges_data if e.get('comm_count', 0) >= min_freq]
filtered.sort(key=lambda e: e.get('comm_count', 0), reverse=True)
return JsonResponse({
'edges': filtered[:500],
'total_edges': len(filtered),
'min_freq': min_freq,
})
def _edge_relationships_legacy(entry: dict, body: dict) -> JsonResponse:
"""Fallback edge computation for old record-based format."""
min_freq = int(body.get('min_freq', 10))
df = entry.get('filtered_df') if entry.get('filtered_df') is not None else entry.get('df')
meta = entry.get('meta', {})
if df is None:
return JsonResponse({'error': '数据未就绪'}, status=400)
ips_col = meta.get('ips_col')
ipd_col = meta.get('ipd_col')
time_col = meta.get('time_col')
bytes_col = meta.get('bytes_col')
if not ips_col or not ipd_col:
return JsonResponse({'error': '缺少源/目的IP列'}, status=400)
if ips_col not in df.columns or ipd_col not in df.columns:
return JsonResponse({'error': 'IP列不在数据中'}, status=400)
try:
src_ips = df[ips_col].cast(pl.Utf8).to_list()
dst_ips = df[ipd_col].cast(pl.Utf8).to_list()
bytes_list = df[bytes_col].cast(pl.Float64).to_list() if bytes_col and bytes_col in df.columns else None
times_list = df[time_col].cast(pl.Utf8).to_list() if time_col and time_col in df.columns else None
pair_counts: dict[tuple[str, str], int] = {}
pair_bytes: dict[tuple[str, str], float] = {}
pair_times: dict[tuple[str, str], list[str]] = {}
for i in range(len(df)):
s = str(src_ips[i]) if src_ips[i] not in (None, '', 'None') else '?'
d = str(dst_ips[i]) if dst_ips[i] not in (None, '', 'None') else '?'
key = (s, d)
pair_counts[key] = pair_counts.get(key, 0) + 1
if bytes_list and i < len(bytes_list):
try:
pair_bytes[key] = pair_bytes.get(key, 0.0) + float(bytes_list[i] or 0)
except (ValueError, TypeError):
pass
if times_list and i < len(times_list):
v = times_list[i]
if v and v not in ('None', ''):
pair_times.setdefault(key, []).append(v)
edges = []
for (src, dst), count in pair_counts.items():
if count < min_freq:
continue
edge = {
'source': src, 'target': dst,
'comm_count': count,
'total_bytes': round(pair_bytes.get((src, dst), 0), 2),
}
times = pair_times.get((src, dst), [])
if times:
times_sorted = sorted(times)
edge['first_seen'] = times_sorted[0][:25]
edge['last_seen'] = times_sorted[-1][:25]
edge['time_count'] = len(times)
edges.append(edge)
edges.sort(key=lambda e: e['comm_count'], reverse=True)
edges = edges[:200]
return JsonResponse({
'edges': edges,
'total_pairs': len(pair_counts),
'total_edges': len(edges),
'min_freq': min_freq,
})
except Exception as e:
return JsonResponse({'error': f'边关系计算失败: {e}'}, status=400)
@require_GET
def column_values(request):
"""返回指定列的唯一值列表(供筛选器下拉建议)。"""
session_id = request.GET.get('session_id')
col_name = request.GET.get('col', '')
if not session_id or not col_name:
return JsonResponse({'error': '缺少 session_id 或 col'}, status=400)
entry = _cached(session_id)
if entry is None:
return JsonResponse({'error': '会话已过期'}, status=400)
df = entry.get('df')
if df is None or col_name not in df.columns:
return JsonResponse({'error': '列不存在'}, status=400)
try:
values = (
df[col_name]
.cast(pl.Utf8)
.fill_null('(空)')
.value_counts(sort=True)
.head(50)
)
result = [
{'value': str(row[0])[:80], 'count': int(row[1])}
for row in values.iter_rows()
]
return JsonResponse({'col': col_name, 'values': result, 'total': len(result)})
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
# ── Main page ────────────────────────────────────────────────────────────────
def index(request):
"""Render the simple analysis workbench page."""
return render(request, 'simple_analysis/simple_analysis.html')