#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 人民日报电子版交互式抓取 / 下载脚本 用法: python3 rmrb_opinion_scraper.py 功能: 1. 抓取: 按日期浏览版面与文章标题,选择单篇/多篇下载为 Markdown 2. 下载: 按日期选择版面,批量下载该版面全部文章 """ from __future__ import annotations import os import re import sys import html from datetime import datetime, date from typing import List, Dict, Optional, Tuple from urllib.parse import urljoin, urlparse from urllib.request import Request, urlopen from urllib.error import URLError, HTTPError from html.parser import HTMLParser # --------------------------------------------------------------------------- # 常量 # --------------------------------------------------------------------------- USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ) BASE_HOST = "https://paper.people.com.cn" REQUEST_TIMEOUT = 25 # 旧版 / 新版 URL 模板 # 新版 PC: https://paper.people.com.cn/rmrb/pc/layout/YYYYMM/DD/node_01.html # 旧版: https://paper.people.com.cn/rmrb/html/YYYY-MM/DD/nbs.D110000renmrb_01.htm # --------------------------------------------------------------------------- # HTTP # --------------------------------------------------------------------------- def fetch(url: str) -> str: """请求页面,返回解码后的文本。失败时抛出异常。""" req = Request(url, headers={ "User-Agent": USER_AGENT, "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", "Referer": BASE_HOST + "/", }) with urlopen(req, timeout=REQUEST_TIMEOUT) as resp: raw = resp.read() # 优先从 Content-Type / meta 判断编码 charset = None ctype = resp.headers.get("Content-Type", "") m = re.search(r"charset=([\w-]+)", ctype, re.I) if m: charset = m.group(1).strip() if not charset: head = raw[:2000].decode("ascii", errors="ignore") m = re.search(r'charset=["\']?([\w-]+)', head, re.I) if m: charset = m.group(1).strip() for enc in (charset, "utf-8", "gb18030", "gbk", "gb2312"): if not enc: continue try: return raw.decode(enc) except (UnicodeDecodeError, LookupError): continue return raw.decode("utf-8", errors="replace") def try_fetch(url: str) -> Optional[str]: try: return fetch(url) except (URLError, HTTPError, TimeoutError, OSError) as e: return None # --------------------------------------------------------------------------- # 简易 HTML 工具(无第三方依赖) # --------------------------------------------------------------------------- def strip_tags(text: str) -> str: text = re.sub(r"(?is)]*>.*?", "", text) text = re.sub(r"(?is)]*>.*?", "", text) text = re.sub(r"(?is)", "", text) text = re.sub(r"(?is)", "\n", text) text = re.sub(r"(?is)

", "\n\n", text) text = re.sub(r"(?is)", "\n", text) text = re.sub(r"(?is)", "\n\n", text) text = re.sub(r"(?is)<[^>]+>", "", text) text = html.unescape(text) # 整理空白 text = text.replace("\r\n", "\n").replace("\r", "\n") text = re.sub(r"[ \t]+\n", "\n", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def sanitize_filename(name: str) -> str: """生成合法文件名。""" name = name.strip() name = re.sub(r'[\\/:*?"<>|\r\n\t]', "_", name) name = re.sub(r"\s+", " ", name).strip(" .") if not name: name = "未命名文章" # 限制长度,避免路径过长 if len(name) > 80: name = name[:80].rstrip() return name # --------------------------------------------------------------------------- # 日期与 URL # --------------------------------------------------------------------------- def parse_date_input(s: str) -> date: s = (s or "").strip() if not s: return date.today() for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"): try: return datetime.strptime(s, fmt).date() except ValueError: continue raise ValueError(f"无法解析日期: {s},请使用 YYYY-MM-DD 格式") def layout_urls_for_date(d: date) -> List[str]: """返回可能的首页版面 URL 列表(按优先级)。""" ymd = d.strftime("%Y%m") day = d.strftime("%d") y_m = d.strftime("%Y-%m") return [ f"{BASE_HOST}/rmrb/pc/layout/{ymd}/{day}/node_01.html", f"{BASE_HOST}/rmrb/html/{y_m}/{day}/nbs.D110000renmrb_01.htm", f"http://paper.people.com.cn/rmrb/pc/layout/{ymd}/{day}/node_01.html", f"http://paper.people.com.cn/rmrb/html/{y_m}/{day}/nbs.D110000renmrb_01.htm", ] def abs_url(base: str, href: str) -> str: if not href: return "" href = href.strip() if href.startswith("javascript:") or href.startswith("#"): return "" return urljoin(base, href) # --------------------------------------------------------------------------- # 解析版面列表 # --------------------------------------------------------------------------- def parse_sections(page_html: str, page_url: str) -> List[Dict[str, str]]: """ 从版面页解析所有版面(板块)。 返回: [{"name": "第01版:要闻", "url": "..."}, ...] """ sections: List[Dict[str, str]] = [] seen = set() # 模式1: 新版 swiper / right_title / pageLink # 第01版:要闻 patterns = [ # 带 id=pageLink r']+href=["\']([^"\']*node_\d+\.html)["\'][^>]*>(.*?)', r']+href=["\']([^"\']*nbs\.D110000renmrb_\d+\.htm)["\'][^>]*>(.*?)', # 旧版 r']+href=["\']([^"\']*nbs\.D110000renmrb[^"\']+)["\'][^>]*>(.*?)', ] for pat in patterns: for m in re.finditer(pat, page_html, re.I | re.S): href = m.group(1) name = strip_tags(m.group(2)) name = re.sub(r"\s+", " ", name).strip() url = abs_url(page_url, href) if not url or not name: continue # 过滤非版面链接 if "node_" not in url and "nbs.D110000" not in url: continue key = url if key in seen: continue seen.add(key) sections.append({"name": name, "url": url}) # 若未解析到,尝试从左侧目录 / 底部导航再扫一遍更宽松规则 if not sections: for m in re.finditer( r']+href=["\']([^"\']+)["\'][^>]*>\s*(第?\s*\d+\s*版[^<]*)\s*', page_html, re.I | re.S, ): href, name = m.group(1), strip_tags(m.group(2)) url = abs_url(page_url, href) if not url or url in seen: continue seen.add(url) sections.append({"name": name.strip(), "url": url}) # 去重后若仍为空,把当前页当作唯一版面 if not sections: # 尝试从标题提取版面名 title_m = re.search(r"(.*?)", page_html, re.I | re.S) name = strip_tags(title_m.group(1)) if title_m else "第01版" sections.append({"name": name, "url": page_url}) return sections # --------------------------------------------------------------------------- # 解析文章列表 # --------------------------------------------------------------------------- def parse_articles(page_html: str, page_url: str) -> List[Dict[str, str]]: """ 从版面页解析文章列表。 返回: [{"title": "...", "url": "..."}, ...] """ articles: List[Dict[str, str]] = [] seen = set() # 新版 content_ 链接 / 旧版 nw.D110000 链接 link_patterns = [ r']+href=["\']([^"\']*content_\d+\.html)["\'][^>]*>(.*?)', r']+href=["\']([^"\']*nw\.D110000renmrb[^"\']+\.htm)["\'][^>]*>(.*?)', r']+href=["\']([^"\']*content[^"\']*\.html?)["\'][^>]*>(.*?)', ] for pat in link_patterns: for m in re.finditer(pat, page_html, re.I | re.S): href = m.group(1) title = strip_tags(m.group(2)) title = re.sub(r"\s+", " ", title).strip() url = abs_url(page_url, href) if not url or not title: continue # 过滤导航类短标题 if len(title) < 2: continue if title in ("上一版", "下一版", "返回目录", "放大", "缩小", "下载"): continue if url in seen: continue # 排除版面节点链接 if re.search(r"node_\d+\.html", url) or "nbs.D110000" in url: continue seen.add(url) articles.append({"title": title, "url": url}) # 从 map area 热点坐标解析(旧版报纸常见) if not articles: for m in re.finditer( r']+href=["\']([^"\']+)["\'][^>]*(?:alt|title)=["\']([^"\']+)["\']', page_html, re.I, ): href, title = m.group(1), html.unescape(m.group(2)).strip() url = abs_url(page_url, href) if not url or not title or url in seen: continue seen.add(url) articles.append({"title": title, "url": url}) for m in re.finditer( r']*(?:alt|title)=["\']([^"\']+)["\'][^>]+href=["\']([^"\']+)["\']', page_html, re.I, ): title, href = html.unescape(m.group(1)).strip(), m.group(2) url = abs_url(page_url, href) if not url or not title or url in seen: continue seen.add(url) articles.append({"title": title, "url": url}) return articles # --------------------------------------------------------------------------- # 解析文章正文 # --------------------------------------------------------------------------- def parse_article_content(page_html: str, page_url: str) -> Tuple[str, str, str]: """ 返回 (title, content_text, source_info) """ # 标题 title = "" for pat in ( r']*class=["\'][^"\']*article[^"\']*["\'][^>]*>(.*?)', r']*>(.*?)', r']+class=["\'][^"\']*title["\'][^>]*>(.*?)', r"(.*?)", ): m = re.search(pat, page_html, re.I | re.S) if m: title = strip_tags(m.group(1)) title = re.sub(r"\s*[-_|].*$", "", title).strip() # 去掉站点后缀 if title: break # 正文候选区域 content = "" content_patterns = [ r']+id=["\']ozoom["\'][^>]*>(.*?)\s*', r']+class=["\'][^"\']*article[_-]?content[^"\']*["\'][^>]*>(.*?)', r']+id=["\']articleContent["\'][^>]*>(.*?)', r']+class=["\'][^"\']*content["\'][^>]*>(.*?)', r']+class=["\'][^"\']*text[_-]?content[^"\']*["\'][^>]*>(.*?)', r']*>(.*?)', ] for pat in content_patterns: m = re.search(pat, page_html, re.I | re.S) if m: block = m.group(1) # 去掉内嵌脚本样式 text = strip_tags(block) if len(text) > 30: content = text break # 兜底:取所有

if not content: ps = re.findall(r"]*>(.*?)

", page_html, re.I | re.S) paras = [strip_tags(p) for p in ps] paras = [p for p in paras if p and len(p) > 1] # 过滤页脚噪声 noise = ("人民网", "返回目录", "上一版", "下一版", "放大", "缩小") paras = [p for p in paras if not any(n == p for n in noise)] content = "\n\n".join(paras) # 来源 / 日期信息 source = "" for pat in ( r']+class=["\'][^"\']*sec[^"\']*["\'][^>]*>(.*?)', r']+class=["\'][^"\']*date["\'][^>]*>(.*?)', r']+class=["\'][^"\']*date["\'][^>]*>(.*?)', ): m = re.search(pat, page_html, re.I | re.S) if m: source = strip_tags(m.group(1)) source = re.sub(r"\s+", " ", source).strip() if source: break if not title: title = "未命名文章" return title, content, source def article_to_markdown(title: str, content: str, source: str, url: str, d: date) -> str: lines = [ f"# {title}", "", f"- 日期: {d.isoformat()}", f"- 来源: 人民日报电子版" + (f" | {source}" if source else ""), f"- 链接: {url}", "", "---", "", content if content else "_(未能解析到正文,请打开链接查看)_", "", ] return "\n".join(lines) # --------------------------------------------------------------------------- # 数据层:加载某日全部版面 # --------------------------------------------------------------------------- class DayPaper: def __init__(self, d: date): self.date = d self.home_url: Optional[str] = None self.sections: List[Dict[str, str]] = [] # name, url self._section_html: Dict[str, str] = {} # url -> html self._section_articles: Dict[str, List[Dict[str, str]]] = {} def load(self) -> None: last_err = None html_text = None for url in layout_urls_for_date(self.date): try: html_text = fetch(url) self.home_url = url break except Exception as e: last_err = e html_text = None if not html_text or not self.home_url: raise RuntimeError( f"无法获取 {self.date.isoformat()} 的人民日报电子版。" f" 请确认日期是否有报纸(周末/节假日版面较少),网络是否可用。" f" 详情: {last_err}" ) self.sections = parse_sections(html_text, self.home_url) # 缓存首页 html self._section_html[self.home_url] = html_text def get_section_html(self, section_url: str) -> str: if section_url in self._section_html: return self._section_html[section_url] text = fetch(section_url) self._section_html[section_url] = text return text def get_articles(self, section: Dict[str, str]) -> List[Dict[str, str]]: url = section["url"] if url in self._section_articles: return self._section_articles[url] page = self.get_section_html(url) arts = parse_articles(page, url) self._section_articles[url] = arts return arts # --------------------------------------------------------------------------- # 交互工具 # --------------------------------------------------------------------------- def prompt(msg: str, default: str = "") -> str: try: if default: s = input(f"{msg} [{default}]: ").strip() return s if s else default return input(f"{msg}: ").strip() except (EOFError, KeyboardInterrupt): print("\n已取消。") sys.exit(0) def prompt_date(msg: str = "请输入日期 (YYYY-MM-DD,回车默认当天)") -> date: while True: s = prompt(msg, date.today().isoformat()) try: return parse_date_input(s) except ValueError as e: print(f" ! {e}") def parse_selection(s: str, max_n: int) -> List[int]: """ 解析用户选择: "1" / "1-3" / "1,3,5" / "1,3-5" 返回 1-based 索引列表(已去重、排序)。 """ s = (s or "").strip() if not s: return [] chosen = set() for part in re.split(r"[,,]\s*", s): part = part.strip() if not part: continue if re.fullmatch(r"\d+", part): n = int(part) if 1 <= n <= max_n: chosen.add(n) else: raise ValueError(f"序号超出范围: {n} (1-{max_n})") elif re.fullmatch(r"\d+\s*[-~~到至]\s*\d+", part): a, b = re.split(r"[-~~到至]", part) a, b = int(a.strip()), int(b.strip()) if a > b: a, b = b, a for n in range(a, b + 1): if 1 <= n <= max_n: chosen.add(n) else: raise ValueError(f"序号超出范围: {n} (1-{max_n})") else: raise ValueError(f"无法解析选择: {part}") return sorted(chosen) def print_numbered(items: List[str], title: str = "") -> None: if title: print(f"\n{title}") print("-" * 40) for i, name in enumerate(items, 1): print(f" {i}. {name}") print() def download_article(art: Dict[str, str], d: date, save_dir: str) -> Dict: """下载单篇文章,返回详情 dict。""" info = { "title": art.get("title", ""), "url": art["url"], "ok": False, "path": "", "error": "", } try: page = fetch(art["url"]) title, content, source = parse_article_content(page, art["url"]) if not title or title == "未命名文章": title = art.get("title") or title info["title"] = title md = article_to_markdown(title, content, source, art["url"], d) fname = sanitize_filename(title) + ".md" path = os.path.join(save_dir, fname) # 重名则追加序号 if os.path.exists(path): base = sanitize_filename(title) k = 2 while os.path.exists(os.path.join(save_dir, f"{base}_{k}.md")): k += 1 path = os.path.join(save_dir, f"{base}_{k}.md") with open(path, "w", encoding="utf-8") as f: f.write(md) info["ok"] = True info["path"] = path except Exception as e: info["error"] = str(e) return info def print_download_report(results: List[Dict]) -> None: ok = [r for r in results if r["ok"]] fail = [r for r in results if not r["ok"]] print("\n" + "=" * 50) print("下载完成详情") print("=" * 50) print(f"成功: {len(ok)} 篇 | 失败: {len(fail)} 篇 | 合计: {len(results)} 篇") if ok: print("\n[成功]") for r in ok: print(f" ✓ {r['title']}") print(f" -> {r['path']}") if fail: print("\n[失败]") for r in fail: print(f" ✗ {r.get('title') or r['url']}") print(f" 原因: {r['error']}") print("=" * 50 + "\n") # --------------------------------------------------------------------------- # 模式 1: 抓取 # --------------------------------------------------------------------------- def mode_scrape(save_dir: str) -> None: print("\n>>> 模式: 1. 抓取(浏览后选择文章下载)") d = prompt_date() print(f"\n正在获取 {d.isoformat()} 的版面信息...") paper = DayPaper(d) try: paper.load() except RuntimeError as e: print(f"错误: {e}") return if not paper.sections: print("未解析到任何版面。") return print_numbered([s["name"] for s in paper.sections], f"{d.isoformat()} 版面列表") while True: raw = prompt("请选择要查看的版面序号 (q 退出)") if raw.lower() in ("q", "quit", "exit"): return try: idxs = parse_selection(raw, len(paper.sections)) if len(idxs) != 1: print(" 请输入单个版面序号。") continue section = paper.sections[idxs[0] - 1] break except ValueError as e: print(f" ! {e}") print(f"\n正在获取版面「{section['name']}」的文章列表...") try: articles = paper.get_articles(section) except Exception as e: print(f"错误: 获取文章列表失败: {e}") return if not articles: print("该版面下未解析到文章。") return print_numbered( [a["title"] for a in articles], f"「{section['name']}」文章列表", ) print("选择说明: 单篇如 2;多篇如 1-3 或 1,3,5") while True: raw = prompt("请选择要下载的文章序号 (q 退出)") if raw.lower() in ("q", "quit", "exit"): return try: idxs = parse_selection(raw, len(articles)) if not idxs: print(" 未选择任何文章。") continue break except ValueError as e: print(f" ! {e}") selected = [articles[i - 1] for i in idxs] print(f"\n开始下载 {len(selected)} 篇文章到: {save_dir}") results = [] for i, art in enumerate(selected, 1): print(f" [{i}/{len(selected)}] {art['title']} ...", end=" ", flush=True) r = download_article(art, d, save_dir) results.append(r) print("OK" if r["ok"] else f"失败: {r['error']}") print_download_report(results) # --------------------------------------------------------------------------- # 模式 2: 下载 # --------------------------------------------------------------------------- def mode_download(save_dir: str) -> None: print("\n>>> 模式: 2. 下载(按版面批量下载全部文章)") print("提示: 若只想下载单篇文章,请输入「back」切换到抓取模式。") d = prompt_date() print(f"\n正在获取 {d.isoformat()} 的版面信息...") paper = DayPaper(d) try: paper.load() except RuntimeError as e: print(f"错误: {e}") return if not paper.sections: print("未解析到任何版面。") return print_numbered([s["name"] for s in paper.sections], f"{d.isoformat()} 版面列表") print("选择说明: 单版如 2;多版如 1-3 或 1,3,5") print("提示: 若只想下载单篇文章,请输入「back」切换到抓取模式。") def wants_scrape_mode(text: str) -> bool: t = text.strip().lower().replace(" ", "") return t in ("back", "b", "抓取", "scrape") while True: raw = prompt("请选择要下载全部文章的版面序号 (q 退出 / back 抓取)") if raw.lower() in ("q", "quit", "exit"): return if wants_scrape_mode(raw): mode_scrape(save_dir) return try: idxs = parse_selection(raw, len(paper.sections)) if not idxs: print(" 未选择任何版面。") continue break except ValueError as e: print(f" ! {e}") chosen_sections = [paper.sections[i - 1] for i in idxs] all_results: List[Dict] = [] for sec in chosen_sections: print(f"\n>>> 版面: {sec['name']}") try: articles = paper.get_articles(sec) except Exception as e: print(f" 获取文章列表失败: {e}") continue if not articles: print(" 该版面无文章。") continue print(f" 共 {len(articles)} 篇,开始下载...") for i, art in enumerate(articles, 1): print(f" [{i}/{len(articles)}] {art['title']} ...", end=" ", flush=True) r = download_article(art, d, save_dir) all_results.append(r) print("OK" if r["ok"] else f"失败: {r['error']}") if all_results: print_download_report(all_results) else: print("\n没有下载任何文章。") # --------------------------------------------------------------------------- # 主入口 # --------------------------------------------------------------------------- def main() -> None: # Windows 控制台尽量使用 utf-8 try: sys.stdout.reconfigure(encoding="utf-8") sys.stdin.reconfigure(encoding="utf-8") except Exception: pass save_dir = os.getcwd() print("=" * 50) print(" 人民日报电子版 · 交互式抓取/下载工具") print("=" * 50) print(f"默认保存目录: {save_dir}") print() print("请选择模式:") print(" 1. 抓取 - 浏览版面与标题,按需下载文章") print(" 2. 下载 - 按版面批量下载全部文章") print() while True: choice = prompt("请输入 1 或 2 (q 退出)") if choice.lower() in ("q", "quit", "exit"): print("再见。") return if choice in ("1", "1.", "抓取"): mode_scrape(save_dir) break if choice in ("2", "2.", "下载"): mode_download(save_dir) break print(" 请输入 1 或 2。") # 结束后询问是否继续 while True: again = prompt("是否继续? (1 抓取 / 2 下载 / q 退出)", "q") if again.lower() in ("q", "quit", "exit", ""): print("再见。") return if again in ("1", "抓取"): mode_scrape(save_dir) elif again in ("2", "下载"): mode_download(save_dir) else: print(" 请输入 1 / 2 / q。") if __name__ == "__main__": main()