| '''Сохраняет опубликованные части 1–3: текст, HTML и изображения.
|
|
|
| Запуск из корня: python docs/articles/import_published.py
|
| Повторный запуск обновляет локальные копии с сайта.
|
| Используется только стандартная библиотека Python.
|
| '''
|
|
|
| import hashlib
|
| import html
|
| import re
|
| from datetime import datetime, timedelta, timezone
|
| from html.parser import HTMLParser
|
| from pathlib import Path
|
| from urllib.parse import urljoin, urlparse
|
| from urllib.request import Request, urlopen
|
|
|
| ARTICLES = {1: 19804, 2: 19852, 3: 19946}
|
| ROOT = Path(__file__).resolve().parent
|
| VOID = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'wbr'}
|
|
|
|
|
| class Node:
|
| def __init__(self, tag='', attrs=()):
|
| self.tag, self.attrs, self.children = tag, dict(attrs), []
|
|
|
| def find(self, predicate):
|
| if predicate(self):
|
| return self
|
| for child in self.children:
|
| if isinstance(child, Node):
|
| found = child.find(predicate)
|
| if found is not None:
|
| return found
|
| return None
|
|
|
| def text(self):
|
| return ''.join(child.text() if isinstance(child, Node) else child for child in self.children)
|
|
|
|
|
| class Parser(HTMLParser):
|
| def __init__(self):
|
| super().__init__(convert_charrefs=True)
|
| self.root = Node()
|
| self.stack = [self.root]
|
|
|
| def handle_starttag(self, tag, attrs):
|
| node = Node(tag, attrs)
|
| self.stack[-1].children.append(node)
|
| if tag not in VOID:
|
| self.stack.append(node)
|
|
|
| def handle_startendtag(self, tag, attrs):
|
| self.handle_starttag(tag, attrs)
|
| if tag not in VOID:
|
| self.handle_endtag(tag)
|
|
|
| def handle_endtag(self, tag):
|
| for index in range(len(self.stack) - 1, 0, -1):
|
| if self.stack[index].tag == tag:
|
| del self.stack[index:]
|
| break
|
|
|
| def handle_data(self, data):
|
| self.stack[-1].children.append(data)
|
|
|
|
|
| def download(url):
|
| with urlopen(Request(url, headers={'User-Agent': 'Mozilla/5.0'}), timeout=45) as response:
|
| return response.read()
|
|
|
|
|
| def serialize(node):
|
| if isinstance(node, str):
|
| return html.escape(node, quote=False)
|
| if node.tag in ('script', 'style'):
|
| return ''
|
| attrs = ''.join(f' {key}="{html.escape(value or "", quote=True)}"' for key, value in node.attrs.items())
|
| body = ''.join(serialize(child) for child in node.children)
|
| if not node.tag:
|
| return body
|
| return f'<{node.tag}{attrs}>' + ('' if node.tag in VOID else body + f'</{node.tag}>')
|
|
|
|
|
| def markdown(node):
|
| if isinstance(node, str):
|
| return re.sub(r'\s+', ' ', node)
|
| tag = node.tag
|
| if tag in ('script', 'style'):
|
| return ''
|
| if tag == 'pre':
|
| code = node.text().replace('\xa0', ' ').strip('\r\n')
|
| fence = '`' * max(3, max((len(m.group()) + 1 for m in re.finditer(r'`+', code)), default=3))
|
| return f'\n\n{fence}\n{code}\n{fence}\n\n'
|
| if tag == 'table':
|
| return '\n\n' + serialize(node) + '\n\n'
|
| body = ''.join(markdown(child) for child in node.children)
|
| if re.fullmatch(r'h[1-6]', tag):
|
| return '\n\n' + '#' * max(2, int(tag[1]) - 1) + ' ' + body.strip() + '\n\n'
|
| if tag == 'a' and node.attrs.get('href'):
|
| return f'[{body.strip()}](<{node.attrs["href"]}>)'
|
| if tag == 'img':
|
| return f'}>)'
|
| if tag in ('b', 'strong'):
|
| return '**' + body.strip() + '**' if body.strip() else ''
|
| if tag in ('i', 'em'):
|
| return '*' + body.strip() + '*' if body.strip() else ''
|
| if tag == 'code':
|
| return '`' + body.strip() + '`'
|
| if tag == 'li':
|
| return '\n- ' + body.strip().replace('\n\n', '\n ') + '\n'
|
| if tag == 'br':
|
| return ' \n'
|
| if tag in ('p', 'div', 'section', 'ul', 'ol', 'blockquote'):
|
| return '\n\n' + body.strip() + '\n\n'
|
| return body
|
|
|
|
|
| def clean_markdown(text):
|
| lines, in_code = [], False
|
| for line in text.replace('\r', '').splitlines():
|
| if line.lstrip().startswith('```'):
|
| in_code = not in_code
|
| # Сохраняем жёсткие переносы Markdown без хвостовых пробелов.
|
| hard_break = not in_code and line.endswith(' ') and bool(line.strip())
|
| lines.append(line.rstrip() + ('<br>' if hard_break else ''))
|
| return '\n'.join(lines)
|
|
|
|
|
| def save(part, article_id):
|
| url = f'https://www.mql5.com/ru/articles/{article_id}'
|
| parser = Parser()
|
| parser.feed(download(url).decode('utf-8-sig').replace('\r', ''))
|
| title_node = parser.root.find(lambda node: node.tag == 'h1')
|
| content = parser.root.find(lambda node: node.tag == 'div' and
|
| {'inner', 'formatted-content'}.issubset(node.attrs.get('class', '').split()))
|
| if title_node is None or content is None or len(content.text()) < 3000:
|
| raise RuntimeError(f'Не найден полный текст статьи {article_id}')
|
| title = title_node.text().strip()
|
| folder = ROOT / f'part-{part:02d}'
|
| (folder / 'images').mkdir(parents=True, exist_ok=True)
|
| (folder / 'publish').mkdir(exist_ok=True)
|
| counts = {'images': 0, 'code': 0}
|
|
|
| def localize(node):
|
| if not isinstance(node, Node):
|
| return
|
| if node.tag == 'pre':
|
| counts['code'] += 1
|
| if node.attrs.get('href'):
|
| node.attrs['href'] = urljoin(url, node.attrs['href'])
|
| if node.tag == 'img' and node.attrs.get('src'):
|
| source = urljoin(url, node.attrs['src'])
|
| if urlparse(source).hostname == 'c.mql5.com':
|
| name = hashlib.sha256(source.encode()).hexdigest()[:8] + '-' + Path(urlparse(source).path).name
|
| (folder / 'images' / name).write_bytes(download(source))
|
| node.attrs['src'] = 'images/' + name
|
| counts['images'] += 1
|
| else:
|
| node.attrs['src'] = source
|
| for child in node.children:
|
| localize(child)
|
|
|
| localize(content)
|
| date = datetime.now(timezone(timedelta(hours=3))).date().isoformat()
|
| text = clean_markdown(markdown(content))
|
| text = re.sub(r'(?m)^[ \t]+$', '', text)
|
| text = re.sub(r'\n{3,}', '\n\n', text).strip()
|
| header = f'# {title}\n\nИсточник: [{url}]({url}). Локальная копия от {date}.\n\n'
|
| (folder / 'article.md').write_text(header + text + '\n', encoding='utf-8', newline='\n')
|
| fragment = serialize(content).replace('src="images/', 'src="../images/')
|
| fragment = '\n'.join(line.rstrip() for line in fragment.replace('\r', '').splitlines())
|
| (folder / 'publish/article.html').write_text(
|
| '<!doctype html>\n<meta charset="utf-8">\n<h1>' + html.escape(title) + '</h1>\n' + fragment + '\n', encoding='utf-8', newline='\n')
|
| (folder / 'source.md').write_text(f'# Источник\n\n- Статья: [{title}]({url}).\n'
|
| f'- Автор: Yuriy Bykov.\n- Дата загрузки: {date}.\n'
|
| f'- Блоков кода: {counts["code"]}; изображений: {counts["images"]}.\n\n'
|
| 'Markdown получен из полного текста страницы. Таблицы сохранены в HTML, '
|
| 'листинги — отдельными блоками, изображения — локально. '
|
| 'Исходное форматирование тела статьи сохранено в `publish/article.html`; '
|
| 'навигация сайта, реклама и обсуждение не включены.\n', encoding='utf-8')
|
| print(f'part-{part:02d}: {len(text)} символов, {counts}')
|
|
|
|
|
| if __name__ == '__main__':
|
| for part, article_id in ARTICLES.items():
|
| save(part, article_id)
|