最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

Python代碼實(shí)現(xiàn)下載Odoo18在線(xiàn)文檔并生成markdown文檔

 更新時(shí)間:2026年04月10日 09:30:28   作者:閉關(guān)苦煉內(nèi)功  
本文介紹了一個(gè)Odoo18在線(xiàn)文檔下載工具,通過(guò)Python腳本自動(dòng)爬取Odoo18中文文檔并轉(zhuǎn)換為PDF格式,文中的示例代碼講解詳細(xì),感興趣的小伙伴可以了解下

Odoo 18在線(xiàn)文檔是一個(gè)全面且結(jié)構(gòu)化的信息庫(kù),旨在幫助用戶(hù)、開(kāi)發(fā)者及合作伙伴了解和使用Odoo 18這套開(kāi)源企業(yè)管理系統(tǒng)。這套文檔既是系統(tǒng)功能的權(quán)威解讀,也是技術(shù)開(kāi)發(fā)和業(yè)務(wù)落地的實(shí)操指南。

下面我們就來(lái)看看如何使用Python實(shí)現(xiàn)Odoo18文檔下載工具并生成為markdown文檔格式吧。

Odoo 18在線(xiàn)文檔介紹

Odoo 18的在線(xiàn)文檔體系龐大,主要分為以下幾類(lèi),方便不同角色的人員按需查閱:

  • 用戶(hù)指南 (User Guide):專(zhuān)為最終用戶(hù)設(shè)計(jì),按銷(xiāo)售、采購(gòu)、庫(kù)存等不同模塊提供操作指引,幫助用戶(hù)完成日常工作。這部分內(nèi)容通?;谡鎸?shí)業(yè)務(wù)場(chǎng)景編寫(xiě),配有實(shí)戰(zhàn)案例。
  • 開(kāi)發(fā)者文檔 (Developer Documentation):這是為技術(shù)人員準(zhǔn)備的核心資料。它深入講解了Odoo的底層架構(gòu),例如模型(Model)、視圖(View)、控制器(Controller),并提供完整的API參考和模塊開(kāi)發(fā)指南,是進(jìn)行二次開(kāi)發(fā)的基礎(chǔ)。
  • 實(shí)施與集成指南:涵蓋系統(tǒng)安裝、部署、配置和性能優(yōu)化等運(yùn)維相關(guān)內(nèi)容,同時(shí)介紹如何將Odoo與其他系統(tǒng)對(duì)接,進(jìn)行高級(jí)功能集成。
  • 安全與更新記錄:提供平臺(tái)的安全性策略和漏洞上報(bào)渠道,并記錄Odoo 18版本新增的“更快的處理速度、改進(jìn)的用戶(hù)界面(UI)”等特性。

Python腳本實(shí)現(xiàn)

# coding=utf-8
import logging
import os
import time
import re
import requests
from bs4 import BeautifulSoup
import markdown2
import pdfkit
import sys
import hashlib
from urllib.parse import urljoin, urlparse
from io import BytesIO
from PIL import Image
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class OdooMarkdownCrawler:
    """
    Odoo文檔爬蟲(chóng),生成Markdown格式,最后轉(zhuǎn)換為PDF
    """
    def __init__(self, base_url, output_name="Odoo18_User_Tutorial"):
        """
        初始化爬蟲(chóng)
        :param base_url: 基礎(chǔ)URL
        :param output_name: 輸出文件名
        """
        self.base_url = base_url
        self.output_name = output_name
        self.domain = '{uri.scheme}://{uri.netloc}'.format(uri=urlparse(self.base_url))
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
            'Connection': 'keep-alive',
        }
        self.session = requests.Session()
        self.session.headers.update(self.headers)
        # 創(chuàng)建目錄
        self.temp_dir = "temp_markdown_files"
        self.images_dir = os.path.join(self.temp_dir, "images")
        os.makedirs(self.temp_dir, exist_ok=True)
        os.makedirs(self.images_dir, exist_ok=True)
        # 存儲(chǔ)已訪(fǎng)問(wèn)的URL,避免重復(fù)
        self.visited_urls = set()
        # 存儲(chǔ)Markdown文件
        self.markdown_files = []
        # 存儲(chǔ)所有頁(yè)面內(nèi)容
        self.all_pages = []
        logger.info(f"爬蟲(chóng)初始化完成,基礎(chǔ)URL: {self.base_url}")
    def get_page_content(self, url, max_retries=3):
        """
        獲取頁(yè)面內(nèi)容,帶重試機(jī)制
        """
        for attempt in range(max_retries):
            try:
                logger.info(f"正在獲取頁(yè)面: {url} (嘗試 {attempt + 1}/{max_retries})")
                response = self.session.get(url, timeout=30)
                if response.status_code == 200:
                    return response.content
                elif response.status_code == 429:
                    logger.warning(f"請(qǐng)求過(guò)于頻繁,等待 3 秒后重試...")
                    time.sleep(3)
                else:
                    logger.warning(f"獲取頁(yè)面失敗,狀態(tài)碼: {response.status_code}")
                    if response.status_code == 404:
                        return None
                time.sleep(1)
            except requests.exceptions.RequestException as e:
                logger.error(f"請(qǐng)求異常: {e}")
                if attempt < max_retries - 1:
                    logger.info(f"等待 2 秒后重試...")
                    time.sleep(2)
        logger.error(f"重試 {max_retries} 次后仍無(wú)法獲取頁(yè)面: {url}")
        return None
    def discover_links(self, start_url):
        """
        從起始頁(yè)面發(fā)現(xiàn)所有相關(guān)鏈接
        """
        logger.info(f"開(kāi)始發(fā)現(xiàn)鏈接: {start_url}")
        content = self.get_page_content(start_url)
        if not content:
            logger.error("無(wú)法獲取起始頁(yè)面內(nèi)容")
            return []
        soup = BeautifulSoup(content, 'html.parser')
        # 查找所有文檔鏈接
        links = []
        visited = set()
        # 方法1:查找導(dǎo)航菜單中的鏈接
        nav_elements = soup.find_all(['nav', 'ul', 'div'], class_=re.compile('nav|menu|toc|sidebar', re.I))
        for nav in nav_elements:
            for a_tag in nav.find_all('a', href=True):
                href = a_tag['href'].strip()
                text = a_tag.get_text(strip=True)
                if not href or href.startswith('#') or 'javascript:' in href or href.startswith('mailto:'):
                    continue
                # 構(gòu)建完整URL
                full_url = urljoin(self.base_url, href)
                # 檢查是否是文檔頁(yè)面
                if '/documentation/18.0/zh_CN/' in full_url and full_url.endswith('.html'):
                    if full_url not in visited:
                        visited.add(full_url)
                        links.append({
                            'url': full_url,
                            'title': text,
                            'section': '導(dǎo)航菜單'
                        })
                        logger.debug(f"發(fā)現(xiàn)導(dǎo)航鏈接: {text} -> {full_url}")
        # 方法2:查找主要內(nèi)容中的鏈接
        main_content = soup.find('main') or soup.find(class_=re.compile('content|article|body', re.I))
        if main_content:
            for a_tag in main_content.find_all('a', href=True):
                href = a_tag['href'].strip()
                text = a_tag.get_text(strip=True)
                if not href or href.startswith('#') or 'javascript:' in href:
                    continue
                full_url = urljoin(self.base_url, href)
                if '/documentation/18.0/zh_CN/' in full_url and full_url.endswith('.html'):
                    if full_url not in visited:
                        visited.add(full_url)
                        links.append({
                            'url': full_url,
                            'title': text,
                            'section': '主要內(nèi)容'
                        })
                        logger.debug(f"發(fā)現(xiàn)內(nèi)容鏈接: {text} -> {full_url}")
        # 方法3:添加一些常見(jiàn)的核心頁(yè)面(備用)
        if not links:
            logger.warning("未發(fā)現(xiàn)鏈接,使用備用核心頁(yè)面列表")
            core_pages = [
                'applications.html',
                'applications/essentials/activities.html',
                'applications/general/overview.html',
                'applications/sales.html',
                'applications/inventory.html',
                'applications/crm.html',
                'applications/accounting.html',
                'applications/reporting.html',
                'applications/contacts.html',
                'applications/general/search.html'
            ]
            for page in core_pages:
                full_url = f"https://www.odooai.cn/documentation/18.0/zh_CN/{page}"
                title = page.replace('.html', '').replace('/', ' ').replace('_', ' ').title()
                links.append({
                    'url': full_url,
                    'title': title,
                    'section': '備用核心頁(yè)面'
                })
        logger.info(f"共發(fā)現(xiàn) {len(links)} 個(gè)頁(yè)面鏈接")
        return links
    def download_image(self, img_url, page_url):
        """
        下載圖片并返回本地路徑
        """
        try:
            if not img_url.startswith('http'):
                img_url = urljoin(page_url, img_url)
            logger.debug(f"下載圖片: {img_url}")
            # 獲取圖片內(nèi)容
            response = self.session.get(img_url, timeout=15)
            if response.status_code != 200:
                logger.warning(f"圖片下載失敗,狀態(tài)碼: {response.status_code}")
                return None
            # 檢查文件類(lèi)型
            content_type = response.headers.get('Content-Type', '')
            if not content_type.startswith('image/'):
                logger.warning(f"非圖片內(nèi)容類(lèi)型: {content_type}")
                return None
            # 生成文件名
            img_hash = hashlib.md5(response.content).hexdigest()[:8]
            ext = '.jpg' if 'jpeg' in content_type else '.png' if 'png' in content_type else '.gif'
            filename = f"img_{img_hash}{ext}"
            filepath = os.path.join(self.images_dir, filename)
            # 保存圖片
            with open(filepath, 'wb') as f:
                f.write(response.content)
            # 返回相對(duì)路徑
            return f"./images/{filename}"
        except Exception as e:
            logger.error(f"圖片下載失敗 {img_url}: {e}")
            return None
    def process_element(self, element, page_url, depth=0):
        """
        遞歸處理HTML元素,保持原始結(jié)構(gòu)
        """
        markdown_lines = []
        # 處理圖片
        if element.name == 'img':
            img_url = element.get('src', '')
            if img_url:
                local_path = self.download_image(img_url, page_url)
                if local_path:
                    alt_text = element.get('alt', '圖片')
                    markdown_lines.append(f"![{alt_text}]({local_path})\n")
            return markdown_lines
        # 處理文本節(jié)點(diǎn)
        if element.name is None:
            text = element.strip()
            if text:
                # 保持縮進(jìn)
                indent = '  ' * depth
                markdown_lines.append(f"{indent}{text}")
            return markdown_lines
        # 處理段落
        if element.name == 'p':
            text = element.get_text(strip=True)
            if text:
                markdown_lines.append(f"{text}\n")
            return markdown_lines
        # 處理標(biāo)題
        if element.name.startswith('h'):
            level = int(element.name[1])
            text = element.get_text(strip=True)
            if text:
                markdown_lines.append(f"{'#' * level} {text}\n")
            return markdown_lines
        # 處理列表
        if element.name in ['ul', 'ol']:
            list_type = '-' if element.name == 'ul' else '1.'
            for i, li in enumerate(element.find_all('li', recursive=False)):
                items = self.process_element(li, page_url, depth + 1)
                if items:
                    # 處理多行列表項(xiàng)
                    for j, item in enumerate(items):
                        if j == 0:
                            markdown_lines.append(f"{'  ' * depth}{list_type} {item}")
                        else:
                            markdown_lines.append(f"{'  ' * depth}   {item}")
            markdown_lines.append("\n")
            return markdown_lines
        # 處理表格
        if element.name == 'table':
            return [self.table_to_markdown(element)]
        # 處理代碼塊
        if element.name == 'pre':
            code = element.get_text(strip=True)
            if code:
                markdown_lines.append(f"```\n[code]\n```\n")
            return markdown_lines
        # 處理引用塊
        if element.name == 'blockquote':
            text = element.get_text(strip=True)
            if text:
                for line in text.split('\n'):
                    markdown_lines.append(f"> {line}\n")
            return markdown_lines
        # 處理其他元素
        if element.contents:
            for child in element.children:
                lines = self.process_element(child, page_url, depth + 1)
                markdown_lines.extend(lines)
        return markdown_lines
    def html_to_markdown(self, html_content, page_url, title):
        """
        將HTML內(nèi)容轉(zhuǎn)換為Markdown格式,保持元素位置
        """
        soup = BeautifulSoup(html_content, 'html.parser')
        # 移除不需要的元素
        for selector in ['nav', 'footer', '.o_header', '.o_footer', '.o_search_header', 
                        '.o_mobile-overlay', '.header', '.footer', '.edit-page',
                        '.share-buttons', '.search-box', '.o_topbar', '.o_bottombar',
                        '.o_edit_page', '.o_share_btn', '.o_search', '.edit-button', '.feedback',
                        '.o_actions', '.toc', '.sidebar']:
            for element in soup.select(selector):
                element.decompose()
        # 找到主要內(nèi)容區(qū)域
        main_content = soup.find('main') or soup.find(class_=re.compile('content|article|body', re.I)) or soup.find(id=re.compile('content|main'))
        if not main_content:
            logger.warning("未找到主要內(nèi)容區(qū)域,使用整個(gè)body")
            main_content = soup.body
        # 提取標(biāo)題
        page_title = title
        if not page_title:
            h1 = soup.find('h1')
            if h1:
                page_title = h1.get_text(strip=True)
        # 創(chuàng)建Markdown內(nèi)容
        markdown_content = []
        # 添加標(biāo)題
        if page_title:
            markdown_content.append(f"# {page_title}\n")
        # 添加元信息
        markdown_content.append(f"**來(lái)源:** [{page_url}]({page_url})\n")
        markdown_content.append(f"**爬取時(shí)間:** {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
        markdown_content.append("---\n")
        # 處理所有內(nèi)容元素,保持原始位置
        for element in main_content.children:
            lines = self.process_element(element, page_url)
            markdown_content.extend(lines)
        return '\n'.join(markdown_content)
    def table_to_markdown(self, table_element):
        """
        將HTML表格轉(zhuǎn)換為Markdown表格
        """
        if not table_element.find('tr'):
            return ""
        rows = table_element.find_all('tr')
        if not rows:
            return ""
        markdown_rows = []
        for i, row in enumerate(rows):
            cells = row.find_all(['td', 'th'])
            if not cells:
                continue
            row_data = [cell.get_text(strip=True) for cell in cells]
            if i == 0:  # 表頭
                markdown_rows.append('| ' + ' | '.join(row_data) + ' |')
                markdown_rows.append('| ' + ' | '.join(['---'] * len(row_data)) + ' |')
            else:
                markdown_rows.append('| ' + ' | '.join(row_data) + ' |')
        return '\n'.join(markdown_rows) + '\n'
    def save_markdown_file(self, content, filename):
        """
        保存Markdown文件
        """
        # 處理文件名中的特殊字符
        safe_filename = re.sub(r'[\\/*?:"<>|]', '', filename)
        safe_filename = safe_filename.replace(' ', '_')
        safe_filename = safe_filename[:50] + '.md'
        filepath = os.path.join(self.temp_dir, safe_filename)
        try:
            with open(filepath, 'w', encoding='utf-8') as f:
                f.write(content)
            logger.info(f"已保存Markdown文件: {safe_filename}")
            return filepath
        except Exception as e:
            logger.error(f"保存文件失敗 {safe_filename}: {e}")
            return None
    def crawl_pages(self, page_links):
        """
        爬取所有頁(yè)面并轉(zhuǎn)換為Markdown
        """
        logger.info(f"開(kāi)始爬取 {len(page_links)} 個(gè)頁(yè)面...")
        for i, page_info in enumerate(page_links):
            url = page_info['url']
            title = page_info['title'] or f"頁(yè)面_{i+1}"
            # 檢查是否已訪(fǎng)問(wèn)
            if url in self.visited_urls:
                logger.info(f"跳過(guò)已訪(fǎng)問(wèn)的頁(yè)面: {url}")
                continue
            self.visited_urls.add(url)
            # 獲取頁(yè)面內(nèi)容
            html_content = self.get_page_content(url)
            if not html_content:
                # 嘗試常見(jiàn)的URL變體
                url_variants = [
                    url.replace('/zh_CN/', '/zh_CN/applications/'),
                    url.replace('/zh_CN/', '/zh_CN/applications/general/'),
                    url.replace('/zh_CN/', '/zh_CN/applications/essentials/'),
                    url.replace('/zh_CN/', '/zh_CN/applications/sales/'),
                    url.replace('/zh_CN/', '/zh_CN/applications/inventory/'),
                    url.replace('/zh_CN/', '/zh_CN/applications/crm/'),
                    url.replace('/zh_CN/', '/zh_CN/applications/accounting/'),
                ]
                for variant_url in url_variants:
                    logger.info(f"嘗試URL變體: {variant_url}")
                    html_content = self.get_page_content(variant_url)
                    if html_content:
                        url = variant_url
                        break
            if not html_content:
                logger.warning(f"無(wú)法獲取頁(yè)面內(nèi)容,跳過(guò): {url}")
                continue
            # 轉(zhuǎn)換為Markdown
            markdown_content = self.html_to_markdown(html_content, url, title)
            # 生成文件名
            safe_title = re.sub(r'[\\/*?:"<>|]', '', title)[:30]
            safe_title = safe_title.replace(' ', '_')
            filename = f"{i+1:03d}_{safe_title}"
            # 保存Markdown文件
            filepath = self.save_markdown_file(markdown_content, filename)
            if filepath:
                self.markdown_files.append(filepath)
                self.all_pages.append({
                    'title': title,
                    'content': markdown_content,
                    'url': url
                })
            # 每3個(gè)頁(yè)面暫停一下,避免請(qǐng)求過(guò)于頻繁
            if (i + 1) % 3 == 0:
                logger.info(f"已爬取 {i + 1} 個(gè)頁(yè)面,暫停 2 秒...")
                time.sleep(2)
            # 每個(gè)頁(yè)面之間稍作延遲
            time.sleep(1.0)
        logger.info(f"爬取完成,共成功保存 {len(self.markdown_files)} 個(gè)Markdown文件")
    def combine_markdown_files(self):
        """
        合并所有Markdown文件
        """
        if not self.all_pages:
            logger.error("沒(méi)有可合并的Markdown文件")
            return None
        logger.info("開(kāi)始合并Markdown文件...")
        combined_content = []
        # 添加封面
        combined_content.append("# Odoo 18 用戶(hù)教程\n")
        combined_content.append(f"**生成時(shí)間:** {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
        combined_content.append(f"**版本:** 18.0\n")
        combined_content.append(f"**語(yǔ)言:** 簡(jiǎn)體中文\n")
        combined_content.append(f"**總頁(yè)數(shù):** {len(self.all_pages)}\n")
        combined_content.append("---\n")
        # 添加目錄
        combined_content.append("## 目錄\n")
        for i, page in enumerate(self.all_pages):
            title = page['title']
            anchor = re.sub(r'[^\w\s-]', '', title).lower().replace(' ', '-')
            combined_content.append(f"{i+1}. [{title}](#{anchor})")
        combined_content.append("\n")
        # 添加所有頁(yè)面內(nèi)容
        for i, page in enumerate(self.all_pages):
            title = page['title']
            content = page['content']
            # 為每個(gè)頁(yè)面添加分頁(yè)符
            combined_content.append(f"\n\n<!-- 分頁(yè)符 -->\n\n")
            combined_content.append(f"## {title}\n")
            combined_content.append(content)
        combined_content.append("\n\n---\n")
        combined_content.append("**文檔生成完成**\n")
        combined_filepath = os.path.join(self.temp_dir, "combined_document.md")
        try:
            with open(combined_filepath, 'w', encoding='utf-8') as f:
                f.write('\n'.join(combined_content))
            logger.info(f"已保存合并的Markdown文件: {combined_filepath}")
            return combined_filepath
        except Exception as e:
            logger.error(f"保存合并文件失敗: {e}")
            return None
    def convert_markdown_to_html(self, markdown_file):
        """
        將Markdown轉(zhuǎn)換為HTML
        """
        try:
            with open(markdown_file, 'r', encoding='utf-8') as f:
                markdown_content = f.read()
            # 轉(zhuǎn)換為HTML
            html_content = markdown2.markdown(markdown_content, extras=["tables", "fenced-code-blocks", "footnotes"])
            # 添加HTML結(jié)構(gòu)和樣式
            styled_html = f"""
            <!DOCTYPE html>
            <html lang="zh-CN">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Odoo 18 用戶(hù)教程</title>
                <style>
                    body {{
                        font-family: "Microsoft YaHei", "SimSun", Arial, sans-serif;
                        line-height: 1.6;
                        color: #333;
                        max-width: 1000px;
                        margin: 0 auto;
                        padding: 20px;
                    }}
                    h1, h2, h3, h4, h5, h6 {{
                        color: #2c3e50;
                        margin-top: 1.5em;
                        margin-bottom: 0.5em;
                    }}
                    h1 {{
                        border-bottom: 2px solid #3498db;
                        padding-bottom: 10px;
                        font-size: 2em;
                    }}
                    h2 {{
                        border-left: 4px solid #3498db;
                        padding-left: 10px;
                        font-size: 1.8em;
                    }}
                    a {{
                        color: #3498db;
                        text-decoration: none;
                    }}
                    a:hover {{
                        text-decoration: underline;
                    }}
                    img {{
                        max-width: 100%;
                        height: auto;
                        display: block;
                        margin: 10px auto;
                        border: 1px solid #ddd;
                        padding: 5px;
                        border-radius: 4px;
                    }}
                    pre, code {{
                        background-color: #f8f9fa;
                        padding: 5px 10px;
                        border-radius: 4px;
                        font-family: Consolas, Monaco, "Andale Mono", monospace;
                        font-size: 0.9em;
                        overflow-x: auto;
                    }}
                    pre {{
                        padding: 15px;
                        border-left: 4px solid #3498db;
                        margin: 15px 0;
                        background-color: #f9f9f9;
                    }}
                    table {{
                        border-collapse: collapse;
                        width: 100%;
                        margin: 15px 0;
                        border: 1px solid #ddd;
                    }}
                    th, td {{
                        border: 1px solid #ddd;
                        padding: 8px 12px;
                        text-align: left;
                    }}
                    th {{
                        background-color: #f2f2f2;
                        font-weight: bold;
                    }}
                    tr:nth-child(even) {{
                        background-color: #f9f9f9;
                    }}
                    blockquote {{
                        border-left: 4px solid #3498db;
                        padding-left: 15px;
                        margin: 15px 0;
                        color: #666;
                        background-color: #f8f9fa;
                        border-radius: 0 4px 4px 0;
                    }}
                    .page-break {{
                        page-break-after: always;
                    }}
                </style>
            </head>
            <body>
                {html_content}
            </body>
            </html>
            """
            html_filepath = os.path.join(self.temp_dir, "combined_document.html")
            with open(html_filepath, 'w', encoding='utf-8') as f:
                f.write(styled_html)
            logger.info(f"已保存HTML文件: {html_filepath}")
            return html_filepath
        except Exception as e:
            logger.error(f"Markdown轉(zhuǎn)HTML失敗: {e}")
            return None
    def convert_to_pdf(self, html_file):
        """
        將HTML轉(zhuǎn)換為PDF
        """
        if not html_file or not os.path.exists(html_file):
            logger.error("HTML文件不存在,無(wú)法轉(zhuǎn)換為PDF")
            return False
        logger.info("開(kāi)始轉(zhuǎn)換PDF...")
        try:
            # 配置PDF選項(xiàng)
            options = {
                'page-size': 'A4',
                'margin-top': '0.75in',
                'margin-right': '0.75in',
                'margin-bottom': '0.75in',
                'margin-left': '0.75in',
                'encoding': "UTF-8",
                'no-outline': None,
                'enable-local-file-access': True,
                'footer-center': '[page] / [topage]',
                'footer-font-size': '8',
                'header-center': 'Odoo 18 用戶(hù)教程',
                'header-font-size': '8',
                'header-spacing': '5',
                'footer-spacing': '5',
                'minimum-font-size': '10',
                'zoom': '1.25',
                'quiet': ''
            }
            output_path = f"{self.output_name}.pdf"
            logger.info(f"開(kāi)始生成PDF文件: {output_path}")
            # 轉(zhuǎn)換為PDF
            pdfkit.from_file(html_file, output_path, options=options)
            logger.info(f"PDF轉(zhuǎn)換成功!文件保存為: {output_path}")
            return True
        except Exception as e:
            logger.error(f"PDF轉(zhuǎn)換失敗: {e}")
            logger.error("可能的原因:")
            logger.error("1. 未安裝wkhtmltopdf,請(qǐng)安裝:https://wkhtmltopdf.org/downloads.html")
            logger.error("2. wkhtmltopdf路徑未配置")
            logger.error("3. 文件路徑包含中文或特殊字符")
            logger.error("4. 內(nèi)存不足或文件過(guò)大")
            return False
    def cleanup(self):
        """
        清理臨時(shí)文件
        """
        logger.info("開(kāi)始清理臨時(shí)文件...")
        # 不刪除Markdown文件,保留作為備份
        logger.info("保留Markdown文件作為備份")
        logger.info("清理完成")
    def run(self):
        """
        運(yùn)行爬蟲(chóng)
        """
        start_time = time.time()
        logger.info("Odoo Markdown爬蟲(chóng)啟動(dòng)!")
        # 1. 發(fā)現(xiàn)所有鏈接
        page_links = self.discover_links(self.base_url)
        if not page_links:
            logger.error("未發(fā)現(xiàn)任何頁(yè)面鏈接")
            return
        # 2. 爬取所有頁(yè)面
        self.crawl_pages(page_links)
        if not self.all_pages:
            logger.error("沒(méi)有成功爬取任何頁(yè)面")
            return
        # 3. 合并Markdown文件
        combined_md_file = self.combine_markdown_files()
        if not combined_md_file:
            logger.error("合并Markdown文件失敗")
            return
        # 4. 轉(zhuǎn)換為HTML
        html_file = self.convert_markdown_to_html(combined_md_file)
        if not html_file:
            logger.error("轉(zhuǎn)換為HTML失敗")
            return
        # 5. 轉(zhuǎn)換為PDF
        self.convert_to_pdf(html_file)
        # 6. 清理
        self.cleanup()
        total_time = time.time() - start_time
        logger.info(f"爬蟲(chóng)運(yùn)行完成!總耗時(shí): {total_time:.2f} 秒")
        logger.info(f"共處理頁(yè)面: {len(page_links)} 個(gè)")
        logger.info(f"成功保存頁(yè)面: {len(self.all_pages)} 個(gè)")
        # 顯示輸出文件信息
        logger.info(f"輸出文件:")
        logger.info(f"- Markdown文件: {self.temp_dir}/")
        logger.info(f"- PDF文件: {self.output_name}.pdf")
def main():
    """
    主函數(shù)
    """
    # 配置參數(shù)
    base_url = "https://www.odooai.cn/documentation/18.0/zh_CN/applications.html"
    output_name = "Odoo18_User_Tutorial"
    # 創(chuàng)建爬蟲(chóng)實(shí)例
    crawler = OdooMarkdownCrawler(base_url, output_name)
    # 運(yùn)行爬蟲(chóng)
    crawler.run()
if __name__ == "__main__":
    main()

方法補(bǔ)充

將 Odoo 18 在線(xiàn)文檔下載并轉(zhuǎn)為 Markdown,主要有兩條技術(shù)路徑:

路徑方法技術(shù)門(mén)檻輸出質(zhì)量推薦度
路徑一從官方 GitHub 克隆文檔源碼 → Sphinx 本地構(gòu)建 → sphinx-markdown-builder 導(dǎo)出 Markdown較低,一次配置后自動(dòng)化運(yùn)行高質(zhì)量(保留完整結(jié)構(gòu)、交叉引用)????? 強(qiáng)烈推薦
路徑二直接用 Python 獲取在線(xiàn) HTML 頁(yè)面 → html-to-markdown / markgrab 逐頁(yè)轉(zhuǎn)換中等,需處理反爬、遞歸遍歷中等(可能丟失交叉引用、樣式)??? 備選方案

核心結(jié)論:能走路徑一就不要走路徑二。Odoo 官方文檔使用 Sphinx 從 reStructuredText (.rst) 源文件生成 HTML,這意味著你可以在本地重新構(gòu)建,并通過(guò) sphinx-markdown-builder 直接將 .rst 源碼導(dǎo)出為 Markdown,質(zhì)量遠(yuǎn)高于對(duì)最終 HTML 的二次抓取。

路徑一:克隆源碼 + Sphinx 本地構(gòu)建轉(zhuǎn) Markdown

完整操作步驟

第一步:克隆 Odoo 文檔倉(cāng)庫(kù)

Odoo 文檔源碼托管在官方 GitHub 倉(cāng)庫(kù),可使用以下命令克?。?/p>

# Odoo 18 文檔通常位于 odoo/documentation 目錄下
git clone https://github.com/odoo/odoo.git
cd odoo/documentation

如果只想獲取文檔而不下載全部 Odoo 源碼,可單獨(dú)克隆文檔倉(cāng)庫(kù)(需確認(rèn) Odoo 18 的官方文檔倉(cāng)庫(kù)地址)。根據(jù)搜索結(jié)果,已有開(kāi)發(fā)者通過(guò)克隆 odoo/documentation 倉(cāng)庫(kù)并在本地構(gòu)建的方式獲取離線(xiàn) HTML 文檔。

第二步:安裝 Sphinx 及相關(guān)依賴(lài)

# 安裝 Sphinx
pip install sphinx
# 安裝 sphinx-markdown-builder(用于導(dǎo)出 Markdown)
pip install sphinx-markdown-builder
# 根據(jù) Odoo 文檔的 requirements.txt 安裝其他依賴(lài)(如有)
# pip install -r requirements.txt

sphinx-markdown-builder 的核心作用是將 Sphinx 生成的文檔樹(shù)轉(zhuǎn)換為 Markdown 格式,讓你能夠利用 Sphinx 的強(qiáng)大功能,同時(shí)享受 Markdown 的簡(jiǎn)潔和流行。

第三步:配置 Sphinx 以支持 Markdown 輸出

在文檔源碼目錄中找到或創(chuàng)建 conf.py 配置文件,添加以下配置:

# 在 conf.py 中添加擴(kuò)展
extensions = [
    'sphinx_markdown_builder',  # 關(guān)鍵:添加 Markdown 導(dǎo)出支持
    # 其他 Odoo 文檔需要的擴(kuò)展...
]
# 如果需要保留中文內(nèi)容,設(shè)置語(yǔ)言
language = 'zh_CN'

Odoo 文檔原生的 conf.py 可能已配置了多個(gè)擴(kuò)展,請(qǐng)不要覆蓋它們,而是將 sphinx_markdown_builder 追加到 extensions 列表中。

第四步:構(gòu)建并導(dǎo)出 Markdown

# 方法一:使用 sphinx-build 直接生成 Markdown
sphinx-build -b markdown . ./build/markdown
# 方法二:如果文檔已構(gòu)建為 HTML,也可以從 HTML 轉(zhuǎn)換
# sphinx-build -b markdown ./_build/html ./build/markdown_from_html

-b markdown 參數(shù)指定使用 sphinx_markdown_builder 構(gòu)建器,將源碼輸出為 Markdown 格式。

第五步:驗(yàn)證輸出

# 查看生成的 Markdown 文件
ls ./build/markdown/

生成的 Markdown 文件會(huì)保留原有的文檔結(jié)構(gòu)、標(biāo)題層級(jí)、交叉引用、代碼塊等格式,可以直接用于 Obsidian、Typora 等工具閱讀或編輯。

路徑二:直接爬取在線(xiàn)文檔 + 逐頁(yè)轉(zhuǎn)換 HTML → Markdown

如果無(wú)法通過(guò)源碼構(gòu)建方式獲?。ㄈ绮幌肟寺〈笮蛡}(cāng)庫(kù),或只需少量頁(yè)面),可采用 Python 爬取在線(xiàn) HTML 文檔并轉(zhuǎn)換為 Markdown。

使用 html-to-markdown 庫(kù)(推薦)

html-to-markdown 是一個(gè)高性能的 Rust 核心庫(kù),Python API 簡(jiǎn)潔,轉(zhuǎn)換速度快。

import requests
from html_to_markdown import convert, ConversionOptions
from urllib.parse import urljoin, urlparse
import time
import os
from bs4 import BeautifulSoup
# 配置
BASE_URL = "https://www.odoo.com/documentation/18.0/zh_CN/"
OUTPUT_DIR = "./odoo18_markdown"
# 創(chuàng)建輸出目錄
os.makedirs(OUTPUT_DIR, exist_ok=True)
# 配置轉(zhuǎn)換選項(xiàng)
options = ConversionOptions(
    heading_style="atx",      # 使用 ATX 風(fēng)格標(biāo)題(# 開(kāi)頭)
    list_indent_width=2,      # 列表縮進(jìn)寬度
    extract_metadata=True,    # 提取元數(shù)據(jù)
    extract_tables=True,      # 提取表格結(jié)構(gòu)
)

ConversionOptions 提供了豐富的自定義選項(xiàng):heading_style 可選 underlined、atx 或 atx_closedlist_indent_width 控制縮進(jìn)級(jí)別;extract_tables 開(kāi)啟結(jié)構(gòu)化表格提取。

爬取并轉(zhuǎn)換單個(gè)頁(yè)面:

def fetch_and_convert(url):
    """獲取頁(yè)面HTML并轉(zhuǎn)換為Markdown"""
    try:
        resp = requests.get(url, timeout=30, headers={
            'User-Agent': 'Mozilla/5.0 (compatible; OdooDocBot/1.0)'
        })
        resp.raise_for_status()
        result = convert(resp.text, options)
        markdown = result["content"]
        return markdown
    except Exception as e:
        print(f"Error processing {url}: {e}")
        return None

convert 函數(shù)返回的 ConversionResult 字典包含多個(gè)字段:content(轉(zhuǎn)換后的 Markdown)、metadata(提取的元數(shù)據(jù))、tables(結(jié)構(gòu)化表格數(shù)據(jù))、images(提取的圖片信息)以及 warnings(轉(zhuǎn)換警告)。

使用 markgrab 自動(dòng)提取文章內(nèi)容(適合噪聲較多的頁(yè)面)

markgrab 是一個(gè)更智能的內(nèi)容提取工具,能自動(dòng)過(guò)濾導(dǎo)航欄、側(cè)邊欄、廣告等噪聲,提取頁(yè)面的核心文章內(nèi)容。

import asyncio
from markgrab import extract
async def extract_markdown(url):
    result = await extract(
        url,
        max_chars=50000,           # 限制輸出長(zhǎng)度
        use_browser=False,         # 靜態(tài)頁(yè)面不需要瀏覽器渲染
    )
    return result.markdown
# 運(yùn)行異步函數(shù)
markdown = asyncio.run(extract_markdown("https://www.odoo.com/documentation/18.0/zh_CN/index.html"))

markgrab 的特點(diǎn):自動(dòng)檢測(cè)內(nèi)容類(lèi)型(HTML/PDF/DOCX),支持異步操作,內(nèi)置內(nèi)容密度過(guò)濾以去除噪聲,如果首次獲取不足 50 詞會(huì)自動(dòng)回退到 Playwright 瀏覽器渲染。

完整的遞歸爬取示例

import requests
from html_to_markdown import convert, ConversionOptions
from urllib.parse import urljoin, urlparse
import time
import os
from bs4 import BeautifulSoup
BASE_URL = "https://www.odoo.com/documentation/18.0/zh_CN/"
OUTPUT_DIR = "./odoo18_markdown"
visited = set()
os.makedirs(OUTPUT_DIR, exist_ok=True)
options = ConversionOptions(
    heading_style="atx",
    list_indent_width=2,
    extract_metadata=True,
    extract_tables=True,
)
def get_page_links(html, current_url):
    """從頁(yè)面中提取同域文檔鏈接"""
    soup = BeautifulSoup(html, 'html.parser')
    links = set()
    for a in soup.find_all('a', href=True):
        href = urljoin(current_url, a['href'])
        # 過(guò)濾:同域、文檔路徑、非錨點(diǎn)、非外部資源
        if (BASE_URL in href and not href.endswith(('.png', '.jpg', '.pdf')) 
            and '#' not in href and href not in visited):
            links.add(href)
    return links
def crawl_and_convert(url):
    if url in visited:
        return
    visited.add(url)
    print(f"Processing: {url}")
    try:
        resp = requests.get(url, timeout=30, headers={
            'User-Agent': 'Mozilla/5.0 (compatible; OdooDocBot/1.0)'
        })
        resp.raise_for_status()
        # 轉(zhuǎn)換主內(nèi)容為 Markdown
        result = convert(resp.text, options)
        markdown = result["content"]
        # 保存 Markdown 文件
        filename = url.replace(BASE_URL, '').replace('/', '_') or 'index'
        filepath = os.path.join(OUTPUT_DIR, f"{filename}.md")
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(f"# Source: {url}\n\n")
            f.write(markdown)
        # 遞歸處理子鏈接
        for link in get_page_links(resp.text, url):
            if link not in visited:
                time.sleep(1)  # 禮貌性延遲,避免請(qǐng)求過(guò)快
                crawl_and_convert(link)
    except Exception as e:
        print(f"Error: {e}")
# 開(kāi)始爬取
crawl_and_convert(BASE_URL)
print(f"Done. Saved to {OUTPUT_DIR}")

處理動(dòng)態(tài)內(nèi)容(如需)

如果 Odoo 文檔中某些頁(yè)面依賴(lài) JavaScript 渲染,可配合 Playwright:

from playwright.sync_api import sync_playwright
def fetch_with_playwright(url):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url, wait_until="networkidle")
        html = page.content()
        browser.close()
        return html

根據(jù) BrightData 的指南,動(dòng)態(tài)的網(wǎng)站需要等待 JavaScript 執(zhí)行完成后才能獲取完整內(nèi)容,Playwright 可自動(dòng)處理此場(chǎng)景。

到此這篇關(guān)于Python代碼實(shí)現(xiàn)下載Odoo18在線(xiàn)文檔并生成markdown文檔的文章就介紹到這了,更多相關(guān)Python下載Odoo18文檔下載內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!

相關(guān)文章

  • Python內(nèi)置函數(shù)hex()的實(shí)現(xiàn)示例

    Python內(nèi)置函數(shù)hex()的實(shí)現(xiàn)示例

    這篇文章主要介紹了Python內(nèi)置函數(shù)hex()的實(shí)現(xiàn)示例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧
    2020-08-08
  • python pygame實(shí)現(xiàn)2048游戲

    python pygame實(shí)現(xiàn)2048游戲

    這篇文章主要為大家詳細(xì)介紹了python pygame實(shí)現(xiàn)2048游戲,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2018-11-11
  • Python輕松實(shí)現(xiàn)PowerPoint表格的創(chuàng)建與格式化操作

    Python輕松實(shí)現(xiàn)PowerPoint表格的創(chuàng)建與格式化操作

    在制作商務(wù)演示文稿時(shí),表格是展示結(jié)構(gòu)化數(shù)據(jù)的重要工具,本文介紹如何使用?Spire.Presentation?for?Python?庫(kù)在?PowerPoint?幻燈片中創(chuàng)建表格,有需要的小伙伴可以了解下
    2026-05-05
  • python的常用模塊之collections模塊詳解

    python的常用模塊之collections模塊詳解

    這篇文章主要介紹了python的常用模塊之collections模塊詳解,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧
    2018-12-12
  • Python打印獲取異常信息的代碼詳解

    Python打印獲取異常信息的代碼詳解

    在日常的軟件開(kāi)發(fā)工作中,異常處理(Exception Handling)是一個(gè)至關(guān)重要的環(huán)節(jié),它不僅影響到程序的穩(wěn)定性和健壯性,還在提高用戶(hù)體驗(yàn)、調(diào)試問(wèn)題以及防止安全漏洞方面起到了不可替代的作用,本文給大家介紹了Python打印獲取異常信息,需要的朋友可以參考下
    2024-10-10
  • Python matplotlib繪圖風(fēng)格詳解

    Python matplotlib繪圖風(fēng)格詳解

    從matplotlib的角度來(lái)說(shuō),繪圖風(fēng)格也算是圖像類(lèi)型的一部分,所以這篇文章小編想帶大家了解一下Python中matplotlib的繪圖風(fēng)格,有需要的可以參考下
    2023-09-09
  • Windows環(huán)境下創(chuàng)建并激活Python虛擬環(huán)境venv

    Windows環(huán)境下創(chuàng)建并激活Python虛擬環(huán)境venv

    Python虛擬環(huán)境venv是管理項(xiàng)目依賴(lài)的強(qiáng)大工具,通過(guò)創(chuàng)建、激活和使用虛擬環(huán)境,你可以輕松隔離和管理項(xiàng)目依賴(lài),避免依賴(lài)沖突,提高開(kāi)發(fā)效率和項(xiàng)目的可移植性,這篇文章主要介紹了Windows環(huán)境下創(chuàng)建并激活Python虛擬環(huán)境venv的相關(guān)資料,需要的朋友可以參考下
    2026-03-03
  • Numpy中np.dot與np.matmul的區(qū)別詳解

    Numpy中np.dot與np.matmul的區(qū)別詳解

    本文主要介紹了Numpy中np.dot與np.matmul的區(qū)別詳解,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧
    2023-02-02
  • Python入門(mén)教程(十三)Python元組

    Python入門(mén)教程(十三)Python元組

    這篇文章主要介紹了Python入門(mén)教程(十三)Python元組,Python是一門(mén)非常強(qiáng)大好用的語(yǔ)言,也有著易上手的特性,本文為入門(mén)教程,需要的朋友可以參考下
    2023-04-04
  • 探索Python元類(lèi)與class語(yǔ)句協(xié)議掌握類(lèi)的控制權(quán)

    探索Python元類(lèi)與class語(yǔ)句協(xié)議掌握類(lèi)的控制權(quán)

    這篇文章主要介紹了通過(guò)Python元類(lèi)與class語(yǔ)句協(xié)議掌握類(lèi)的控制權(quán)探索,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪
    2024-01-01

最新評(píng)論

苏州市| 安吉县| 漳州市| 台安县| 石棉县| 峨山| 文登市| 措勤县| 贵德县| 永年县| 崇左市| 德保县| 隆尧县| 子长县| 孙吴县| 叶城县| 湘潭市| 宝山区| 皋兰县| 阜平县| 旌德县| 波密县| 扎鲁特旗| 绥德县| 泰和县| 绥化市| 武夷山市| 屏边| 凉城县| 永清县| 东台市| 林周县| 马龙县| 巴南区| 文成县| 鹿泉市| 玉树县| 浦北县| 施秉县| 太和县| 宝山区|