421 lines
15 KiB
Python
421 lines
15 KiB
Python
# coding: utf-8
|
||
"""
|
||
iOS 批量测试域名统计工具
|
||
|
||
用法:
|
||
# 统计 output/ 下所有批次结果(原有逻辑)
|
||
python ios_count.py
|
||
|
||
# 只统计指定批次结果文件
|
||
python ios_count.py --batch_file output/ios_batch_result_20260225_151849.csv
|
||
"""
|
||
|
||
import os
|
||
import csv
|
||
import glob
|
||
import re
|
||
import argparse
|
||
from collections import defaultdict
|
||
from pathlib import Path
|
||
|
||
|
||
def build_bundle2info(mapping_csv: str) -> dict:
|
||
"""
|
||
从 appstore_mapping.csv 建立 bundle_id -> {app_id, app_name} 的映射。
|
||
同一 bundle_id 多条记录时保留最新(last_updated 最大)的那条。
|
||
"""
|
||
result = {}
|
||
if not os.path.isfile(mapping_csv):
|
||
return result
|
||
try:
|
||
with open(mapping_csv, 'r', encoding='utf-8-sig') as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
bid = row.get('bundle_id', '').strip()
|
||
if not bid:
|
||
continue
|
||
lu = row.get('last_updated', '').strip()
|
||
if bid not in result or lu > result[bid]['last_updated']:
|
||
result[bid] = {
|
||
'app_id': row.get('app_id', '').strip(),
|
||
'app_name': row.get('app_name', '').strip(),
|
||
'last_updated': lu,
|
||
}
|
||
except Exception as e:
|
||
print(f"[WARN] 读取 {mapping_csv} 失败: {e}")
|
||
return result
|
||
|
||
|
||
def build_appid2rawname(apps_csv: str) -> dict:
|
||
"""
|
||
从 ios_only_apps.csv 建立 纯数字App_ID -> Name 的映射。
|
||
"""
|
||
result = {}
|
||
if not os.path.isfile(apps_csv):
|
||
return result
|
||
try:
|
||
with open(apps_csv, 'r', encoding='utf-8-sig') as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
numeric_id = row.get('App_ID', '').strip()
|
||
name = row.get('Name', '').strip()
|
||
if numeric_id and name:
|
||
result[numeric_id] = name
|
||
except Exception as e:
|
||
print(f"[WARN] 读取 {apps_csv} 失败: {e}")
|
||
return result
|
||
|
||
|
||
def extract_second_level_domain(domain):
|
||
"""提取二级域名,格式:*.example.com"""
|
||
if not domain:
|
||
return ""
|
||
domain = str(domain).lower().replace('http://', '').replace('https://', '').strip().strip('"\'')
|
||
parts = domain.split('.')
|
||
if len(parts) <= 2:
|
||
return domain
|
||
return "*." + ".".join(parts[-2:])
|
||
|
||
|
||
def load_success_bundle_ids(output_dir):
|
||
"""
|
||
从 output/ 下所有 ios_batch_result_*.csv 中读取 SUCCESS 记录。
|
||
若同一 bundle_id 多次出现,取步数/时长最大值(保留最佳测试结果)。
|
||
|
||
返回: dict {
|
||
bundle_id: {
|
||
'app_name': str,
|
||
'raw_app_name': str,
|
||
'app_id': str,
|
||
'timestamp': str, # 最新记录的 test_timestamp(%Y%m%d_%H%M%S)
|
||
'droidbot_steps': int,
|
||
'guiagent_steps': int,
|
||
'duration_seconds': int,
|
||
}
|
||
}
|
||
"""
|
||
bundle_ids = {}
|
||
pattern = os.path.join(output_dir, 'ios_batch_result_*.csv')
|
||
csv_files = sorted(glob.glob(pattern))
|
||
|
||
if not csv_files:
|
||
print(f"未找到 ios_batch_result_*.csv 文件(搜索路径: {output_dir})")
|
||
return bundle_ids
|
||
|
||
print(f"共找到 {len(csv_files)} 个批次结果文件:")
|
||
for f in csv_files:
|
||
print(f" {os.path.basename(f)}")
|
||
|
||
for csv_path in csv_files:
|
||
_load_csv_into(csv_path, bundle_ids)
|
||
|
||
print(f"\n共找到 {len(bundle_ids)} 个 SUCCESS 状态的应用(bundle_id)。")
|
||
return bundle_ids
|
||
|
||
|
||
def load_success_bundle_ids_from_file(csv_path):
|
||
"""
|
||
从单个 ios_batch_result_*.csv 文件读取所有 SUCCESS 记录。
|
||
同一 bundle_id 多次出现时取步数/时长最大值。
|
||
|
||
返回值格式同 load_success_bundle_ids。
|
||
"""
|
||
bundle_ids = {}
|
||
if not os.path.isfile(csv_path):
|
||
print(f"[ERROR] 文件不存在: {csv_path}")
|
||
return bundle_ids
|
||
|
||
print(f"使用批次结果文件: {csv_path}")
|
||
_load_csv_into(csv_path, bundle_ids)
|
||
print(f"共找到 {len(bundle_ids)} 个 SUCCESS 状态的应用(bundle_id)。")
|
||
return bundle_ids
|
||
|
||
|
||
def _load_csv_into(csv_path, bundle_ids: dict):
|
||
"""将单个批次 CSV 中的 SUCCESS 记录合并进 bundle_ids 字典。"""
|
||
|
||
def safe_int(val):
|
||
try:
|
||
return int(float(val or 0))
|
||
except (ValueError, TypeError):
|
||
return 0
|
||
|
||
try:
|
||
with open(csv_path, 'r', encoding='utf-8-sig') as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
status = row.get('status', '').strip()
|
||
bundle_id = row.get('bundle_id', '').strip()
|
||
if status != 'SUCCESS' or not bundle_id:
|
||
continue
|
||
|
||
app_name = row.get('app_name', '').strip()
|
||
raw_app_name = row.get('raw_app_name', '').strip()
|
||
app_id = row.get('app_id', '').strip()
|
||
timestamp = row.get('timestamp', '').strip()
|
||
droidbot_steps = safe_int(row.get('droidbot_steps', 0))
|
||
guiagent_steps = safe_int(row.get('guiagent_steps', 0))
|
||
duration_seconds = safe_int(row.get('duration_seconds', 0))
|
||
|
||
if bundle_id not in bundle_ids:
|
||
bundle_ids[bundle_id] = {
|
||
'app_name': app_name,
|
||
'raw_app_name': raw_app_name,
|
||
'app_id': app_id,
|
||
'timestamp': timestamp,
|
||
'droidbot_steps': droidbot_steps,
|
||
'guiagent_steps': guiagent_steps,
|
||
'duration_seconds': duration_seconds,
|
||
}
|
||
else:
|
||
# 同一应用多次出现,取步数/时长最大值;时间戳取最新(字符串比较即可)
|
||
cur = bundle_ids[bundle_id]
|
||
cur['droidbot_steps'] = max(cur['droidbot_steps'], droidbot_steps)
|
||
cur['guiagent_steps'] = max(cur['guiagent_steps'], guiagent_steps)
|
||
cur['duration_seconds'] = max(cur['duration_seconds'], duration_seconds)
|
||
if timestamp > cur['timestamp']:
|
||
cur['timestamp'] = timestamp
|
||
cur['app_name'] = app_name
|
||
cur['raw_app_name'] = raw_app_name
|
||
cur['app_id'] = app_id
|
||
except Exception as e:
|
||
print(f"读取 {csv_path} 时出错: {e}")
|
||
|
||
|
||
def bundle_id_to_dir_prefix(bundle_id):
|
||
"""
|
||
将 bundle_id 中的点号替换为下划线,作为测试结果目录的前缀。
|
||
例:com.anthropic.claude -> com_anthropic_claude
|
||
"""
|
||
return bundle_id.replace('.', '_')
|
||
|
||
|
||
def find_test_dir_by_timestamp(test_base_dir, bundle_id, timestamp):
|
||
"""
|
||
单批次模式:通过 bundle_id + timestamp 直接组装测试目录路径。
|
||
|
||
目录格式:{test_base_dir}/{bundle_prefix}_iOS_{timestamp}
|
||
timestamp 格式:%Y%m%d_%H%M%S(与 ios_test.py 输出目录名一致)
|
||
|
||
若精确路径不存在,回退到模糊查找该 timestamp 最接近的目录。
|
||
"""
|
||
prefix = bundle_id_to_dir_prefix(bundle_id)
|
||
exact = os.path.join(test_base_dir, f"{prefix}_iOS_{timestamp}")
|
||
if os.path.isdir(exact):
|
||
return exact
|
||
|
||
# 回退:模糊查找前缀匹配的目录中时间戳最接近的
|
||
return find_latest_test_dir(test_base_dir, bundle_id)
|
||
|
||
|
||
def find_latest_test_dir(output_dir, bundle_id):
|
||
"""
|
||
多批次模式:查找时间戳最大(最新)的测试结果目录。
|
||
目录命名格式:{bundle_id前缀}_iOS_{时间戳}
|
||
"""
|
||
prefix = bundle_id_to_dir_prefix(bundle_id)
|
||
pattern = os.path.join(output_dir, f"{prefix}_iOS_*")
|
||
candidates = [d for d in glob.glob(pattern) if os.path.isdir(d)]
|
||
|
||
if not candidates:
|
||
return None
|
||
|
||
def extract_timestamp(path):
|
||
m = re.search(r'_iOS_(\d{8}_\d{6})$', os.path.basename(path))
|
||
return m.group(1) if m else ''
|
||
|
||
return sorted(candidates, key=extract_timestamp)[-1]
|
||
|
||
|
||
def parse_flows_csv(test_dir, bundle_id):
|
||
"""
|
||
解析测试目录下 traffic/*.pcap.flows.csv,提取非空 TargetDomain 域名集合。
|
||
返回: (domains_set, second_level_domains_set)
|
||
"""
|
||
domains = set()
|
||
second_level_domains = set()
|
||
|
||
traffic_dir = os.path.join(test_dir, 'traffic')
|
||
if not os.path.isdir(traffic_dir):
|
||
return domains, second_level_domains
|
||
|
||
flows_files = glob.glob(os.path.join(traffic_dir, '*.flows.csv'))
|
||
for flows_path in flows_files:
|
||
try:
|
||
with open(flows_path, 'r', encoding='utf-8-sig') as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
domain = row.get('TargetDomain', '').strip()
|
||
if domain:
|
||
domains.add(domain)
|
||
sld = extract_second_level_domain(domain)
|
||
if sld:
|
||
second_level_domains.add(sld)
|
||
except Exception as e:
|
||
print(f"解析 {flows_path} 时出错: {e}")
|
||
|
||
return domains, second_level_domains
|
||
|
||
|
||
def process_all_data(batch_file: str = None):
|
||
"""
|
||
主处理函数。
|
||
|
||
Args:
|
||
batch_file: 若指定,只处理该批次 CSV;否则处理 output/ 下所有批次。
|
||
"""
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
output_dir = os.path.join(os.path.dirname(script_dir), 'output')
|
||
if not os.path.isdir(output_dir):
|
||
output_dir = os.path.join(os.getcwd(), 'output')
|
||
|
||
# 测试结果子目录
|
||
test_base_dir = os.path.join(output_dir, 'ios_test')
|
||
if not os.path.isdir(test_base_dir):
|
||
test_base_dir = output_dir
|
||
print(f"[WARN] output/ios_test 目录不存在,回退到 output/ 查找测试目录")
|
||
|
||
print(f"批次结果目录: {output_dir}")
|
||
print(f"测试结果目录: {test_base_dir}\n")
|
||
|
||
# ── 加载回退用映射表 ─────────────────────────────────────────────────────
|
||
mapping_csv = os.path.join(output_dir, 'ios', 'appstore_mapping.csv')
|
||
apps_csv = os.path.join(script_dir, 'ios_only_apps.csv')
|
||
bundle2info = build_bundle2info(mapping_csv)
|
||
appid2rawname = build_appid2rawname(apps_csv)
|
||
|
||
# ── 1. 读取 SUCCESS 记录 ──────────────────────────────────────────────
|
||
single_mode = batch_file is not None
|
||
if single_mode:
|
||
bundle_ids = load_success_bundle_ids_from_file(batch_file)
|
||
else:
|
||
bundle_ids = load_success_bundle_ids(output_dir)
|
||
|
||
if not bundle_ids:
|
||
print("没有找到 SUCCESS 状态的应用,退出。")
|
||
return
|
||
|
||
# ── 2. 找测试目录,解析域名数据 ──────────────────────────────────────
|
||
app_data = {}
|
||
missing_dirs = []
|
||
|
||
print("\n开始查找并解析测试结果目录...")
|
||
for bundle_id, info in sorted(bundle_ids.items()):
|
||
if single_mode and info.get('timestamp'):
|
||
# 单批次模式:精确定位(bundle_id + timestamp)
|
||
test_dir = find_test_dir_by_timestamp(test_base_dir, bundle_id, info['timestamp'])
|
||
else:
|
||
# 多批次模式:取最新目录
|
||
test_dir = find_latest_test_dir(test_base_dir, bundle_id)
|
||
|
||
if not test_dir:
|
||
missing_dirs.append(bundle_id)
|
||
continue
|
||
|
||
domains, second_level_domains = parse_flows_csv(test_dir, bundle_id)
|
||
|
||
# ── 回退填充 app_id / raw_app_name ──────────────────────────────────
|
||
app_id = info.get('app_id', '')
|
||
app_name = info['app_name']
|
||
raw_app_name = info.get('raw_app_name', '')
|
||
|
||
if not app_id or not raw_app_name:
|
||
# 1. 用 bundle_id 查 appstore_mapping 得 app_id
|
||
binfo = bundle2info.get(bundle_id, {})
|
||
if not app_id:
|
||
app_id = binfo.get('app_id', '')
|
||
if not app_name:
|
||
app_name = binfo.get('app_name', '')
|
||
|
||
if not raw_app_name and app_id:
|
||
# 2. 去掉字母前缀(如 "id" -> 纯数字),查 ios_only_apps
|
||
numeric_id = re.sub(r'^[^0-9]+', '', app_id)
|
||
raw_app_name = appid2rawname.get(numeric_id, '')
|
||
|
||
app_data[bundle_id] = {
|
||
'app_name': app_name,
|
||
'raw_app_name': raw_app_name,
|
||
'app_id': app_id,
|
||
'test_dir': os.path.basename(test_dir),
|
||
'domains': domains,
|
||
'second_level_domains': second_level_domains,
|
||
'droidbot_steps': info['droidbot_steps'],
|
||
'guiagent_steps': info['guiagent_steps'],
|
||
'duration_seconds': info['duration_seconds'],
|
||
}
|
||
|
||
print(f"\n成功处理 {len(app_data)} 个应用。")
|
||
if missing_dirs:
|
||
print(f"以下 {len(missing_dirs)} 个 bundle_id 未找到测试结果目录:")
|
||
for bid in missing_dirs:
|
||
print(f" {bid}")
|
||
|
||
# ── 3. 输出汇总 CSV ────────────────────────────────────────────────────
|
||
from datetime import datetime
|
||
output_file = os.path.join(output_dir, f'ios_app_domain_summary_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv')
|
||
headers = [
|
||
'app_name', 'raw_app_name', 'app_id', 'bundle_id',
|
||
'unique_domain_count', 'unique_second_level_domain_count',
|
||
'droidbot_steps', 'guiagent_steps', 'duration_seconds',
|
||
'test_dir', 'unique_domain_names', 'unique_second_level_domains',
|
||
]
|
||
|
||
try:
|
||
with open(output_file, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||
writer = csv.writer(csvfile)
|
||
writer.writerow(headers)
|
||
|
||
count = 0
|
||
for bundle_id in sorted(app_data.keys()):
|
||
info = app_data[bundle_id]
|
||
domains = sorted(info['domains'])
|
||
slds = sorted(info['second_level_domains'])
|
||
|
||
writer.writerow([
|
||
info['app_name'],
|
||
info['raw_app_name'],
|
||
info['app_id'],
|
||
bundle_id,
|
||
len(domains),
|
||
len(slds),
|
||
info['droidbot_steps'],
|
||
info['guiagent_steps'],
|
||
info['duration_seconds'],
|
||
info['test_dir'],
|
||
', '.join(domains),
|
||
', '.join(slds),
|
||
])
|
||
count += 1
|
||
|
||
print(f"\n处理完成!统计了 {count} 个 App。")
|
||
print(f"结果已保存至: {output_file}")
|
||
|
||
except Exception as e:
|
||
print(f"写入 CSV 时出错: {e}")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="iOS 批量测试域名统计工具",
|
||
formatter_class=argparse.RawTextHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
# 统计所有批次
|
||
python ios_count.py
|
||
|
||
# 只统计指定批次
|
||
python ios_count.py --batch_file output/ios_batch_result_20260225_151849.csv
|
||
"""
|
||
)
|
||
parser.add_argument(
|
||
'--batch_file', '-f',
|
||
type=str, default=None,
|
||
help='指定单个 ios_batch_result_*.csv 文件路径;不指定则处理 output/ 下所有批次'
|
||
)
|
||
args = parser.parse_args()
|
||
process_all_data(batch_file=args.batch_file)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|