#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
构建 m1_competitor_sources.json 与 m2_public_sources.json。

设计原则：写入 JSON 的「实测状态码」一律来自本脚本运行时的真实 HTTP 探测，
不手工填写，从根本上避免「状态码与实测不符」被整批退回。
"""
import json
import os
import time
import urllib.error
import urllib.request

OUT_DIR = os.path.dirname(os.path.abspath(__file__))
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
DELAY = 1.2  # 限速 >= 1.2 秒/请求


def _ssl_context():
    """构造 SSL 上下文。

    macOS 自带 Python(CommandLineTools) 的证书库不完整，部分国内站点证书链
    校验会失败（CERTIFICATE_VERIFY_FAILED）。优先用 certifi 的 CA bundle；
    仍失败则降级为不校验，并在结果中显式标注，绝不把证书问题误报为「不可达」。
    """
    import ssl
    try:
        import certifi  # type: ignore
        return ssl.create_default_context(cafile=certifi.where()), False
    except Exception:  # noqa: BLE001
        ctx = ssl.create_default_context()
        try:
            ctx.load_default_certs()
        except Exception:  # noqa: BLE001
            pass
        return ctx, False


def probe(url, timeout=25):
    """真实发起一次 GET，返回 (状态码, 最终URL, 说明)。"""
    req = urllib.request.Request(url, headers={
        "User-Agent": UA,
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "zh-CN,zh;q=0.9",
        "Connection": "close",
    })
    ctx, _ = _ssl_context()
    code, final, cert_note = None, url, ""
    try:
        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
            code, final = r.status, r.geturl()
    except urllib.error.HTTPError as e:
        code, final = e.code, url
    except urllib.error.URLError as e:
        reason = e.reason
        if "CERTIFICATE_VERIFY_FAILED" in str(reason):
            # 证书链问题：降级重试一次（仅用于读取公开页面的状态码）
            import ssl as _ssl
            ctx2 = _ssl.create_default_context()
            ctx2.check_hostname = False
            ctx2.verify_mode = _ssl.CERT_NONE
            try:
                with urllib.request.urlopen(req, timeout=timeout, context=ctx2) as r:
                    return r.status, r.geturl(), "本机CA证书库不完整，已跳过证书校验"
            except Exception as e2:  # noqa: BLE001
                return "不可达", url, f"证书校验失败且降级请求仍失败: {type(e2).__name__}"
        return "不可达", url, f"URLError: {reason}"
    except Exception as e:  # noqa: BLE001
        return "异常", url, f"{type(e).__name__}: {e}"
    finally:
        time.sleep(DELAY)

    note = ""
    if code in (403, 412, 429, 401):
        note = "反爬/风控"
    return code, final, note


CACHE_FILE = os.path.join(OUT_DIR, ".probe_cache.json")

# 需要用 POST 探测的接口：GET 会得到误导性的状态码。
# 键为 URL，值为 (form_body, referer)。
POST_PROBES = {
    "http://www.cninfo.com.cn/new/hisAnnouncement/query": (
        "stock=688105%2C9900046657&tabName=fulltext&pageSize=5&pageNum=1&column=sse&category=&seDate=&isHLtitle=true",
        "http://www.cninfo.com.cn/new/commonUrl?url=disclosure/list/notice"),
    "http://www.cninfo.com.cn/new/information/topSearch/query": (
        "keyWord=688105&maxNum=10",
        "http://www.cninfo.com.cn/new/index"),
}


def _load_cache():
    try:
        with open(CACHE_FILE, encoding="utf-8") as f:
            return json.load(f)
    except Exception:  # noqa: BLE001
        return {}


def _save_cache(cache):
    with open(CACHE_FILE, "w", encoding="utf-8") as f:
        json.dump(cache, f, ensure_ascii=False, indent=1)


def _probe_post(url, body, referer):
    """body 既可以是已编码的字符串，也可以是 dict。"""
    import urllib.parse
    if isinstance(body, str):
        data = body.encode()
    else:
        data = urllib.parse.urlencode(body).encode()
    req = urllib.request.Request(url, data=data, headers={
        "User-Agent": UA,
        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
        "X-Requested-With": "XMLHttpRequest",
        "Referer": referer,
        "Accept": "application/json, text/javascript, */*; q=0.01",
        "Content-Length": str(len(data)),
    })
    try:
        with urllib.request.urlopen(req, timeout=25) as r:
            payload = r.read(400)
            code = r.status
        ok = payload.lstrip()[:1] in (b"{", b"[")
        return code, url, "" if ok else "返回非JSON，参数可能有误"
    except urllib.error.HTTPError as e:
        return e.code, url, "POST返回错误"
    except Exception as e:  # noqa: BLE001
        return "异常", url, f"{type(e).__name__}"
    finally:
        time.sleep(DELAY)


def probe_cached(url):
    """带磁盘缓存的探测：避免重跑时对同一站点反复请求。"""
    cache = _load_cache()
    if url in cache:
        hit = cache[url]
        return hit["code"], hit["final"], hit["flag"]
    if url in POST_PROBES:
        body, ref = POST_PROBES[url]
        code, final, flag = _probe_post(url, body, ref)
    else:
        code, final, flag = probe(url)
    cache[url] = {"code": code, "final": final, "flag": flag}
    _save_cache(cache)
    return code, final, flag


def make_entry(kind, url, note):
    code, final, flag = probe_cached(url)
    e = {"类型": kind, "URL": url, "请求方法": "POST" if url in POST_PROBES else "GET",
         "实测状态码": code}
    bits = []
    if note:
        bits.append(note)
    if final and final.rstrip("/") != url.rstrip("/"):
        bits.append(f"跳转至 {final}")
    if flag:
        bits.append(flag)
    e["备注"] = "；".join(bits) if bits else ""
    return e


# ---------------------------------------------------------------- M1 数据源
M1 = [
    {
        "品牌": "诺唯赞",
        "上市状态": "A股上市",
        "代码": "688105",
        "entries": [
            ("官网首页", "https://www.vazyme.com/", "主站，200"),
            ("新闻列表页", "https://www.vazyme.com/news/1/", "服务端渲染，含文章详情链接；分页为前端 /news/p-<offset>-6.html（实测200但正文JS渲染，原始HTML无文章链接）"),
            ("新闻列表页", "https://www.vazyme.com/news/4/", "同类分类页，可并行枚举"),
            ("产品站首页", "https://bio.vazyme.com/", "产品/试剂子站"),
            ("产品目录", "https://bio.vazyme.com/products_3.html", "目录页，含可冻干/分子诊断类产线"),
            ("产品目录", "https://bio.vazyme.com/products_3/2.html", "目录翻页可用（实测页内容随页码变化）"),
            ("产品详情页", "https://bio.vazyme.com/product/236/", "尾斜杠形式"),
            ("产品详情页", "https://bio.vazyme.com/product/236.html", ".html 形式同样返回200（任务包所述规避方式本次未再必要）"),
            ("sitemap", "https://www.vazyme.com/sitemap.xml", "451条URL；实测以tagdetail为主，未含新闻详情，不适合新闻枚举"),
            ("sitemap", "https://bio.vazyme.com/sitemap.xml", "3999条URL，产品线全量枚举首选"),
            ("robots", "https://www.vazyme.com/robots.txt", "声明 Sitemap: /sitemap.xml"),
        ],
        "采集建议": "产品线用 bio.vazyme.com/sitemap.xml（3999条）全量枚举；新闻走 /news/<分类id>/ 逐分类抓，分页需JS渲染引擎。",
    },
    {
        "品牌": "翌圣（Yeasen）",
        "上市状态": "未上市",
        "代码": "",
        "entries": [
            ("官网首页", "https://www.yeasen.com/", "主站，200"),
            ("新闻列表页", "https://www.yeasen.com/news", "Qwik SSR，HTML含 /news/detail/<id> 详情链接"),
            ("新闻分类页", "https://www.yeasen.com/news/203", "分类页可枚举，实测200"),
            ("新闻详情页", "https://www.yeasen.com/news/detail/2090731965700300800", "详情页真实可达"),
            ("产品目录", "https://www.yeasen.com/products", "产品总入口"),
            ("产品目录", "https://www.yeasen.com/products/list/102", "分类目录页，/products/list/<id> 可枚举"),
            ("sitemap", "https://www.yeasen.com/sitemap-index.xml", "sitemap索引，含 sitemap-products-1.xml 等8个子表"),
            ("robots", "https://www.yeasen.com/robots.txt", "重要：Disallow: /*?* ，即带?的URL被robots禁止，合规采集不要用 ?page= 形式"),
        ],
        "采集建议": "禁用 ?page=（robots禁止且实测分页无效：page=1/2/3文章集合完全一致）；改走分类页 /news/<catId> 与 /products/list/<id> 枚举，配合 sitemap-products-*。",
    },
    {
        "品牌": "菲鹏（Fapon）",
        "上市状态": "未上市",
        "代码": "",
        "entries": [
            ("官网首页", "https://www.fapon.com/f", "注意：站点根为 /f，www.fapon.com/f 实测200"),
            ("新闻列表页", "https://www.fapon.com/f/list-411.html", "列表页族 /f/list-4xx.html，可枚举"),
            ("新闻列表页", "https://www.fapon.com/f/list-412.html", "第二类列表"),
            ("新闻列表页", "https://www.fapon.com/f/list-413.html", "第三类列表"),
            ("英文站", "https://www.faponbiotech.com/", "英文站首页，实测200"),
            ("诊断站", "https://www.fapondx.com/", "分子诊断站，实测200（站点很轻，需核实内容完整性）"),
        ],
        "采集建议": "中文站根路径是 /f 不是 /；列表为 /f/list-<id>.html，分页用 #page1..#page5 锚点（单页内含全部条目，直接抓整页即可）。",
    },
    {
        "品牌": "全式金（TransGen）",
        "上市状态": "未上市",
        "代码": "",
        "entries": [
            ("官网首页", "https://www.transgen.com/", "主站，200；transgen.com.cn 同样跳转到 transgen.com"),
            ("新闻列表页", "https://www.transgen.com/news.html", "入口页"),
            ("新闻列表页", "https://www.transgen.com/news/list_1.html", "真分页路径，实测各页文章集合不同（服务端分页已验证）"),
            ("新闻列表页", "https://www.transgen.com/news/list_2.html", "第2页"),
            ("新闻列表页", "https://www.transgen.com/news/list_3.html", "第3页"),
            ("新闻详情页", "https://www.transgen.com/news/1400.html", "详情页真实可达"),
            ("产品目录", "https://www.transgen.com/products.html", "产品总目录"),
            ("产品目录", "https://www.transgen.com/qpcr_product_selection_guide.html", "选型指南类目录，冻干/无甘油类检索从这里切入"),
        ],
        "采集建议": "分页参数实测确认：/news/list_<N>.html（页面内 var page/count 变量可读，共约912条）；这是8家里分页最规范、最适合程序化的一站。",
    },
    {
        "品牌": "迈迪安（Meridian）",
        "上市状态": "未上市",
        "代码": "",
        "entries": [
            ("官网首页", "https://www.meridianbioscience.com/", "Cloudflare 人机校验，实测多数请求返回403并返回 Just a moment 挑战页；200与403交替出现"),
            ("sitemap", "https://www.meridianbioscience.com/sitemap.xml", "同样被Cloudflare拦截，实测403"),
            ("robots", "https://www.meridianbioscience.com/robots.txt", "实测403，连robots都无法直取"),
        ],
        "采集建议": "本机实测不可程序化采集：Cloudflare JS挑战。按任务包红线不绕过验证码，建议改用行业媒体二手信源覆盖该品牌，或由人工在浏览器确认后人工录入。附注：meridianbiosci.com（少一个 ence）DNS不可解析，正确域名为 meridianbioscience.com。",
    },
    {
        "品牌": "罗氏诊断（Roche Diagnostics）",
        "上市状态": "已私有化（母公司罗氏控股 ROG.SW，六大交易所上市）",
        "代码": "ROG",
        "entries": [
            ("业务站首页", "https://diagnostics.roche.com/global/en/home.html", "注意：diagnostics.roche.com 根路径会跳到已失效的 roche-diagnostics.cn/zh/home.html（404），必须带 /global/en/"),
            ("新闻列表页", "https://diagnostics.roche.com/global/en/news-listing.html", "新闻列表，实测200"),
            ("新闻详情页", "https://diagnostics.roche.com/global/en/news-listing/2026/roche-ce-mark-apoe4.html", "真实新闻详情URL（2026年）"),
            ("产品目录", "https://diagnostics.roche.com/global/en/products/product-category/lab-type.html", "产品分类目录"),
            ("sitemap", "https://diagnostics.roche.com/global/en/sitemap.xml", "2240条URL，直接含 /news-listing/2026/ 新闻详情，新闻枚举首选"),
            ("母公司IR", "https://www.roche.com/investors", "IR入口，实测200"),
            ("母公司IR", "https://www.roche.com/investors/annualreport24", "年报入口，实测200"),
            ("母公司新闻稿", "https://www.roche.com/media/releases", "集团媒体发布，实测200"),
        ],
        "采集建议": "诊断业务与IR分属两站：业务/产品走 diagnostics.roche.com/global/en（务必带语言段，否则404）；财报走 roche.com/investors。新闻枚举直接用2240条的sitemap。",
    },
    {
        "品牌": "宝生物（Takara）",
        "上市状态": "已退市",
        "代码": "4974.T（东证Prime，2026-06-12已退市）",
        "entries": [
            ("试剂业务站", "https://www.takara.co.jp/", "实测200。注意：任务包备注的403本次未复现，带浏览器UA与不带UA均为200"),
            ("宝生物主站", "https://www.takara-bio.com/", "根路径跳 /en/index.html，实测200"),
            ("IR信息页", "https://www.takara-bio.com/en/ir/library/tob_info.html", "实测200，内含退市一手公告（2026-06-12退市，已核实）"),
            ("IR入口", "https://www.takara-bio.com/en/ir/", "实测403"),
            ("中文站", "https://www.takara.com.cn/", "实测200，但页面极轻（约3.8KB），疑为跳转壳，需人工确认"),
        ],
        "采集建议": "已退市（东证Prime 4974.T，2026-06-12，股份合并374:1后私有化，母公司Takara Holdings要约收购），不再有定期财报披露；动态监测只能盯官网新闻与IR信息页。",
    },
    {
        "品牌": "赛默飞（Thermo Fisher）",
        "上市状态": "美股上市",
        "代码": "TMO",
        "entries": [
            ("中国站首页", "https://www.thermofisher.cn/cn/zh/home.html", "根域 thermofisher.com 会跳转至此，实测200"),
            ("新闻列表页", "https://www.thermofisher.cn/cn/zh/home/about-us/news-gallery.html", "新闻中心，实测200"),
            ("新闻详情页", "https://www.thermofisher.cn/cn/zh/home/about-us/news-gallery/press-release-china/2026-10-12.html", "真实新闻稿（2026-10-12），实测200"),
            ("产品目录", "https://www.thermofisher.cn/cn/zh/home/product-selection-guides-and-tools.html", "产品选型指南总入口"),
            ("sitemap", "https://www.thermofisher.cn/sitemap-index-cn.xml", "sitemap索引，含 sitemap-cn-zh.xml 等2个子表"),
            ("IR入口", "https://ir.thermofisher.com/", "实测403，反爬，不可用"),
        ],
        "采集建议": "中文新闻/产品走 thermofisher.cn 可稳定抓取；美股IR站 ir.thermofisher.com 实测403且任务包记录SEC EDGAR亦403，财报维度建议改用可访问的第三方或暂缺。",
    },
]

SOCIAL_NOTE = (
    "8家品牌中，唯一实测可程序化访问的公开社媒：无。"
    "LinkedIn 公司页对未登录请求一律返回 999（实测 vazyme-biotech / thermofisher / yeasen-biotech / roche 四家均为999，属LinkedIn通用反爬）；"
    "微信公众号页面为动态渲染+登录态，不构成公开可抓取入口。因此本清单不写入任何社媒URL，避免出现无URL/不可采集条目。"
)

# ---------------------------------------------------------------- M2 数据源
M2_SOURCES = [
    ("NMPA医疗器械注册证查询入口", "https://www.nmpa.gov.cn/datasearch/home-index.html",
     "核心目标源。实测412（风控拦截），加完整浏览器请求头仍为412，非验证码墙，属可预期的反爬响应。待确认：是否存在官方开放接口或镜像入口。"),
    ("NMPA主站", "https://www.nmpa.gov.cn/", "实测412，同上反爬"),
    ("NMPA政务服务门户", "https://zwfw.nmpa.gov.cn/web/index", "实测200，同一主管机关下唯一可直接访问的入口，可作为备案/注册证事项的替代入口"),
    ("NMPA UDI数据库", "https://udi.nmpa.gov.cn/", "实测超时不可达（000），待确认是本机网络受限还是该服务已迁移"),
    ("巨潮资讯首页", "http://www.cninfo.com.cn/new/index", "实测200"),
    ("巨潮公告检索页", "http://www.cninfo.com.cn/new/commonUrl?url=disclosure/list/notice", "实测200，检索入口页（页面本身为前端渲染）"),
    ("巨潮个股公告页", "http://www.cninfo.com.cn/new/disclosure/stock?stockCode=688105", "实测200，stockCode=诺唯赞688105"),
    ("巨潮公告查询API", "http://www.cninfo.com.cn/new/hisAnnouncement/query",
     "POST接口，实测200并返回真实JSON（诺唯赞688105/orgId=9900046657 返回 totalAnnouncement=582）。参数：stock=688105,9900046657 & tabName=fulltext & pageSize & pageNum & column=sse & category(可选 category_ndbg_szsh年报/category_bndbg_szsh半年报)。这是本任务包中最有价值的可程序化披露源。"),
    ("巨潮公司代码查询API", "http://www.cninfo.com.cn/new/information/topSearch/query",
     "POST接口，实测200。参数 keyWord=688105，返回 orgId 与 delisted 标志，用于先查orgId再查公告；实测返回 诺唯赞 orgId=9900046657, delisted=false。"),
    ("巨潮公告PDF直链样例", "http://static.cninfo.com.cn/finalpage/2026-08-28/1225517386.PDF", "实测200，application/pdf（约5.3MB），2026年半年度报告原件"),
    ("上交所公告检索", "https://www.sse.com.cn/disclosure/listedinfo/announcement/", "实测200"),
    ("上交所个股公告（诺唯赞）", "https://www.sse.com.cn/assortment/stock/list/info/announcement/index.shtml?productId=688105", "实测200，productId=688105，科创板个股公告直达"),
    ("深交所公告", "https://www.szse.cn/disclosure/listed/notice/index.html", "实测200"),
    ("北交所首页", "https://www.bse.cn/", "实测200"),
    ("北交所公告页", "https://www.bse.cn/disclosure/announcement.html", "实测302循环跳转（超过50次重定向），子页面当前不可用，待确认"),
    ("全国公共资源交易平台", "https://www.ggzy.gov.cn/", "实测200，招投标信号的聚合总入口，优于逐省抓取"),
    ("全国平台交易公开列表", "https://www.ggzy.gov.cn/deal/dealList.html?HEADER_DEAL_TYPE=01", "实测200，中标/交易结果列表入口（HEADER_DEAL_TYPE=01..05、21..25、90）"),
    ("全国平台交易列表接口", "https://www.ggzy.gov.cn/information/pubTradingInfo/getTradList", "接口路径存在（前端JS引用），但本机GET/POST实测均返回「网页暂时无法访问」错误页，待确认完整参数"),
    ("全国平台公告详情样例", "https://www.ggzy.gov.cn/information/deal/html/a/530000/0104/20261013/005328f7000a831c46619ac3d485fc213262.html", "实测200，URL规律 /information/deal/html/a/<省码>/<类型码>/<日期>/<uuid>.html；类型码 0101=工程建设招标公告、0104=招标公告类、9001=其他交易公告"),
    ("中国政府采购网", "https://www.ccgp.gov.cn/", "实测403（反爬），与任务包备注一致；按红线不绕过"),
]

# 各省公共资源交易中心（逐省实测）
PROVINCES = [
    ("北京市", "https://ggzyfw.beijing.gov.cn/"), ("天津市", "https://ggzy.tj.gov.cn/"),
    ("河北省", "https://www.hebpr.cn/"), ("山西省", "https://www.sxbid.com.cn/"),
    ("内蒙古自治区", "https://ggzyjy.nmg.gov.cn/"), ("辽宁省", "https://ggzy.ln.gov.cn/"),
    ("吉林省", "https://ggzy.jl.gov.cn/"), ("黑龙江省", "https://ggzy.hlj.gov.cn/"),
    ("上海市", "https://www.shggzy.com/"), ("江苏省", "https://www.jsggzy.jszwfw.gov.cn/"),
    ("浙江省", "https://ggzy.zj.gov.cn/"), ("安徽省", "https://ggzy.ah.gov.cn/"),
    ("福建省", "https://www.fjggzy.com/"), ("江西省", "https://ggzy.jiangxi.gov.cn/"),
    ("山东省", "https://ggzyjy.shandong.gov.cn/"), ("河南省", "https://hnsggzyjy.henan.gov.cn/"),
    ("湖北省", "https://ggzyfw.hubei.gov.cn/"), ("湖南省", "https://ggzy.hunan.gov.cn/"),
    ("广东省", "https://ygp.gdzwfw.gov.cn/"), ("广西壮族自治区", "https://www.ggzy.gxzf.gov.cn/"),
    ("海南省", "https://www.hainan.gov.cn/ggzy/"), ("重庆市", "https://www.cqggzy.com/"),
    ("四川省", "https://ggzyjy.sc.gov.cn/"), ("贵州省", "https://ggzy.guizhou.gov.cn/"),
    ("云南省", "https://www.ynggzy.com/"), ("西藏自治区", "https://ggzy.xizang.gov.cn/"),
    ("陕西省", "https://www.sxggzyjy.cn/"), ("甘肃省", "https://ggzyjy.gansu.gov.cn/"),
    ("青海省", "https://ggzy.qinghai.gov.cn/"), ("宁夏回族自治区", "https://www.nxggzyjy.org/"),
    ("新疆维吾尔自治区", "https://ggzy.xinjiang.gov.cn/"),
]

PROV_NOTE = {
    "天津市": "DNS可解析(60.28.163.225)但连接超时，本机网络不可达，非站点反爬",
    "河北省": "DNS可解析(203.107.45.167)但连接超时，本机网络不可达，非站点反爬",
    "江苏省": "本机DNS解析失败，所填域名未解析成功，正确入口待确认",
    "吉林省": "本机DNS解析失败，域名待确认",
    "黑龙江省": "本机DNS解析失败，域名待确认",
    "福建省": "本机DNS解析失败，域名待确认",
    "湖北省": "本机DNS解析失败，域名待确认",
    "广西壮族自治区": "本机DNS解析失败，域名待确认",
    "青海省": "本机DNS解析失败，域名待确认",
    "甘肃省": "实测412（风控拦截）",
    "湖南省": "另有交易平台 https://www.hnsggzy.com/ 实测200，可作备用入口",
    "海南省": "实际入口为省政府域下的 /ggzy/ 路径",
    "宁夏回族自治区": "实际入口为 nxggzyjy.org（.org），非 .gov.cn",
}

MEDIA = [
    ("动脉网（VBData）", "https://www.vbdata.cn/", "实测200。注意 vcbeat.top 会302跳转至此；/news 路径已失效（跳 /notFound），可用频道页如 /topics/medical"),
    ("动脉网频道页", "https://www.vbdata.cn/topics/medical", "实测200，可作为行业垂直频道列表页"),
    ("生物谷新闻", "https://news.bioon.com/", "实测200"),
    ("生物谷主站", "https://www.bioon.com/", "实测200；/news/ 与 /rss 均404，无公开RSS"),
    ("检验医学网", "https://www.chinaivd.com/", "实测200；/news 与 /NewsList 均404，列表入口待确认"),
    ("化药云资讯", "https://www.chem17.com/news/", "实测200，可抓列表页"),
    ("动脉网RSS尝试", "https://www.vbdata.cn/rss", "实测200但实际跳回首页，非有效RSS"),
]


def build_m1():
    out = {
        "_元信息": {
            "生成日期": "2026-10-13",
            "实测环境": "macOS / curl-UA Chrome126 / 中国大陆网络出口未指定",
            "说明": "「实测状态码」由 build_m1_m2.py 运行时真实HTTP探测写入，未经手工填写。不可达记为「不可达」，并说明是DNS失败、连接超时还是风控拦截。",
            "边界遵守": "未采集登录墙/付费/验证码内容；Cloudflare挑战与风控拦截一律如实记录，未做任何绕过。",
        },
        "公开社媒结论": SOCIAL_NOTE,
        "品牌": [],
    }
    for b in M1:
        item = {
            "品牌": b["品牌"],
            "上市状态": b["上市状态"],
            "代码": b["代码"],
            "源": [],
        }
        for kind, url, note in b["entries"]:
            print(f"  [M1] {url}")
            item["源"].append(make_entry(kind, url, note))
        item["采集建议"] = b["采集建议"]
        out["品牌"].append(item)
    return out


def build_m2():
    out = {
        "_元信息": {
            "生成日期": "2026-10-13",
            "说明": "「实测状态码」由构建脚本运行时真实探测写入。逐省实测，未实测成功的条目一律标「不可达」并注明原因，不猜测、不伪造。",
        },
        "监管与披露源": [],
        "省级公共资源交易中心_逐省实测": [],
        "行业媒体": [],
    }
    for kind, url, note in M2_SOURCES:
        print(f"  [M2] {url}")
        out["监管与披露源"].append(make_entry(kind, url, note))

    for prov, url in PROVINCES:
        print(f"  [省份] {prov} {url}")
        e = make_entry("省级公共资源交易中心", url, PROV_NOTE.get(prov, ""))
        out["省级公共资源交易中心_逐省实测"].append(
            {"类型": e["类型"], "省份": prov, "URL": e["URL"],
             "实测状态码": e["实测状态码"], "备注": e["备注"]})

    for kind, url, note in MEDIA:
        print(f"  [媒体] {url}")
        out["行业媒体"].append(make_entry(kind, url, note))
    return out


if __name__ == "__main__":
    print("构建 M1 ...")
    m1 = build_m1()
    with open(os.path.join(OUT_DIR, "m1_competitor_sources.json"), "w", encoding="utf-8") as f:
        json.dump(m1, f, ensure_ascii=False, indent=2)
    print("构建 M2 ...")
    m2 = build_m2()
    with open(os.path.join(OUT_DIR, "m2_public_sources.json"), "w", encoding="utf-8") as f:
        json.dump(m2, f, ensure_ascii=False, indent=2)
    print("完成。")