download_musicxml_spec.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. MusicXML 4.0 规范文档下载器
  5. 下载 W3C MusicXML 4.0 官方规范的所有页面,保存为本地文件供学习使用。
  6. 官方网站: https://www.w3.org/2021/06/musicxml40/
  7. """
  8. import os
  9. import re
  10. import time
  11. import requests
  12. from bs4 import BeautifulSoup
  13. from urllib.parse import urljoin, urlparse
  14. from pathlib import Path
  15. from concurrent.futures import ThreadPoolExecutor, as_completed
  16. # 配置
  17. BASE_URL = "https://www.w3.org/2021/06/musicxml40/"
  18. OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "musicxml-spec"
  19. MAX_WORKERS = 5 # 并发下载数量
  20. REQUEST_DELAY = 0.5 # 请求间隔(秒),避免对服务器造成压力
  21. # 请求头
  22. HEADERS = {
  23. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
  24. "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
  25. "Accept-Language": "en-US,en;q=0.5",
  26. }
  27. def ensure_output_dir():
  28. """确保输出目录存在"""
  29. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  30. (OUTPUT_DIR / "elements").mkdir(exist_ok=True)
  31. (OUTPUT_DIR / "data-types").mkdir(exist_ok=True)
  32. (OUTPUT_DIR / "examples").mkdir(exist_ok=True)
  33. print(f"输出目录: {OUTPUT_DIR}")
  34. def fetch_page(url: str, retries: int = 3) -> str | None:
  35. """获取页面内容,带重试机制"""
  36. for attempt in range(retries):
  37. try:
  38. response = requests.get(url, headers=HEADERS, timeout=30)
  39. response.raise_for_status()
  40. response.encoding = 'utf-8'
  41. return response.text
  42. except requests.RequestException as e:
  43. print(f" 获取 {url} 失败 (尝试 {attempt + 1}/{retries}): {e}")
  44. if attempt < retries - 1:
  45. time.sleep(2)
  46. return None
  47. def extract_text_content(html: str) -> str:
  48. """从 HTML 中提取主要文本内容,转换为 Markdown 格式"""
  49. soup = BeautifulSoup(html, 'html.parser')
  50. # 移除不需要的元素
  51. for element in soup.find_all(['script', 'style', 'nav', 'header', 'footer']):
  52. element.decompose()
  53. # 获取主要内容区域
  54. main_content = soup.find('main') or soup.find('article') or soup.find('body')
  55. if not main_content:
  56. return ""
  57. lines = []
  58. # 获取标题
  59. title = soup.find('title')
  60. if title:
  61. lines.append(f"# {title.get_text(strip=True)}\n")
  62. # 处理内容
  63. for element in main_content.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'pre', 'code', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'dl', 'dt', 'dd']):
  64. tag_name = element.name
  65. text = element.get_text(strip=True)
  66. if not text:
  67. continue
  68. if tag_name == 'h1':
  69. lines.append(f"\n# {text}\n")
  70. elif tag_name == 'h2':
  71. lines.append(f"\n## {text}\n")
  72. elif tag_name == 'h3':
  73. lines.append(f"\n### {text}\n")
  74. elif tag_name == 'h4':
  75. lines.append(f"\n#### {text}\n")
  76. elif tag_name == 'h5':
  77. lines.append(f"\n##### {text}\n")
  78. elif tag_name == 'h6':
  79. lines.append(f"\n###### {text}\n")
  80. elif tag_name == 'p':
  81. lines.append(f"{text}\n")
  82. elif tag_name == 'pre':
  83. # 代码块
  84. code_text = element.get_text()
  85. lines.append(f"\n```xml\n{code_text}\n```\n")
  86. elif tag_name == 'li':
  87. lines.append(f"- {text}")
  88. elif tag_name == 'dt':
  89. lines.append(f"\n**{text}**")
  90. elif tag_name == 'dd':
  91. lines.append(f": {text}\n")
  92. return '\n'.join(lines)
  93. def save_page(url: str, content: str, subdir: str = ""):
  94. """保存页面内容到文件"""
  95. # 从 URL 生成文件名
  96. parsed = urlparse(url)
  97. path = parsed.path.rstrip('/')
  98. if path.endswith('.html') or path.endswith('.htm'):
  99. filename = os.path.basename(path)
  100. filename = filename.rsplit('.', 1)[0] + '.md'
  101. elif path.endswith('/') or not os.path.basename(path):
  102. filename = 'index.md'
  103. else:
  104. filename = os.path.basename(path) + '.md'
  105. # 清理文件名
  106. filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
  107. # 确定保存路径
  108. if subdir:
  109. save_dir = OUTPUT_DIR / subdir
  110. save_dir.mkdir(exist_ok=True)
  111. else:
  112. save_dir = OUTPUT_DIR
  113. filepath = save_dir / filename
  114. # 转换为 Markdown
  115. md_content = extract_text_content(content)
  116. # 添加来源信息
  117. header = f"---\nsource: {url}\ndownloaded: {time.strftime('%Y-%m-%d %H:%M:%S')}\n---\n\n"
  118. with open(filepath, 'w', encoding='utf-8') as f:
  119. f.write(header + md_content)
  120. return filepath
  121. def get_all_links(html: str, base_url: str) -> list[tuple[str, str]]:
  122. """
  123. 从页面中提取所有相关链接
  124. 返回: [(url, 分类), ...]
  125. """
  126. soup = BeautifulSoup(html, 'html.parser')
  127. links = []
  128. seen = set()
  129. for a_tag in soup.find_all('a', href=True):
  130. href = a_tag['href']
  131. full_url = urljoin(base_url, href)
  132. # 只处理同域名下的链接
  133. if not full_url.startswith(BASE_URL):
  134. continue
  135. # 跳过锚点链接
  136. if '#' in href and not href.startswith('http'):
  137. continue
  138. # 去重
  139. if full_url in seen:
  140. continue
  141. seen.add(full_url)
  142. # 分类
  143. subdir = ""
  144. if '/musicxml-reference/elements/' in full_url:
  145. subdir = "elements"
  146. elif '/musicxml-reference/data-types/' in full_url:
  147. subdir = "data-types"
  148. elif '/musicxml-reference/examples/' in full_url:
  149. subdir = "examples"
  150. links.append((full_url, subdir))
  151. return links
  152. def download_index_page():
  153. """下载首页并提取所有链接"""
  154. print("正在获取首页...")
  155. html = fetch_page(BASE_URL)
  156. if not html:
  157. print("无法获取首页,退出")
  158. return []
  159. # 保存首页
  160. save_page(BASE_URL, html)
  161. print("首页已保存")
  162. # 提取链接
  163. links = get_all_links(html, BASE_URL)
  164. print(f"找到 {len(links)} 个链接")
  165. return links
  166. def download_page(url: str, subdir: str) -> tuple[str, bool, str]:
  167. """下载单个页面"""
  168. time.sleep(REQUEST_DELAY) # 添加延迟
  169. html = fetch_page(url)
  170. if html:
  171. filepath = save_page(url, html, subdir)
  172. return url, True, str(filepath)
  173. return url, False, ""
  174. def download_all_pages(links: list[tuple[str, str]]):
  175. """并发下载所有页面"""
  176. print(f"\n开始下载 {len(links)} 个页面...")
  177. success_count = 0
  178. fail_count = 0
  179. with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
  180. futures = {
  181. executor.submit(download_page, url, subdir): url
  182. for url, subdir in links
  183. }
  184. for i, future in enumerate(as_completed(futures), 1):
  185. url = futures[future]
  186. try:
  187. result_url, success, filepath = future.result()
  188. if success:
  189. success_count += 1
  190. print(f"[{i}/{len(links)}] ✓ {os.path.basename(filepath)}")
  191. else:
  192. fail_count += 1
  193. print(f"[{i}/{len(links)}] ✗ {url}")
  194. except Exception as e:
  195. fail_count += 1
  196. print(f"[{i}/{len(links)}] ✗ {url}: {e}")
  197. print(f"\n下载完成: 成功 {success_count}, 失败 {fail_count}")
  198. def create_index_file():
  199. """创建目录索引文件"""
  200. index_path = OUTPUT_DIR / "README.md"
  201. content = """# MusicXML 4.0 规范文档
  202. > 来源: https://www.w3.org/2021/06/musicxml40/
  203. > 下载时间: {time}
  204. ## 目录结构
  205. - `elements/` - 元素定义(所有 MusicXML 元素的详细说明)
  206. - `data-types/` - 数据类型定义
  207. - `examples/` - 示例文件
  208. ## 主要文档
  209. 请从 `index.md` 开始阅读。
  210. ## 元素列表
  211. """.format(time=time.strftime('%Y-%m-%d %H:%M:%S'))
  212. # 列出所有元素文件
  213. elements_dir = OUTPUT_DIR / "elements"
  214. if elements_dir.exists():
  215. element_files = sorted(elements_dir.glob("*.md"))
  216. for f in element_files:
  217. name = f.stem
  218. content += f"- [{name}](elements/{f.name})\n"
  219. with open(index_path, 'w', encoding='utf-8') as f:
  220. f.write(content)
  221. print(f"\n索引文件已创建: {index_path}")
  222. def main():
  223. """主函数"""
  224. print("=" * 60)
  225. print("MusicXML 4.0 规范文档下载器")
  226. print("=" * 60)
  227. print()
  228. # 创建输出目录
  229. ensure_output_dir()
  230. # 下载首页并获取所有链接
  231. links = download_index_page()
  232. if not links:
  233. print("未找到任何链接,尝试下载主要页面...")
  234. # 手动添加一些已知的重要页面
  235. links = [
  236. (BASE_URL + "musicxml-reference/", ""),
  237. (BASE_URL + "musicxml-reference/elements/", ""),
  238. (BASE_URL + "musicxml-reference/data-types/", ""),
  239. (BASE_URL + "tutorial/introduction/", ""),
  240. ]
  241. # 下载所有页面
  242. download_all_pages(links)
  243. # 创建索引
  244. create_index_file()
  245. print("\n" + "=" * 60)
  246. print(f"所有文档已保存到: {OUTPUT_DIR}")
  247. print("=" * 60)
  248. if __name__ == "__main__":
  249. main()