📂 solution-1.0

← 返回上级
"""
Phase 1: 并发抓取所有军事新闻源的头条列表
Phase 2: 并发获取文章详情摘要

| 运行方式: python3 geopolitics-daily-scan.py
| 输出: stdout(cron 自动投递到微信)

依赖: 标准库 only (urllib, re, json, concurrent.futures, ssl)
"""
import urllib.request
import urllib.error
import json
import re
import ssl
from datetime import datetime, timezone, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed

import sys
DATE_OVERRIDE = sys.argv[1] if len(sys.argv) > 1 else None
TODAY = DATE_OVERRIDE or datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
BJ_TZ = timezone(timedelta(hours=8))
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE


def fetch_url(url, timeout=20, headers=None):
    default_headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    }
    if headers:
        default_headers.update(headers)
    req = urllib.request.Request(url, headers=default_headers)
    try:
        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
            data = resp.read()
            charset = resp.headers.get_content_charset() or 'utf-8'
            return data.decode(charset, errors='replace')
    except Exception:
        return None


# ====== 各源采集函数 ======

def fetch_huanqiu():
    """环球网军事 - textarea解析(最可靠)"""
    url = "https://mil.huanqiu.com/"
    content = fetch_url(url)
    if not content:
        return []
    ids = re.findall(r'<textarea class="item-aid">([^<]+)</textarea>', content)
    titles = re.findall(r'<textarea class="item-title">([^<]+)</textarea>', content)
    hosts = re.findall(r'<textarea class="item-cnf-host">([^<]+)</textarea>', content)
    results = []
    for i in range(len(ids)):
        aid = ids[i].strip()
        title = titles[i].strip() if i < len(titles) else ''
        host = hosts[i].strip() if i < len(hosts) else ''
        if not title:
            continue
        article_url = f"https://{host}/article/{aid}" if host and aid else ''
        results.append({'source': '环球网军事', 'title': title, 'url': article_url, 'summary': ''})
    return results


def fetch_guancha():
    """观察者网军事 - HTML解析"""
    url = "https://www.guancha.cn/military-affairs"
    content = fetch_url(url)
    if not content:
        return []
    results = []
    links = re.findall(r'href="(/military-affairs/\d{4}_\d{2}_\d{2}_[^"]*\.shtml)"', content)
    seen = set()
    for link in links:
        full_url = f"https://www.guancha.cn{link}"
        if full_url in seen:
            continue
        seen.add(full_url)
        title_match = re.search(rf'href="{re.escape(link)}"[^>]*>([^<]+)</a>', content)
        title = title_match.group(1).strip() if title_match else ''
        title = re.sub(r'<[^>]+>', '', title).strip()
        if not title or len(title) < 6 or title.isdigit():
            continue
        results.append({'source': '观察者网军事', 'title': title, 'url': full_url, 'summary': ''})
    return results


def fetch_ifeng():
    """凤凰网军事(不稳定,可能返回空)"""
    urls = ["https://news.ifeng.com/mil/", "https://mil.ifeng.com/"]
    for url in urls:
        content = fetch_url(url)
        if content and len(content) > 500:
            results = []
            links = re.findall(
                r'<a[^>]*href="(https?://[^"]*ifeng[^"]*/(?:c|article)/[^"]+)"[^>]*>([^<]+)</a>',
                content,
            )
            for href, title in links:
                title = re.sub(r'<[^>]+>', '', title).strip()
                if title and len(title) > 5:
                    results.append({'source': '凤凰网军事', 'title': title, 'url': href, 'summary': ''})
            if results:
                return results
    return []


def fetch_sina():
    """新浪军事 - 备选Feed API"""
    urls = [
        "https://feed.mix.sina.com.cn/api/roll/get?pageid=153&lid=2514&num=20",
        "https://interface.sina.cn/pc_api/public_news_data.d.json?cre=tianyi&mod=pcmil&cids=57922&length=15",
    ]
    for url in urls:
        content = fetch_url(url)
        if not content:
            continue
        try:
            data = json.loads(content)
            items = data.get('result', {}).get('data', []) or data.get('data', [])
            if not items or items is None:
                continue
            results = []
            for item in items:
                title = item.get('title', '').strip()
                link = item.get('url', '').strip()
                if not title:
                    continue
                if link and not link.startswith('http'):
                    link = f'https:{link}' if link.startswith('//') else link
                results.append({'source': '新浪军事', 'title': title, 'url': link, 'summary': ''})
            if results:
                return results
        except (json.JSONDecodeError, KeyError):
            continue
    return []


# ====== 文章详情获取 ======

def fetch_summary(url):
    content = fetch_url(url, timeout=15)
    if not content:
        return ''
    for pattern in [
        r'<meta[^>]*property="og:description"[^>]*content="([^"]+)"',
        r'<meta[^>]*name="description"[^>]*content="([^"]+)"',
        r'<meta[^>]*property="description"[^>]*content="([^"]+)"',
        r'<p[^>]*class="[^"]*summary[^"]*"[^>]*>([^<]+)</p>',
        r'<p[^>]*>([^<]{40,})</p>',
    ]:
        m = re.search(pattern, content, re.I)
        if m:
            return m.group(1).strip().replace('&nbsp;', ' ').replace('&amp;', '&')[:200]
    return ''


# ====== 分类规则 ======
CLASSIFICATION_RULES = [
    ('中美关系 / 台海 / 南海',
     ['中美', '拜登', '台海', '台湾', '南海', '美日', '美菲', '印太', '对华', '美台', '统一', '台独', '两岸',
      '中国海军', '越南', '访问']),
    ('中东局势',
     ['中东', '伊朗', '以色列', '加沙', '哈马斯', '真主党', '巴勒斯坦', '胡塞', '也门', '叙利亚', '黎巴嫩',
      '红海', '波斯湾', '内塔尼亚胡', '斡旋', '卡塔尔', '阿联酋', '霍尔木兹', '布什尔']),
    ('俄乌冲突',
     ['俄乌', '俄罗斯', '乌克兰', '普京', '泽连斯基', '基辅', '莫斯科', '顿巴斯', '俄军', '乌军',
      '特别军事行动', '远程打击', '停火', '俄黑海']),
    ('欧洲 / 北约',
     ['欧洲', '北约', '欧盟', '法国', '德国', '英国', '马克龙', '朔尔茨', '欧洲防务', '北约峰会',
      '波兰', '波罗的海', '斯洛伐克', '瑞典', '萨博', '鹰狮']),
    ('亚太 / 朝鲜半岛',
     ['朝鲜', '韩国', '日本', '岸田', '金正恩', '朝韩', '半岛', '美日韩', '澳大利亚', '印度',
      '东盟', '菲律宾', '导弹试射', '朝核', '文身']),
    ('核武器 / 军控',
     ['核武', '核弹', '核威慑', '军控', '核裁军', '核试验', '洲际导弹', '高超音速', '导弹防御',
      '战略武器', '核潜艇', '潜射']),
]


def classify(title, summary):
    text = (title + ' ' + summary).lower()
    scores = [(sum(1 for kw in kws if kw.lower() in text), cat) for cat, kws in CLASSIFICATION_RULES]
    scores = [(s, c) for s, c in scores if s > 0]
    if scores:
        scores.sort(reverse=True)
        return scores[0][1]
    return '其他国际'


# ====== 主流程 ======

def main():
    print("=" * 60)
    print(f"国际地缘政治日报 · 采集 {TODAY}")
    print("=" * 60)

    # Phase 1: 并发抓取
    sources = {
        '环球网军事': fetch_huanqiu,
        '观察者网军事': fetch_guancha,
        '凤凰网军事': fetch_ifeng,
        '新浪军事': fetch_sina,
    }
    all_articles = []
    with ThreadPoolExecutor(max_workers=4) as ex:
        futures = {ex.submit(fn): name for name, fn in sources.items()}
        for f in as_completed(futures):
            name = futures[f]
            try:
                arts = f.result()
                all_articles.extend(arts)
                print(f"  ✅ {name}: {len(arts)}条")
            except Exception as e:
                print(f"  ❌ {name}: {e}")

    # 去重
    seen = set()
    unique = []
    for a in all_articles:
        key = re.sub(r'[^\u4e00-\u9fff\w]', '', a['title'][:30])
        if key and key not in seen:
            seen.add(key)
            unique.append(a)

    total_raw = len(all_articles)
    print(f"\n📊 原始: {total_raw}条 → 去重后: {len(unique)}条")

    # 过滤旧文章(新浪Feed API可能返回多年前的旧新闻)
    filtered = []
    old_count = 0
    for a in unique:
        if any(f"/{y}" in a['url'] for y in ["2022", "2023", "2024"]):
            old_count += 1
        else:
            filtered.append(a)
    print(f"🗑️ 过滤掉 {old_count} 条旧文章, 剩余 {len(filtered)} 条")

    if not filtered:
        print("❌ 无可用新闻")
        return

    # Phase 2: 获取摘要
    print(f"\n📝 获取 {min(len(filtered), 40)} 篇文章摘要...")
    with ThreadPoolExecutor(max_workers=8) as ex:
        futs = {ex.submit(fetch_summary, a['url']): i for i, a in enumerate(filtered[:40])}
        for f in as_completed(futs):
            i = futs[f]
            try:
                filtered[i]['summary'] = f.result() or ''
            except Exception:
                pass

    # 分类
    classified = {}
    for a in filtered:
        cat = classify(a['title'], a.get('summary', ''))
        classified.setdefault(cat, []).append(a)

    # 生成日报(输出到 stdout,cron 自动投递到微信)
    category_order = [
        '中美关系 / 台海 / 南海',
        '中东局势',
        '俄乌冲突',
        '欧洲 / 北约',
        '亚太 / 朝鲜半岛',
        '核武器 / 军控',
        '其他国际',
    ]

    lines = [f"# 国际地缘政治日报 · {TODAY}\n"]
    total_news = 0
    for cat in category_order:
        items = classified.get(cat, [])
        if not items:
            continue
        lines.append(f"## {cat}\n")
        for a in items:
            s = a.get('summary', '')
            entry = f"**{cat}** | [{a['title']}]({a['url']})"
            if s:
                entry += f" — {s}"
            lines.append(entry)
            total_news += 1
        lines.append('')

    now_str = datetime.now(BJ_TZ).strftime('%Y-%m-%d %H:%M')
    lines.append(
        f"---\n*采集时间: {now_str} | 数据来源: 环球网军事、观察者网军事、凤凰网军事、新浪军事*\n"
    )

    # ✅ 直接输出到 stdout(不写本地 .md 文件)
    report = '\n'.join(lines)
    print("\n" + "=" * 60)
    print("📋 国际地缘政治日报(推送版)")
    print("=" * 60)
    print(report)
    print(f"\n📊 共 {len(filtered)} 条新闻, 分布:")
    for cat in category_order:
        items = classified.get(cat, [])
        if items:
            print(f"   - {cat}: {len(items)}条")


if __name__ == '__main__':
    main()