夜雨聆风学习资料网

ARTICLE · 1217251

将公号文章(含图片)整理为 Word 文档

将公号文章(含图片)整理为 Word 文档
# -*- 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 node    node = soup.select_one("article")    if node:        return node    # 退而求其次:取正文段落最多的容器    body = soup.body or soup    best, best_len = body, 0    for div in body.find_all("div"):        length = len(div.get_text(strip=True))        if length > best_len:            best, best_len = div, length    return 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_name    style.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:        return    p = doc.add_paragraph()    p.alignment = WD_ALIGN_PARAGRAPH.CENTER    run = p.add_run(" ".join(parts))    run.italic = True    run.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))            continue        name = node.name        if 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:"):            continue        if src.startswith("//"):            src = "https:" + src        return 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 False    try:        resp = session.get(src, headers={"Referer": base_url}, timeout=30)        resp.raise_for_status()        data = resp.content        if not data:            return False        im = Image.open(BytesIO(data))        width_px, height_px = im.size        if 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.CENTER            p.add_run().add_picture(buf, width=Inches(width_inch))        print(f"  [图片] 已插入 {width_px}x{height_px}px -> {src[:80]}")        return True    except Exception as exc:  # noqa: BLE001        print(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:            continue        grid.append([c.get_text(separator=" ", strip=True) for c in cells])    if not grid:        return    ncols = 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 = val    doc.add_paragraph()def flush_paragraph(doc: Document, session: requests.Session, nodes, base_url: str, style=None):    """把一批内联节点渲染为一个(或含图片的)段落。"""    items = list(iter_inline(nodes))    if not items:        return    if all(kind == "text" and not payload[0].strip() for kind, payload in items):        return    para = doc.add_paragraph(style=style) if style else doc.add_paragraph()    text_seen = False    image_seen = False    for kind, payload in items:        if kind == "text":            text, bold, italic = payload            if not text.strip() and not text_seen:                continue            run = para.add_run(text)            run.bold = bold or None            run.italic = italic or None            text_seen = True        elif kind == "br":            para.add_run().add_break()        elif kind == "img":            if add_image(doc, session, payload, base_url, paragraph=para):                image_seen = True    if image_seen and not text_seen:        para.alignment = WD_ALIGN_PARAGRAPH.CENTER    if 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)            continue        name = child.name        if 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.name    if 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, _ = payload                para.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.CENTER    else:        # 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: BLE001        print(f"处理失败: {exc}", file=sys.stderr)        sys.exit(1)if __name__ == "__main__":    main()
使用方法:替换变量DEFAULT_URL的值,然后执行此代码
执行结果如下

相关学习资料