ARTICLE · 1217251
将公号文章(含图片)整理为 Word 文档
将公号文章(含图片)整理为 Word 文档使用方法:替换变量DEFAULT_URL的值,然后执行此代码 执行结果如下
# -*- coding: utf-8 -*-"""抓取 URL 文章内容(含图片)并整理生成 Word 文档。用法:python url_article_to_word.py [URL] [-o 输出文件名.docx]示例:python url_article_to_word.py https://mp.weixin.qq.com/s/feM61Lf_4NIpknTAPGQMAA"""import argparseimport osimport reimport sysfrom io import BytesIOfrom urllib.parse import urljoinimport requestsfrom bs4 import BeautifulSoup, NavigableStringfrom PIL import Imagefrom docx import Documentfrom docx.enum.text import WD_ALIGN_PARAGRAPHfrom docx.oxml.ns import qnfrom docx.shared import Inches, PtDEFAULT_URL = "https://mp.weixin.qq.com/s/EdHdEsV7dPireTRj7wg4hA"# 页面最大可用宽度(英寸),A4 页边距约 1 英寸MAX_IMAGE_WIDTH_INCH = 6.0# 小于该像素的图片视为图标/表情,忽略MIN_IMAGE_PIXEL = 40INLINE_TAGS = {"span", "strong", "b", "em", "i", "u", "s", "a", "code", "label","small", "mark", "time", "cite", "abbr", "sub", "sup", "font", "br", "img",}HEADING_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6"}HEADERS = {"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ""(KHTML, like Gecko) Chrome/124.0 Safari/537.36"),"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",}# --------------------------------------------------------------------------- ## 网络# --------------------------------------------------------------------------- #def build_session() -> requests.Session:s = requests.Session()s.headers.update(HEADERS)return sdef fetch_html(session: requests.Session, url: str) -> str:resp = session.get(url, timeout=30)resp.raise_for_status()if not resp.encoding or resp.encoding.lower() == "iso-8859-1":resp.encoding = resp.apparent_encoding or "utf-8"return resp.text# --------------------------------------------------------------------------- ## 文章解析# --------------------------------------------------------------------------- #def find_content(soup: BeautifulSoup):"""定位正文容器,兼容微信公众号及常见博客结构。"""for selector in ("#js_content", "div.rich_media_content"):node = soup.select_one(selector)if node:return nodenode = soup.select_one("article")if node:return node# 退而求其次:取正文段落最多的容器body = soup.body or soupbest, best_len = body, 0for div in body.find_all("div"):length = len(div.get_text(strip=True))if length > best_len:best, best_len = div, lengthreturn bestdef extract_meta(soup: BeautifulSoup):def text(selector):node = soup.select_one(selector)return node.get_text(strip=True) if node else ""title = (text("#activity-name")or text("h1.rich_media_title")or text("h1")or (soup.title.get_text(strip=True) if soup.title else ""))title = re.sub(r"\s+", " ", title).strip()author = text("#js_name") or text(".rich_media_meta_nickname")date = text("#publish_time") or text("em#publish_time") or text(".rich_media_meta_text")return title, author, date# --------------------------------------------------------------------------- ## Word 文档构建# --------------------------------------------------------------------------- #def set_default_font(doc: Document, font_name: str = "微软雅黑", size: int = 11):style = doc.styles["Normal"]style.font.name = font_namestyle.font.size = Pt(size)style.element.rPr.rFonts.set(qn("w:eastAsia"), font_name)def add_title(doc: Document, title: str):p = doc.add_heading(level=0)run = p.add_run(title)run.font.name = "微软雅黑"run._element.rPr.rFonts.set(qn("w:eastAsia"), "微软雅黑")p.alignment = WD_ALIGN_PARAGRAPH.CENTERdef add_meta(doc: Document, author: str, date: str):parts = [x for x in (author, date) if x]if not parts:returnp = doc.add_paragraph()p.alignment = WD_ALIGN_PARAGRAPH.CENTERrun = p.add_run(" ".join(parts))run.italic = Truerun.font.size = Pt(9)def iter_inline(nodes, bold=False, italic=False):"""扁平化内联节点,产出 (kind, payload) 序列。"""for node in nodes:if isinstance(node, NavigableString):text = str(node)if text:yield ("text", (text, bold, italic))continuename = node.nameif name == "br":yield ("br", None)elif name == "img":yield ("img", node)elif name in ("strong", "b"):yield from iter_inline(node.children, True, italic)elif name in ("em", "i"):yield from iter_inline(node.children, bold, True)else:yield from iter_inline(node.children, bold, italic)def pick_image_src(img_tag, base_url):for attr in ("data-src", "data-original", "data-backsrc", "src"):src = (img_tag.get(attr) or "").strip()if not src or src.startswith("data:"):continueif src.startswith("//"):src = "https:" + srcreturn urljoin(base_url, src)return Nonedef add_image(doc: Document, session: requests.Session, img_tag, base_url: str, paragraph=None):src = pick_image_src(img_tag, base_url)if not src:return Falsetry:resp = session.get(src, headers={"Referer": base_url}, timeout=30)resp.raise_for_status()data = resp.contentif not data:return Falseim = Image.open(BytesIO(data))width_px, height_px = im.sizeif width_px < MIN_IMAGE_PIXEL or height_px < MIN_IMAGE_PIXEL:return False# GIF/WEBP 等格式统一转 PNG,避免 Word 无法渲染if im.format in ("GIF", "WEBP", "BMP") or im.mode not in ("RGB", "L"):im = im.convert("RGB")buf = BytesIO()im.save(buf, format="PNG")buf.seek(0)else:buf = BytesIO(data)width_inch = min(MAX_IMAGE_WIDTH_INCH, width_px / 96.0)if paragraph is not None:paragraph.add_run().add_picture(buf, width=Inches(width_inch))else:p = doc.add_paragraph()p.alignment = WD_ALIGN_PARAGRAPH.CENTERp.add_run().add_picture(buf, width=Inches(width_inch))print(f" [图片] 已插入 {width_px}x{height_px}px -> {src[:80]}")return Trueexcept Exception as exc: # noqa: BLE001print(f" [图片] 下载/插入失败: {src[:80]} ({exc})")return Falsedef add_table(doc: Document, table_el):grid = []for tr in table_el.find_all("tr"):cells = tr.find_all(["td", "th"], recursive=False)if not cells:continuegrid.append([c.get_text(separator=" ", strip=True) for c in cells])if not grid:returnncols = max(len(r) for r in grid)table = doc.add_table(rows=len(grid), cols=ncols)table.style = "Table Grid"for i, row in enumerate(grid):for j, val in enumerate(row):table.cell(i, j).text = valdoc.add_paragraph()def flush_paragraph(doc: Document, session: requests.Session, nodes, base_url: str, style=None):"""把一批内联节点渲染为一个(或含图片的)段落。"""items = list(iter_inline(nodes))if not items:returnif all(kind == "text" and not payload[0].strip() for kind, payload in items):returnpara = doc.add_paragraph(style=style) if style else doc.add_paragraph()text_seen = Falseimage_seen = Falsefor kind, payload in items:if kind == "text":text, bold, italic = payloadif not text.strip() and not text_seen:continuerun = para.add_run(text)run.bold = bold or Nonerun.italic = italic or Nonetext_seen = Trueelif kind == "br":para.add_run().add_break()elif kind == "img":if add_image(doc, session, payload, base_url, paragraph=para):image_seen = Trueif image_seen and not text_seen:para.alignment = WD_ALIGN_PARAGRAPH.CENTERif not text_seen and not image_seen:para._element.getparent().remove(para._element)def walk_block(container, doc: Document, session: requests.Session, base_url: str):"""按文档顺序遍历容器,保序输出文字段、图片、表格、列表等。"""buffer = []for child in container.children:if isinstance(child, NavigableString):if str(child).strip():buffer.append(child)continuename = child.nameif name in INLINE_TAGS:buffer.append(child)continue# 遇到块级元素,先冲掉缓冲区flush_paragraph(doc, session, buffer, base_url)buffer = []handle_block(child, doc, session, base_url)flush_paragraph(doc, session, buffer, base_url)def handle_block(el, doc: Document, session: requests.Session, base_url: str):name = el.nameif name == "img":add_image(doc, session, el, base_url)elif name == "table":add_table(doc, el)elif name in HEADING_TAGS:level = int(name[1])para = doc.add_heading(level=min(level, 4))for kind, payload in iter_inline(el.children):if kind == "text":text, bold, _ = payloadpara.add_run(text)if el.find("img"):for img in el.find_all("img"):add_image(doc, session, img, base_url)elif name in ("ul", "ol"):style = "List Bullet" if name == "ul" else "List Number"for li in el.find_all("li", recursive=False):flush_paragraph(doc, session, list(li.children), base_url, style=style)elif name == "hr":doc.add_paragraph("─" * 30).alignment = WD_ALIGN_PARAGRAPH.CENTERelse:# p / div / section / blockquote / figure / li 等,继续深入walk_block(el, doc, session, base_url)# --------------------------------------------------------------------------- ## 主流程# --------------------------------------------------------------------------- #def sanitize_filename(name: str, fallback: str = "article") -> str:name = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", name).strip().strip(".")return (name[:120] or fallback)def convert(url: str, output: str = None):session = build_session()print(f"正在抓取: {url}")html = fetch_html(session, url)soup = BeautifulSoup(html, "lxml")title, author, date = extract_meta(soup)content = find_content(soup)if content is None:raise RuntimeError("未能定位文章正文内容")doc = Document()set_default_font(doc)add_title(doc, title or "未命名文章")add_meta(doc, author, date)print("正在整理正文并下载图片...")walk_block(content, doc, session, url)if not output:output = sanitize_filename(title) + ".docx"if not output.lower().endswith(".docx"):output += ".docx"doc.save(output)print(f"完成,已保存: {os.path.abspath(output)}")return outputdef main():parser = argparse.ArgumentParser(description="将 URL 文章(含图片)整理为 Word 文档")parser.add_argument("url", nargs="?", default=DEFAULT_URL, help="文章 URL")parser.add_argument("-o", "--output", default=None, help="输出 .docx 文件名")args = parser.parse_args()try:convert(args.url, args.output)except Exception as exc: # noqa: BLE001print(f"处理失败: {exc}", file=sys.stderr)sys.exit(1)if __name__ == "__main__":main()















































































