#!/usr/bin/env python3 """Join frozen site-purpose labels with v2.0; produce a local review and audit pack.""" import argparse from collections import Counter, defaultdict import csv import html import json from pathlib import Path import shutil from freeze import LABELS, BASES, csv_rows, dump, load, sha BASE_SHA = { 'sites.csv': 'da47b5ae060c1a67f5131b9f40e413416f9d7a3f010456c5809316efca36dcd9', 'metrics.json': 'edbdec1742dc52d3e305b9e6fdb11c56163976561255cab6c9563a6ea147f1cc', } def write_csv(path, rows): if not rows: raise ValueError('Cannot publish a headerless CSV') with Path(path).open('w', encoding='utf-8', newline='') as f: w = csv.DictWriter(f, fieldnames=list(rows[0]), lineterminator='\n') w.writeheader() w.writerows({k: json.dumps(v, ensure_ascii=False) if isinstance(v, (list, dict)) else v for k, v in r.items()} for r in rows) def category_summary(rows, keys): result = [] for category, label in LABELS.items(): subset = [r for r in rows if r['category'] == category] valid = [r for r in subset if r['robots_analyzable'] == 1] count = sum(r['ai_root_disallow'] for r in valid) comparable = len(valid) >= 20 and category not in ('unknown', 'other_known') reason = '' if comparable else ('heterogeneous_bucket' if category == 'other_known' else 'unknown_type' if category == 'unknown' else 'analyzable_n_below_20') result.append({ 'category': category, 'label_zh': label, 'total': len(subset), 'robots_analyzable': len(valid), 'robots_unanalyzable': len(subset) - len(valid), 'ai_root_disallow': count, 'restriction_percent': round(100 * count / len(valid), 1) if comparable else None, 'rate_suppressed_reason': reason, 'llms_candidates': sum(r.get('llms_observation') == 'minimum_h1_candidate' for r in subset), 'llms_unknown': sum(r.get('llms_observation') == 'unknown' for r in subset), 'llms_candidate_percent_all_category': round(100 * sum( r.get('llms_observation') == 'minimum_h1_candidate' for r in subset) / len(subset), 1) if subset and category not in ('unknown', 'other_known') else None, 'borderline_n': sum(r['borderline_review_flag'] for r in subset), }) return result def alias_sensitivity(rows, keys): buckets = defaultdict(list) for r in rows: if r['robots_analyzable']: buckets[r['robots_final_url']].append(r) units, groups = [], [] for url, members in sorted(buckets.items()): categories = {r['category'] for r in members} vectors = {tuple(r[k + '_policy'] for k in keys) for r in members} state = 'category_conflict' if len(categories) != 1 else 'policy_conflict' if len(vectors) != 1 else 'included' if state == 'included': units.append(members[0]) if len(members) > 1 or state != 'included': groups.append({'robots_final_url': url, 'member_n': len(members), 'domains': [r['domain'] for r in members], 'categories': sorted(categories), 'policy_vector_n': len(vectors), 'state': state}) categories_summary = [{k: v for k, v in g.items() if not k.startswith('llms_')} for g in category_summary(units, keys)] return { 'unit': 'exact final robots URL among analyzable responses; not company or unique website', 'all_url_groups': len(buckets), 'included_url_groups': len(units), 'excluded_category_conflict_groups': sum(g['state'] == 'category_conflict' for g in groups), 'excluded_policy_conflict_groups': sum(g['state'] == 'policy_conflict' for g in groups), 'included_ai_root_disallow': sum(r['ai_root_disallow'] for r in units), 'llms_collapsed_rates_computed': False, 'categories': categories_summary, }, groups def review_html(rows, summary): e = lambda v: html.escape(str(v), quote=True) options = ''.join('' for k, v in LABELS.items()) bases = ''.join('' for k, v in BASES.items()) body = [] for r in rows: source = e(r['source_url']) if r['source_url'] else '背景知识;未逐站验证当前首页' source_note = r['source_checked_at'] or '无本轮页面验证时间' body.append('' + e(r['rank']) + '' + e(r['domain']) + '' + e(r['category_label']) + '' + e(r['basis_label']) + (' · 继承试点' if r['inherited_pilot'] else '') + '' + e(r['note_zh']) + '
来源与核对信息' + source + '
' + e(source_note) + '
原首页 HTTP ' + e(r['archive_home_status']) + ' · ' + e(r['archive_home_final_url']) + '
补抓 HTTP ' + e(r['followup_home_status']) + '
证据 SHA256 ' + e(r['evidence_body_sha256']) + '
') return ''' 1,000 域名用途分类复核

1,000 域名用途分类复核

已归类 ''' + str(summary['assigned_n']) + ''' / 1,000,未知 ''' + str(summary['unknown_n']) + '''。按现有知识优先、归档核对、不明确再补抓首页处理。类别描述主要网站或服务用途,不能直接当作组织行业、所有权或当前首页验证结果。

原试点 100 个标签原样继承,其余 900 个先分类再合并政策数据。此表不显示 AI 检测结果;页面不载入原站图片、脚本或自动访问外站。政策统计见 全量分析,可下载 分类 CSV。

1000 个域名
''' + ''.join(body) + '''
排名域名用途依据判断与来源

背景知识判断与实际页面证据分开记录;HTTP 200 不保证内容充分。边界标记仅覆盖本次明确记录的混合功能项,不是完整的不确定性评分。同人复核不是独立标注验证。

''' def analysis_html(summary): e = lambda v: html.escape(str(v)) tr = [] for g in summary['categories']: pct = '仅列计数' if g['restriction_percent'] is None else str(g['restriction_percent']) + '%' tr.append('' + e(g['label_zh']) + '' + str(g['total']) + '' + str(g['robots_analyzable']) + '' + str(g['ai_root_disallow']) + '' + pct + '' + str(g['llms_candidates']) + ' / ' + str(g['total']) + '') return '''全量网站用途分层分析

全量 1,000 域名:网站用途与 AI 抓取政策

研究补充分析草稿,尚未写入线上文章。原 v2.0 快照保持不变:854 份可分析 robots,249 份对至少一个所测 AI 标识的根路径求值为 Disallow;llms.txt 最低 H1 候选 156 / 1,000。

分类已覆盖全部 1,000 个观察单位:''' + str(summary['assigned_n']) + ''' 个已归类、''' + str(summary['unknown_n']) + ''' 个未知。类别内的政策比例以可分析 robots 为分母;llms 候选计数以类别全部域名为分母。这两个分母不能互换。

''' + ''.join(tr) + '''
网站主要用途总数可分析 robotsAI 根路径 Disallow比例llms 最低候选 / 全组

仅对可分析 robots ≥ 20 的明确用途类别显示比例。“其他”内部包含内容目录、投注、停放页等异质用途,未知项也不作类别比例比较。20 是展示门槛,不代表统计显著性或可推断整个互联网。

Disallow 包括通配符规则继承;它是本次协议匹配结果,不代表真实 AI 爬虫身份验证、网络可达性、索引收录或引用结果。llms 候选只满足最低文件结构要求,不等于完整合规或被模型采用。

标签使用背景知识、归档与补抓混合来源;同一主体可有多个域名,地域入口与重定向会影响类别组成。已另做完全相同 robots 最终 URL 合并、分类来源分组及边界项排除分析。详见 方法与解释、完整汇总、统计 CSV、1,000 域名分类表。

''' def build(frozen, base, work, output): freeze = load(frozen / 'classification-freeze.json') if sha(frozen / 'classifications.json') != freeze['labels_sha256']: raise ValueError('Frozen classifications changed') for name, digest in freeze['inputs'].items(): if (work / name).is_file() and sha(work / name) != digest: raise ValueError('Frozen decision input changed: ' + name) for name, value in BASE_SHA.items(): if sha(base / name) != value: raise ValueError('Base release changed: ' + name) labels = load(frozen / 'classifications.json') source = csv_rows(base / 'sites.csv') sites = {r['domain']: r for r in source} if (len(labels) != 1000 or len({r['domain'] for r in labels}) != 1000 or len(sites) != 1000 or {r['domain'] for r in labels} != set(sites)): raise ValueError('Join requires an exact one-to-one cohort') metrics = load(base / 'metrics.json') keys = [b['key'] for b in metrics['bots'] if b['group'] != 'baseline'] rows = [] for r in labels: s = sites[r['domain']] if r['rank'] != int(s['rank']): raise ValueError('Rank mismatch') valid = s['robots_analyzable'] == '1' rows.append(r | {'robots_analyzable': int(valid), 'ai_root_disallow': int(any(s[k + '_policy'] == 'disallow' for k in keys)) if valid else None, 'robots_final_url': s['robots_final_url'], 'llms_observation': s['llms_observation'], **{k + '_policy': s[k + '_policy'] for k in keys}}) total_valid = sum(r['robots_analyzable'] for r in rows) total_restricted = sum(r['ai_root_disallow'] for r in rows if r['robots_analyzable']) candidates = sum(r['llms_observation'] == 'minimum_h1_candidate' for r in rows) if (total_valid, total_restricted, candidates) != (854, 249, 156): raise ValueError('Frozen overall metrics not reproduced') alias, alias_groups = alias_sensitivity(rows, keys) bases = {basis: category_summary([r for r in rows if r['basis'] == basis], keys) for basis in BASES} followup_run = work / 'fetch-01/run.json' if not followup_run.exists(): followup_run = work / 'followup-run.json' summary = { 'version': 'site-types-full-v3', 'n': len(rows), 'assigned_n': sum(r['category'] != 'unknown' for r in rows), 'unknown_n': sum(r['category'] == 'unknown' for r in rows), 'unknown_domains': [r['domain'] for r in rows if r['category'] == 'unknown'], 'basis_counts': dict(sorted(Counter(r['basis'] for r in rows).items())), 'totals': {'robots_analyzable': total_valid, 'ai_root_disallow': total_restricted, 'llms_candidates': candidates, 'llms_unknown': sum(r['llms_observation'] == 'unknown' for r in rows)}, 'categories': category_summary(rows, keys), 'basis_sensitivity': bases, 'exclude_recorded_borderline_sensitivity': category_summary([r for r in rows if not r['borderline_review_flag']], keys), 'alias_sensitivity': alias, 'classification_freeze': freeze, 'base_sha256': BASE_SHA, 'followup_homepage_requests': load(followup_run), 'paid_ai_calls': 0, 'new_robots_or_llms_requests': 0, 'limits': ['Knowledge-assisted purpose labels, not formal industry/ownership verification', 'Single reviewer with prior benchmark exposure; no independent validation or accuracy estimate', 'Frozen popularity cohort, not a representative random-web sample', 'Static archived content and later follow-up may show different regional destinations', 'Exact final robots URL sensitivity does not deduplicate companies', 'Heterogeneous other/unknown buckets and analyzable cells below 20 have counts only'], } bot_rows = [] for g in summary['categories']: valid = [r for r in rows if r['category'] == g['category'] and r['robots_analyzable']] for key in keys: n = sum(r[key + '_policy'] == 'disallow' for r in valid) bot_rows.append({'category': g['category'], 'bot': key, 'analyzable_n': len(valid), 'root_disallow_n': n, 'percent': round(100 * n / len(valid), 1) if not g['rate_suppressed_reason'] else None}) rank_rows = [] for low, high in ((1, 100), (101, 500), (501, 1726)): for category in LABELS: members = [r for r in rows if low <= r['rank'] <= high and r['category'] == category] valid = [r for r in members if r['robots_analyzable']] rank_rows.append({'rank_min': low, 'rank_max': high, 'category': category, 'total': len(members), 'robots_analyzable': len(valid), 'ai_root_disallow': sum(r['ai_root_disallow'] for r in valid)}) output.mkdir(parents=True, exist_ok=False) write_csv(output / 'classifications.csv', labels) write_csv(output / 'classified-sites.csv', rows) write_csv(output / 'category-summary.csv', summary['categories']) write_csv(output / 'bot-category-summary.csv', bot_rows) write_csv(output / 'rank-category-counts.csv', rank_rows) if alias_groups: write_csv(output / 'alias-groups.csv', alias_groups) dump(output / 'summary.json', summary) (output / 'review.html').write_text(review_html(labels, summary), encoding='utf-8') (output / 'analysis.html').write_text(analysis_html(summary), encoding='utf-8') for name in ('classifications.json', 'classification-freeze.json'): shutil.copyfile(frozen / name, output / name) for name in ('knowledge-pass.json', 'archive-followup-decisions.json', 'quality-pass.json', 'source-register.json'): shutil.copyfile(work / name, output / name) shutil.copyfile(followup_run, output / 'followup-run.json') return summary if __name__ == '__main__': p = argparse.ArgumentParser(description=__doc__) for name in ('frozen', 'base', 'work', 'output'): p.add_argument('--' + name, required=True, type=Path) result = build(**vars(p.parse_args())) print(json.dumps({'assigned': result['assigned_n'], 'unknown': result['unknown_n'], 'alias_units': result['alias_sensitivity']['included_url_groups']}))