odoo 18在线文档是一个全面且结构化的信息库,旨在帮助用户、开发者及合作伙伴了解和使用odoo 18这套开源企业管理系统。这套文档既是系统功能的权威解读,也是技术开发和业务落地的实操指南。
下面我们就来看看如何使用python实现odoo18文档下载工具并生成为markdown文档格式吧。
odoo 18在线文档介绍
odoo 18的在线文档体系庞大,主要分为以下几类,方便不同角色的人员按需查阅:
- 用户指南 (user guide):专为最终用户设计,按销售、采购、库存等不同模块提供操作指引,帮助用户完成日常工作。这部分内容通常基于真实业务场景编写,配有实战案例。
- 开发者文档 (developer documentation):这是为技术人员准备的核心资料。它深入讲解了odoo的底层架构,例如模型(model)、视图(view)、控制器(controller),并提供完整的api参考和模块开发指南,是进行二次开发的基础。
- 实施与集成指南:涵盖系统安装、部署、配置和性能优化等运维相关内容,同时介绍如何将odoo与其他系统对接,进行高级功能集成。
- 安全与更新记录:提供平台的安全性策略和漏洞上报渠道,并记录odoo 18版本新增的“更快的处理速度、改进的用户界面(ui)”等特性。
python脚本实现
# 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文档爬虫,生成markdown格式,最后转换为pdf
"""
def __init__(self, base_url, output_name="odoo18_user_tutorial"):
"""
初始化爬虫
:param base_url: 基础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)
# 创建目录
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)
# 存储已访问的url,避免重复
self.visited_urls = set()
# 存储markdown文件
self.markdown_files = []
# 存储所有页面内容
self.all_pages = []
logger.info(f"爬虫初始化完成,基础url: {self.base_url}")
def get_page_content(self, url, max_retries=3):
"""
获取页面内容,带重试机制
"""
for attempt in range(max_retries):
try:
logger.info(f"正在获取页面: {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"请求过于频繁,等待 3 秒后重试...")
time.sleep(3)
else:
logger.warning(f"获取页面失败,状态码: {response.status_code}")
if response.status_code == 404:
return none
time.sleep(1)
except requests.exceptions.requestexception as e:
logger.error(f"请求异常: {e}")
if attempt < max_retries - 1:
logger.info(f"等待 2 秒后重试...")
time.sleep(2)
logger.error(f"重试 {max_retries} 次后仍无法获取页面: {url}")
return none
def discover_links(self, start_url):
"""
从起始页面发现所有相关链接
"""
logger.info(f"开始发现链接: {start_url}")
content = self.get_page_content(start_url)
if not content:
logger.error("无法获取起始页面内容")
return []
soup = beautifulsoup(content, 'html.parser')
# 查找所有文档链接
links = []
visited = set()
# 方法1:查找导航菜单中的链接
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
# 构建完整url
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': '导航菜单'
})
logger.debug(f"发现导航链接: {text} -> {full_url}")
# 方法2:查找主要内容中的链接
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': '主要内容'
})
logger.debug(f"发现内容链接: {text} -> {full_url}")
# 方法3:添加一些常见的核心页面(备用)
if not links:
logger.warning("未发现链接,使用备用核心页面列表")
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': '备用核心页面'
})
logger.info(f"共发现 {len(links)} 个页面链接")
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}")
# 获取图片内容
response = self.session.get(img_url, timeout=15)
if response.status_code != 200:
logger.warning(f"图片下载失败,状态码: {response.status_code}")
return none
# 检查文件类型
content_type = response.headers.get('content-type', '')
if not content_type.startswith('image/'):
logger.warning(f"非图片内容类型: {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)
# 返回相对路径
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元素,保持原始结构
"""
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
# 处理文本节点
if element.name is none:
text = element.strip()
if text:
# 保持缩进
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
# 处理标题
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:
# 处理多行列表项
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内容转换为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()
# 找到主要内容区域
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("未找到主要内容区域,使用整个body")
main_content = soup.body
# 提取标题
page_title = title
if not page_title:
h1 = soup.find('h1')
if h1:
page_title = h1.get_text(strip=true)
# 创建markdown内容
markdown_content = []
# 添加标题
if page_title:
markdown_content.append(f"# {page_title}\n")
# 添加元信息
markdown_content.append(f"**来源:** [{page_url}]({page_url})\n")
markdown_content.append(f"**爬取时间:** {time.strftime('%y-%m-%d %h:%m:%s')}\n")
markdown_content.append("---\n")
# 处理所有内容元素,保持原始位置
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表格转换为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):
"""
爬取所有页面并转换为markdown
"""
logger.info(f"开始爬取 {len(page_links)} 个页面...")
for i, page_info in enumerate(page_links):
url = page_info['url']
title = page_info['title'] or f"页面_{i+1}"
# 检查是否已访问
if url in self.visited_urls:
logger.info(f"跳过已访问的页面: {url}")
continue
self.visited_urls.add(url)
# 获取页面内容
html_content = self.get_page_content(url)
if not html_content:
# 尝试常见的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"无法获取页面内容,跳过: {url}")
continue
# 转换为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个页面暂停一下,避免请求过于频繁
if (i + 1) % 3 == 0:
logger.info(f"已爬取 {i + 1} 个页面,暂停 2 秒...")
time.sleep(2)
# 每个页面之间稍作延迟
time.sleep(1.0)
logger.info(f"爬取完成,共成功保存 {len(self.markdown_files)} 个markdown文件")
def combine_markdown_files(self):
"""
合并所有markdown文件
"""
if not self.all_pages:
logger.error("没有可合并的markdown文件")
return none
logger.info("开始合并markdown文件...")
combined_content = []
# 添加封面
combined_content.append("# odoo 18 用户教程\n")
combined_content.append(f"**生成时间:** {time.strftime('%y-%m-%d %h:%m:%s')}\n")
combined_content.append(f"**版本:** 18.0\n")
combined_content.append(f"**语言:** 简体中文\n")
combined_content.append(f"**总页数:** {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")
# 添加所有页面内容
for i, page in enumerate(self.all_pages):
title = page['title']
content = page['content']
# 为每个页面添加分页符
combined_content.append(f"\n\n<!-- 分页符 -->\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转换为html
"""
try:
with open(markdown_file, 'r', encoding='utf-8') as f:
markdown_content = f.read()
# 转换为html
html_content = markdown2.markdown(markdown_content, extras=["tables", "fenced-code-blocks", "footnotes"])
# 添加html结构和样式
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 用户教程</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转html失败: {e}")
return none
def convert_to_pdf(self, html_file):
"""
将html转换为pdf
"""
if not html_file or not os.path.exists(html_file):
logger.error("html文件不存在,无法转换为pdf")
return false
logger.info("开始转换pdf...")
try:
# 配置pdf选项
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 用户教程',
'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"开始生成pdf文件: {output_path}")
# 转换为pdf
pdfkit.from_file(html_file, output_path, options=options)
logger.info(f"pdf转换成功!文件保存为: {output_path}")
return true
except exception as e:
logger.error(f"pdf转换失败: {e}")
logger.error("可能的原因:")
logger.error("1. 未安装wkhtmltopdf,请安装:https://wkhtmltopdf.org/downloads.html")
logger.error("2. wkhtmltopdf路径未配置")
logger.error("3. 文件路径包含中文或特殊字符")
logger.error("4. 内存不足或文件过大")
return false
def cleanup(self):
"""
清理临时文件
"""
logger.info("开始清理临时文件...")
# 不删除markdown文件,保留作为备份
logger.info("保留markdown文件作为备份")
logger.info("清理完成")
def run(self):
"""
运行爬虫
"""
start_time = time.time()
logger.info("odoo markdown爬虫启动!")
# 1. 发现所有链接
page_links = self.discover_links(self.base_url)
if not page_links:
logger.error("未发现任何页面链接")
return
# 2. 爬取所有页面
self.crawl_pages(page_links)
if not self.all_pages:
logger.error("没有成功爬取任何页面")
return
# 3. 合并markdown文件
combined_md_file = self.combine_markdown_files()
if not combined_md_file:
logger.error("合并markdown文件失败")
return
# 4. 转换为html
html_file = self.convert_markdown_to_html(combined_md_file)
if not html_file:
logger.error("转换为html失败")
return
# 5. 转换为pdf
self.convert_to_pdf(html_file)
# 6. 清理
self.cleanup()
total_time = time.time() - start_time
logger.info(f"爬虫运行完成!总耗时: {total_time:.2f} 秒")
logger.info(f"共处理页面: {len(page_links)} 个")
logger.info(f"成功保存页面: {len(self.all_pages)} 个")
# 显示输出文件信息
logger.info(f"输出文件:")
logger.info(f"- markdown文件: {self.temp_dir}/")
logger.info(f"- pdf文件: {self.output_name}.pdf")
def main():
"""
主函数
"""
# 配置参数
base_url = "https://www.odooai.cn/documentation/18.0/zh_cn/applications.html"
output_name = "odoo18_user_tutorial"
# 创建爬虫实例
crawler = odoomarkdowncrawler(base_url, output_name)
# 运行爬虫
crawler.run()
if __name__ == "__main__":
main()方法补充
将 odoo 18 在线文档下载并转为 markdown,主要有两条技术路径:
| 路径 | 方法 | 技术门槛 | 输出质量 | 推荐度 |
|---|---|---|---|---|
| 路径一 | 从官方 github 克隆文档源码 → sphinx 本地构建 → sphinx-markdown-builder 导出 markdown | 较低,一次配置后自动化运行 | 高质量(保留完整结构、交叉引用) | ⭐⭐⭐⭐⭐ 强烈推荐 |
| 路径二 | 直接用 python 获取在线 html 页面 → html-to-markdown / markgrab 逐页转换 | 中等,需处理反爬、递归遍历 | 中等(可能丢失交叉引用、样式) | ⭐⭐⭐ 备选方案 |
核心结论:能走路径一就不要走路径二。odoo 官方文档使用 sphinx 从 restructuredtext (.rst) 源文件生成 html,这意味着你可以在本地重新构建,并通过 sphinx-markdown-builder 直接将 .rst 源码导出为 markdown,质量远高于对最终 html 的二次抓取。
路径一:克隆源码 + sphinx 本地构建转 markdown
完整操作步骤
第一步:克隆 odoo 文档仓库
odoo 文档源码托管在官方 github 仓库,可使用以下命令克隆:
# odoo 18 文档通常位于 odoo/documentation 目录下 git clone https://github.com/odoo/odoo.git cd odoo/documentation
如果只想获取文档而不下载全部 odoo 源码,可单独克隆文档仓库(需确认 odoo 18 的官方文档仓库地址)。根据搜索结果,已有开发者通过克隆 odoo/documentation 仓库并在本地构建的方式获取离线 html 文档。
第二步:安装 sphinx 及相关依赖
# 安装 sphinx pip install sphinx # 安装 sphinx-markdown-builder(用于导出 markdown) pip install sphinx-markdown-builder # 根据 odoo 文档的 requirements.txt 安装其他依赖(如有) # pip install -r requirements.txt
sphinx-markdown-builder 的核心作用是将 sphinx 生成的文档树转换为 markdown 格式,让你能够利用 sphinx 的强大功能,同时享受 markdown 的简洁和流行。
第三步:配置 sphinx 以支持 markdown 输出
在文档源码目录中找到或创建 conf.py 配置文件,添加以下配置:
# 在 conf.py 中添加扩展
extensions = [
'sphinx_markdown_builder', # 关键:添加 markdown 导出支持
# 其他 odoo 文档需要的扩展...
]
# 如果需要保留中文内容,设置语言
language = 'zh_cn'odoo 文档原生的 conf.py 可能已配置了多个扩展,请不要覆盖它们,而是将 sphinx_markdown_builder 追加到 extensions 列表中。
第四步:构建并导出 markdown
# 方法一:使用 sphinx-build 直接生成 markdown sphinx-build -b markdown . ./build/markdown # 方法二:如果文档已构建为 html,也可以从 html 转换 # sphinx-build -b markdown ./_build/html ./build/markdown_from_html
-b markdown 参数指定使用 sphinx_markdown_builder 构建器,将源码输出为 markdown 格式。
第五步:验证输出
# 查看生成的 markdown 文件 ls ./build/markdown/
生成的 markdown 文件会保留原有的文档结构、标题层级、交叉引用、代码块等格式,可以直接用于 obsidian、typora 等工具阅读或编辑。
路径二:直接爬取在线文档 + 逐页转换 html → markdown
如果无法通过源码构建方式获取(如不想克隆大型仓库,或只需少量页面),可采用 python 爬取在线 html 文档并转换为 markdown。
使用 html-to-markdown 库(推荐)
html-to-markdown 是一个高性能的 rust 核心库,python api 简洁,转换速度快。
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"
# 创建输出目录
os.makedirs(output_dir, exist_ok=true)
# 配置转换选项
options = conversionoptions(
heading_style="atx", # 使用 atx 风格标题(# 开头)
list_indent_width=2, # 列表缩进宽度
extract_metadata=true, # 提取元数据
extract_tables=true, # 提取表格结构
)conversionoptions 提供了丰富的自定义选项:heading_style 可选 underlined、atx 或 atx_closed;list_indent_width 控制缩进级别;extract_tables 开启结构化表格提取。
爬取并转换单个页面:
def fetch_and_convert(url):
"""获取页面html并转换为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 函数返回的 conversionresult 字典包含多个字段:content(转换后的 markdown)、metadata(提取的元数据)、tables(结构化表格数据)、images(提取的图片信息)以及 warnings(转换警告)。
使用 markgrab 自动提取文章内容(适合噪声较多的页面)
markgrab 是一个更智能的内容提取工具,能自动过滤导航栏、侧边栏、广告等噪声,提取页面的核心文章内容。
import asyncio
from markgrab import extract
async def extract_markdown(url):
result = await extract(
url,
max_chars=50000, # 限制输出长度
use_browser=false, # 静态页面不需要浏览器渲染
)
return result.markdown
# 运行异步函数
markdown = asyncio.run(extract_markdown("https://www.odoo.com/documentation/18.0/zh_cn/index.html"))markgrab 的特点:自动检测内容类型(html/pdf/docx),支持异步操作,内置内容密度过滤以去除噪声,如果首次获取不足 50 词会自动回退到 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):
"""从页面中提取同域文档链接"""
soup = beautifulsoup(html, 'html.parser')
links = set()
for a in soup.find_all('a', href=true):
href = urljoin(current_url, a['href'])
# 过滤:同域、文档路径、非锚点、非外部资源
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()
# 转换主内容为 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) # 礼貌性延迟,避免请求过快
crawl_and_convert(link)
except exception as e:
print(f"error: {e}")
# 开始爬取
crawl_and_convert(base_url)
print(f"done. saved to {output_dir}")处理动态内容(如需)
如果 odoo 文档中某些页面依赖 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根据 brightdata 的指南,动态的网站需要等待 javascript 执行完成后才能获取完整内容,playwright 可自动处理此场景。
到此这篇关于python代码实现下载odoo18在线文档并生成markdown文档的文章就介绍到这了,更多相关python下载odoo18文档下载内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论