Files
browser-docs-archive/package.py
T
admin9webs 322b7737eb archive: 紫鸟/HubStudio 文档快照(3889 篇 Markdown)
来源:紫鸟帮助中心/博客/主站资讯 + HubStudio API 文档/帮助中心 + 三个官方 skill 仓库。
只入库 Markdown 与元数据;原始 HTML 与 tarball 留在本机。
2026-10-05 12:11:35 +00:00

207 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""package.py — 把抓取结果整理成可检索的归档:README.md + INDEX.md + tar.gz + git 提交。
产物
- README.md 归档总览:来源、统计、目录说明、版权与缺口
- INDEX.md 每组一篇索引(标题 + 本地路径 + 来源 URL)
- ARCHIVE.tar.gz 本地全量快照(含 raw/ 原始响应)
- git 仓库 只提交 md/ + repos/ + manifest + 索引(raw/ 不入库,避免几百 MB 的 HTML)
用法: python3 package.py [--push <gitea-remote-url>]
"""
from __future__ import annotations
import argparse
import collections
import datetime
import re
import subprocess
import sys
import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent
GROUPS = {
'zn-help': '紫鸟帮助中心(help/docs + help-v5/docs,共 468 页)',
'zn-blog': '紫鸟跨境知识库(WordPress 博客,255 篇)',
'zn-news': '紫鸟主站资讯与博客(/news + /blog,含 SEO 文章约 3000 篇)',
'hs-api': 'HubStudio API 文档(api-docs,Markdown 直出,160 页)',
'hs-support': 'HubStudio 帮助中心(support 站,首页可见的文章)',
}
SOURCES = [
('紫鸟 sitemap', 'https://www.ziniao.com/sitemap.xml'),
('紫鸟博客 sitemap', 'https://blog.ziniao.com/sitemaps.xml'),
('紫鸟帮助中心', 'https://www.ziniao.com/help/'),
('紫鸟开放平台', 'https://open.ziniao.com/'),
('紫鸟官方 Skill 仓库', 'https://github.com/ziniao-open/skills'),
('紫鸟官方 WebDriver/Skill Hub', 'https://github.com/ziniao-open/skill-hub'),
('HubStudio API 文档 sitemap', 'https://api-docs.hubstudio.cn/sitemap.xml'),
('HubStudio 帮助中心', 'https://support-orig.hubstudio.cn/'),
('HubStudio 官方 Skill 仓库', 'https://github.com/hubstudio-Max/hubstudio-skill'),
]
def group_stats() -> dict:
stats = {}
manifest = ROOT / 'manifest.tsv'
rows = []
if manifest.exists():
for ln in manifest.read_text(encoding='utf-8').splitlines():
parts = ln.split('\t')
if len(parts) >= 4:
rows.append(parts)
for g in GROUPS:
mds = list((ROOT / 'md' / g).rglob('*.md'))
raws = list((ROOT / 'raw' / g).rglob('*')) if (ROOT / 'raw' / g).exists() else []
raws = [f for f in raws if f.is_file()]
ok = sum(1 for r in rows if r[0] == g and r[2] == '200')
fail = sum(1 for r in rows if r[0] == g and r[2] != '200')
stats[g] = {
'md': len(mds), 'raw': len(raws), 'list': len(_urls(g)),
'ok': ok, 'fail': fail,
'md_bytes': sum(f.stat().st_size for f in mds),
'raw_bytes': sum(f.stat().st_size for f in raws),
}
return stats
def _urls(group: str) -> list[str]:
f = ROOT / f'urls-{group}.txt'
return [x for x in f.read_text(encoding='utf-8').splitlines() if x.strip()] if f.exists() else []
def title_of(md: Path) -> str:
try:
for ln in md.read_text(encoding='utf-8', errors='replace').splitlines()[:40]:
if ln.startswith('#'):
return ln.lstrip('# ').strip()
for ln in md.read_text(encoding='utf-8', errors='replace').splitlines()[:6]:
if 'source:' in ln:
return md.stem
except Exception: # noqa: BLE001
pass
return md.stem
def write_readme(stats: dict) -> None:
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
total_md = sum(s['md'] for s in stats.values())
total_raw = sum(s['raw'] for s in stats.values())
total_mb = sum(s['md_bytes'] + s['raw_bytes'] for s in stats.values()) / 2**20
lines = [
'# 紫鸟 / HubStudio 文档归档', '',
f'抓取时间:**{now}** 抓取工具:`crawl.py`(本目录,源码随包提交)', '',
'## 目录结构', '',
'| 路径 | 说明 |', '|---|---|',
'| `md/<group>/…` | 提取后的 Markdown(可直接阅读/检索,gitea 里有) |',
'| `raw/<group>/…` | 原始响应(HTML 或 .md),**仅本地保留**,不入 git |',
'| `repos/` | 三个官方 Skill 仓库的文件级快照(见下) |',
'| `manifest.tsv` | 每页记录:group / url / http / bytes / sha256前16 / title |',
'| `INDEX.md` | 分组索引:标题 + 本地路径 + 来源 URL |',
'| `ARCHIVE.tar.gz` | 本地全量快照(含 raw/) |', '',
'## 统计', '',
'| group | 说明 | 计划 | 成功 | 失败 | md | raw | 体积 |',
'|---|---|---:|---:|---:|---:|---:|---:|',
]
for g, desc in GROUPS.items():
s = stats[g]
lines.append(f"| `{g}` | {desc} | {s['list']} | {s['ok']} | {s['fail']} | {s['md']} | {s['raw']} | "
f"{s['md_bytes']/2**20:.1f}M + {s['raw_bytes']/2**20:.1f}M |")
lines += ['', f'合计:**{total_md} 篇 Markdown**、{total_raw} 份原始响应、约 **{total_mb:.0f} MB**。', '',
'## 来源', '']
lines += [f'- {n}:<{u}>' for n, u in SOURCES]
lines += ['',
'## 官方 Skill 仓库(repos/)', '',
'| 仓库 | 内容 |', '|---|---|',
'| `repos/skills` | 紫鸟官方 skill 包(员工/角色/部门/账号/设备/店铺控制等),CC BY 4.0 |',
'| `repos/skill-hub` | 紫鸟 WebDriver 文档与示例(applyAuth/getBrowserList/startBrowser…) |',
'| `repos/hubstudio-skill` | HubStudio 官方 skill(含 ADB 连接、OpenClaw 教程、commands 清单) |', '',
'## 已知缺口(重要)', '',
'- **HubStudio 帮助中心(support 站)是 Baklib 托管的 SPA**:文章页无服务端内链、站点无 sitemap,',
' 只能取到首页可见的文章(约 20 篇)。要抓全需登录态或渲染 JS,本归档不含。',
'- 紫鸟主站 `/blog/*` 与 WordPress 博客是两套内容,均按原样归档,可能有重叠主题。',
'- 页面内的图片/附件未下载(只存正文文本);需要时按 `raw/` 里的原始 URL 再取。',
'- 部分页面为 404/登录后可见,manifest 里 http 非 200 的行即失败项。', '',
'## 版权与使用', '',
'- 文档内容版权归各自厂商(紫鸟:福建紫讯信息科技;HubStudio:其运营方)所有,**本归档仅供内部查阅参考**,',
' 不改变原授权;对外分发前请先确认授权。',
'- `repos/skills`、`repos/skill-hub`、`repos/hubstudio-skill` 为官方公开仓库快照,'
'紫鸟 skill 包声明为 **CC BY 4.0**,使用时请保留署名。', '',
'## 复现', '', '```bash', 'python3 crawl.py --list # 看各来源规模',
'python3 crawl.py # 全量抓取(幂等,可续抓)',
'python3 mirror_repos.py # 快照官方仓库', 'python3 package.py --push <remote>', '```', '']
(ROOT / 'README.md').write_text('\n'.join(lines), encoding='utf-8')
def write_index() -> None:
out = ['# 索引', '']
for g, desc in GROUPS.items():
mds = sorted((ROOT / 'md' / g).rglob('*.md'))
out += [f'## {g} — {desc}', '', f'共 {len(mds)} 篇。', '',
'| 标题 | 本地路径 |', '|---|---|']
for md in mds:
rel = md.relative_to(ROOT)
t = title_of(md).replace('|', '/')[:90]
out.append(f'| {t or md.stem} | `{rel}` |')
out.append('')
(ROOT / 'INDEX.md').write_text('\n'.join(out), encoding='utf-8')
def make_tar() -> None:
tar = ROOT / 'ARCHIVE.tar.gz'
with tarfile.open(tar, 'w:gz') as tf:
for sub in ('md', 'raw', 'repos'):
p = ROOT / sub
if p.exists():
tf.add(p, arcname=sub)
for f in ('manifest.tsv', 'INDEX.md', 'README.md', 'crawl.py', 'mirror_repos.py', 'package.py'):
if (ROOT / f).exists():
tf.add(ROOT / f, arcname=f)
print(f' ARCHIVE.tar.gz: {tar.stat().st_size / 2**20:.1f} MB')
def git_commit_push(remote: str | None, stats: dict) -> None:
"""gitea 侧只提交 md/ + repos/ + 元数据(raw/ 不入库)。"""
(ROOT / '.gitignore').write_text('raw/\nARCHIVE.tar.gz\ncrawl.log\n*.bak\n__pycache__/\n')
cmds = [['git', 'init', '-q'], ['git', 'add', '-A'],
['git', '-c', 'user.name=admin9webs', '-c', 'user.email=admin9webs@9webs.site',
'commit', '-q', '-m',
f'archive: 紫鸟/HubStudio 文档快照({sum(s["md"] for s in stats.values())} 篇 Markdown)\n\n'
'来源:紫鸟帮助中心/博客/主站资讯 + HubStudio API 文档/帮助中心 + 三个官方 skill 仓库。\n'
'只入库 Markdown 与元数据;原始 HTML 与 tarball 留在本机。']]
for c in cmds:
r = subprocess.run(c, cwd=ROOT, capture_output=True, text=True)
if r.returncode != 0 and 'nothing to commit' not in (r.stdout + r.stderr):
print(' git:', ' '.join(c[1:3]), '->', (r.stdout + r.stderr).strip()[:200])
if remote:
subprocess.run(['git', 'remote', 'remove', 'origin'], cwd=ROOT, capture_output=True)
subprocess.run(['git', 'remote', 'add', 'origin', remote], cwd=ROOT, check=False)
r = subprocess.run(['git', 'push', '-u', 'origin', 'HEAD:main'], cwd=ROOT,
capture_output=True, text=True)
out = re.sub(r'://[^@\s]+@', '://***@', (r.stdout + r.stderr).strip())
print(' push:', out[-200:] if r.returncode else 'OK')
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('--push', default=None, help='gitea remote(含凭据),推送归档')
ap.add_argument('--no-tar', action='store_true')
args = ap.parse_args()
stats = group_stats()
write_readme(stats)
write_index()
print(' README.md / INDEX.md 已生成')
for g, s in stats.items():
print(f" {g:11s} md={s['md']:4d} ok={s['ok']:4d} fail={s['fail']:3d} "
f"({s['md_bytes']/2**20:.1f}M md)")
if not args.no_tar:
make_tar()
git_commit_push(args.push, stats)
return 0
if __name__ == '__main__':
sys.exit(main())