Python代碼實(shí)現(xiàn)下載Odoo18在線(xiàn)文檔并生成markdown文檔
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"\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_closed;list_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 Noneconvert 函數(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)文章希望大家以后多多支持腳本之家!
- 使用python?xmlrpc連接odoo方式
- 詳解Python odoo中嵌入html簡(jiǎn)單的分頁(yè)功能
- Python實(shí)現(xiàn)的批量下載RFC文檔
- Python+Paramiko編寫(xiě)簡(jiǎn)單的SFTP文件上傳下載工具類(lèi)
- 使用Python開(kāi)發(fā)一個(gè)圖片OCR管理和文章下載工具
- 使用Python實(shí)現(xiàn)帶進(jìn)度條的異步多文件下載器
- python將Markdown文件格式轉(zhuǎn)為Word文檔的示例代碼
- Python將Markdown文件轉(zhuǎn)換為Word(docx)完整教學(xué)
相關(guā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游戲
這篇文章主要為大家詳細(xì)介紹了python pygame實(shí)現(xiàn)2048游戲,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-11-11
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
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ū)別詳解,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2023-02-02
探索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

