weflow-cli 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1051 -0
- package/OPERATIONS.md +99 -13
- package/README.en.md +14 -5
- package/README.md +23 -8
- package/bin/weflow-cli.ts +364 -40
- package/dist/bin/weflow-cli.js +381 -69
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +19 -1
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +35 -0
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +19 -0
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +20 -0
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +27 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +169 -35
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +2 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/exportService.d.ts +43 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +116 -11
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1274 -0
- package/docs/EXTENDING.md +69 -7
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +2 -2
- package/docs/PROJECT_STATE.md +64 -5
- package/docs/RELEASING.md +128 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/index.ts +18 -3
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/index.html +12 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +111 -0
- package/resources/panel/renderer.js +480 -8
- package/scripts/_utils.py +1203 -977
- package/scripts/biz_daily.py +25 -10
- package/scripts/chat_notes.py +15 -5
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +163 -57
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +346 -6
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +212 -58
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wiki_lint.py +94 -17
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +33 -0
- package/src/services/assistantService.ts +26 -0
- package/src/services/assistantTools.ts +2452 -2319
- package/src/services/configService.ts +5 -2
- package/src/services/exportService.ts +129 -12
- package/src/services/messageQuery.ts +29 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
|
@@ -15,7 +15,9 @@ import base64
|
|
|
15
15
|
import urllib.request
|
|
16
16
|
import urllib.error
|
|
17
17
|
import concurrent.futures
|
|
18
|
+
import shutil
|
|
18
19
|
import struct
|
|
20
|
+
import xml.etree.ElementTree as ET
|
|
19
21
|
import json
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
|
|
@@ -94,6 +96,25 @@ COVER_STATE = {
|
|
|
94
96
|
'thumb_fetched': 0, 'thumb_cached': 0, 'thumb_skipped': 0,
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
# 落盘时用的扩展名:按**字节**判(`detect_mime_from_bytes`),不按来源猜 —— 实测同一会话 556 张图里
|
|
100
|
+
# 87 个是 GIF、27 个是 PNG,一律叫 .jpg 会让编辑器按扩展名解码、把动画表情显示成坏图。
|
|
101
|
+
_IMAGE_EXT_BY_MIME = {'image/jpeg': '.jpg', 'image/png': '.png',
|
|
102
|
+
'image/gif': '.gif', 'image/webp': '.webp'}
|
|
103
|
+
|
|
104
|
+
# 文档本体(appmsg `type=6`)从账号目录的 `msg/file/` 拷进 assets。原件是用户自己的文件:不重编码、
|
|
105
|
+
# 扩展名保持原样(编辑器/系统按扩展名决定用什么打开)。上限只防"一个几百 MB 的压缩包把导出变成磁盘炸弹",
|
|
106
|
+
# 超限的**不静默丢**:计入 `FILE_ASSET_STATE['unavailable']` 并写进清单顶部计数。
|
|
107
|
+
MAX_ASSET_FILE_BYTES = 64 * 1024 * 1024
|
|
108
|
+
_FILE_ASSET_SUBDIR = 'files'
|
|
109
|
+
|
|
110
|
+
# 文档那半的计数(图片那半的计数在写盘时现算)。放在模块级是为了不改 `write_assets` 的返回形状 ——
|
|
111
|
+
# 它已经有两个调用方(markdown 导出与 `--media`),与 COVER_STATE/VOICE_STATE 是同一套写法。
|
|
112
|
+
#
|
|
113
|
+
# `exported` 与 `written` 是两件事:`written` 只数**这次新拷**的,`exported` 数"这条消息的文档确实在
|
|
114
|
+
# assets 里"。分开是因为第二次跑同一个目录时新拷贝是 0 —— 只报 `written` 会打印成"0 document(s)",
|
|
115
|
+
# 而实际上 67 份都在,那是给自己制造假象(2026-10-13 真的这么打印过一次)。
|
|
116
|
+
FILE_ASSET_STATE = {'exported': 0, 'written': 0, 'unavailable': 0}
|
|
117
|
+
|
|
97
118
|
# Off by default: full-resolution originals are 5-24MB / 3000-5700px each, and
|
|
98
119
|
# downscaling ~500 of them costs minutes (PIL decode dominates). The cache's
|
|
99
120
|
# own thumbnails need no processing at all and are what an export normally
|
|
@@ -488,6 +509,71 @@ def scan_account_media(account_dir, own_wxid='', talker=''):
|
|
|
488
509
|
return image_map
|
|
489
510
|
|
|
490
511
|
|
|
512
|
+
def normalize_asset_name(name):
|
|
513
|
+
"""把消息里的 `title` 与磁盘文件名归一成同一个键。
|
|
514
|
+
|
|
515
|
+
微信写盘时会给自己加一份副本后缀:消息里是 `晚餐聚餐安排(学术研讨会后).docx`,磁盘上是
|
|
516
|
+
`晚餐聚餐安排(学术研讨会后)(1).docx`(实测)。半角全角都出现过,所以两种括号都剥。
|
|
517
|
+
"""
|
|
518
|
+
stem, ext = os.path.splitext(str(name or ''))
|
|
519
|
+
return (re.sub(r'[\s]*[((]\d+[))]$', '', stem).strip().lower() + ext.lower())
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def scan_account_file_index(account_dir):
|
|
523
|
+
"""`msg/file/**` 的**只 stat 索引**:`{(归一化名, 字节数): [路径]}`。
|
|
524
|
+
|
|
525
|
+
为什么不照图片那套按 talker 缩范围:`msg/file` 按月分目录(`msg/file/2024-04/…`),目录里没有
|
|
526
|
+
talker 的痕迹,一条会话的文件散在所有月份里。好在它只 `stat` 不读内容 —— 真机 1,677 个文件
|
|
527
|
+
不到一秒。键带上字节数是因为同名而内容不同的情形确实存在;真正的消歧靠 md5,但那要读盘,
|
|
528
|
+
所以只留给**多候选**那一档(实测 1,855 条文件消息里 154 条落到这档,全部认下)。
|
|
529
|
+
"""
|
|
530
|
+
index = {}
|
|
531
|
+
root = os.path.join(account_dir, 'msg', 'file')
|
|
532
|
+
if not os.path.isdir(root):
|
|
533
|
+
return index
|
|
534
|
+
for current_root, _, files in os.walk(root):
|
|
535
|
+
for fname in files:
|
|
536
|
+
path = os.path.join(current_root, fname)
|
|
537
|
+
try:
|
|
538
|
+
size = os.path.getsize(path)
|
|
539
|
+
except OSError:
|
|
540
|
+
continue
|
|
541
|
+
index.setdefault((normalize_asset_name(fname), size), []).append(path)
|
|
542
|
+
return index
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def resolve_account_file(info, file_index):
|
|
546
|
+
"""把一条文件消息对到本地那份文件上;对不上返回 None(不猜,D-014)。
|
|
547
|
+
|
|
548
|
+
先按 `(归一化名, 字节数)` 查:真机 1,855 条里 1,489 条唯一命中;多候选才读盘比 md5。整体命中
|
|
549
|
+
88.6%,剩下的 11.4% 是本地确实没有那份文件(未下载 / 已清理),与图片那半同一种情况。
|
|
550
|
+
"""
|
|
551
|
+
if not info or not file_index:
|
|
552
|
+
return None
|
|
553
|
+
name = normalize_asset_name(info.get('title', ''))
|
|
554
|
+
size = info.get('bytes') or 0
|
|
555
|
+
candidates = file_index.get((name, size)) if size else None
|
|
556
|
+
if candidates is None:
|
|
557
|
+
# `totallen` 缺失(少数消息没有)时退化成按名字查一遍:只遍历键,不读盘。
|
|
558
|
+
candidates = [path for (key_name, _size), paths in file_index.items()
|
|
559
|
+
if key_name == name for path in paths]
|
|
560
|
+
if not candidates:
|
|
561
|
+
return None
|
|
562
|
+
if len(candidates) == 1:
|
|
563
|
+
return candidates[0]
|
|
564
|
+
want = (info.get('md5') or '').lower()
|
|
565
|
+
if want:
|
|
566
|
+
for path in candidates:
|
|
567
|
+
try:
|
|
568
|
+
with open(path, 'rb') as fh:
|
|
569
|
+
if hashlib.md5(fh.read()).hexdigest() == want:
|
|
570
|
+
return path
|
|
571
|
+
except OSError:
|
|
572
|
+
continue
|
|
573
|
+
return None
|
|
574
|
+
return candidates[0]
|
|
575
|
+
|
|
576
|
+
|
|
491
577
|
def clean_account_wxid(value):
|
|
492
578
|
value = str(value or '').strip()
|
|
493
579
|
parts = value.rsplit('_', 1)
|
|
@@ -1610,7 +1696,49 @@ def sender_matches_account(sender_user_name, own_wxid):
|
|
|
1610
1696
|
return False
|
|
1611
1697
|
|
|
1612
1698
|
|
|
1613
|
-
def
|
|
1699
|
+
def parse_file_appmsg(content):
|
|
1700
|
+
"""顶层 appmsg `type=6`(文件)的元数据,或 None。
|
|
1701
|
+
|
|
1702
|
+
取的是 `<appmsg>` 的**直接子元素**,不是"整段里第一个同名标签"。原因:引用消息(type 57)把被引用
|
|
1703
|
+
的原文整个塞进 `<refermsg>`,而 `<refermsg>` 就在 `<appmsg>` **内部** —— 那份原文里的 `<type>6</type>`
|
|
1704
|
+
/`<title>`/`<appattach>` 与这条消息自己的字段处在同一段文本里,"切到 appmsg 块"和"取第一个匹配"
|
|
1705
|
+
都分不开,只有按层级取才分得开。2026-10-13 首次测量用正则满文档搜,7,083 条"像文件"里绝大多数
|
|
1706
|
+
就是这种引用原文。
|
|
1707
|
+
"""
|
|
1708
|
+
if not content:
|
|
1709
|
+
return None
|
|
1710
|
+
match = re.search(r'<appmsg[\s>].*?</appmsg>', str(content), re.S)
|
|
1711
|
+
if not match:
|
|
1712
|
+
return None
|
|
1713
|
+
try:
|
|
1714
|
+
element = ET.fromstring('<root>' + match.group(0) + '</root>').find('appmsg')
|
|
1715
|
+
except ET.ParseError:
|
|
1716
|
+
# 真机 7,878 行里有 2 行解析不了(转义残缺)。返回 None = 当"不是文件消息":少带出一个文件,
|
|
1717
|
+
# 好过把某份引用原文的名字当成本条文件的名字。
|
|
1718
|
+
return None
|
|
1719
|
+
if element is None:
|
|
1720
|
+
return None
|
|
1721
|
+
fields = {child.tag: (child.text or '').strip() for child in element}
|
|
1722
|
+
if fields.get('type') != '6':
|
|
1723
|
+
return None
|
|
1724
|
+
title = fields.get('title', '')
|
|
1725
|
+
if not title:
|
|
1726
|
+
return None
|
|
1727
|
+
attachment = element.find('appattach')
|
|
1728
|
+
attach = {child.tag: (child.text or '').strip() for child in attachment} if attachment is not None else {}
|
|
1729
|
+
try:
|
|
1730
|
+
total = int(fields.get('totallen') or attach.get('totallen') or '0')
|
|
1731
|
+
except ValueError:
|
|
1732
|
+
total = 0
|
|
1733
|
+
return {
|
|
1734
|
+
'title': title,
|
|
1735
|
+
'ext': (fields.get('fileext') or attach.get('fileext') or '').lower(),
|
|
1736
|
+
'md5': (fields.get('md5') or attach.get('md5') or '').lower(),
|
|
1737
|
+
'bytes': total,
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display_name='', resource_map=None, own_wxid='', file_index=None):
|
|
1614
1742
|
"""Format a single message for HTML display.
|
|
1615
1743
|
|
|
1616
1744
|
Args:
|
|
@@ -1620,6 +1748,8 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
|
|
|
1620
1748
|
image_map: {local_id: (base64_data, mime_type)} from NT cache scan
|
|
1621
1749
|
sender_map: {sender_id: user_name} from Name2Id table
|
|
1622
1750
|
display_name: human-readable name for the target talker
|
|
1751
|
+
file_index: {(归一化名, 字节数): [路径]} from scan_account_file_index, for
|
|
1752
|
+
resolving a document attachment to the copy on disk
|
|
1623
1753
|
"""
|
|
1624
1754
|
is_group = '@chatroom' in str(talker or '')
|
|
1625
1755
|
local_id = row[0] or 0
|
|
@@ -1730,6 +1860,12 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
|
|
|
1730
1860
|
if '\x00' in content or sum(ord(char) < 32 and char not in '\n\r\t' for char in content) > 2:
|
|
1731
1861
|
content = ''
|
|
1732
1862
|
|
|
1863
|
+
# 文档本体:消息里只有名字与 md5,文件本身在账号目录的 `msg/file/` 下。这里只**解析并定位**,
|
|
1864
|
+
# 不读也不拷 —— 拷贝发生在写 assets 那一步(HTML 那条路根本不落盘:它把图片内嵌成 base64,
|
|
1865
|
+
# 一个几 MB 的 PDF 内嵌进网页既没人打得开、也把文件撑大)。
|
|
1866
|
+
file_info = parse_file_appmsg(content) if local_type == 49 else None
|
|
1867
|
+
file_hit = resolve_account_file(file_info, file_index)
|
|
1868
|
+
|
|
1733
1869
|
# Determine display content
|
|
1734
1870
|
display = ''
|
|
1735
1871
|
image_b64 = None
|
|
@@ -1920,19 +2056,24 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
|
|
|
1920
2056
|
'local_type': local_type,
|
|
1921
2057
|
'display': display,
|
|
1922
2058
|
'image_b64': image_b64,
|
|
2059
|
+
# 文档本体的落点(`path` 为 None = 本地没有那份文件)。写 assets 时才拷。
|
|
2060
|
+
'file': ({'title': file_info['title'], 'ext': file_info['ext'],
|
|
2061
|
+
'bytes': file_info['bytes'], 'md5': file_info['md5'],
|
|
2062
|
+
'path': file_hit} if file_info else None),
|
|
1923
2063
|
# Built here rather than accumulated in module state: format_message
|
|
1924
2064
|
# runs twice (the prefetch dry sweep and the real pass) and the dry
|
|
1925
2065
|
# pass is discarded, so a counter would double-count.
|
|
1926
2066
|
'media': build_media_record(
|
|
1927
2067
|
local_type, image_b64, image_map, local_id, create_time, content,
|
|
1928
|
-
resource_md5s, metadata_content, bool(server_id)
|
|
2068
|
+
resource_md5s, metadata_content, bool(server_id),
|
|
2069
|
+
file_info, file_hit),
|
|
1929
2070
|
}
|
|
1930
2071
|
|
|
1931
2072
|
|
|
1932
2073
|
MEDIA_KIND_BY_TYPE = {
|
|
1933
2074
|
3: 'image', 34: 'voice', 43: 'video-thumb', 47: 'emoji', 49: 'link-cover',
|
|
1934
2075
|
}
|
|
1935
|
-
MEDIA_KINDS = {'image', 'emoji', 'voice', 'video-thumb', 'link-cover'}
|
|
2076
|
+
MEDIA_KINDS = {'image', 'emoji', 'voice', 'video-thumb', 'link-cover', 'file'}
|
|
1936
2077
|
|
|
1937
2078
|
|
|
1938
2079
|
def build_media_report(items, scope, talker, per_page):
|
|
@@ -1980,7 +2121,8 @@ def build_media_report(items, scope, talker, per_page):
|
|
|
1980
2121
|
|
|
1981
2122
|
|
|
1982
2123
|
def build_media_record(local_type, image_b64, image_map, local_id, create_time,
|
|
1983
|
-
content, resource_md5s, metadata_content, has_server_id
|
|
2124
|
+
content, resource_md5s, metadata_content, has_server_id,
|
|
2125
|
+
file_info=None, file_hit=None):
|
|
1984
2126
|
"""What happened to this message's media, or None for a non-media message.
|
|
1985
2127
|
|
|
1986
2128
|
Derived from the same `resolve_media` the page used, so the report cannot
|
|
@@ -1995,6 +2137,19 @@ def build_media_record(local_type, image_b64, image_map, local_id, create_time,
|
|
|
1995
2137
|
'mime': None, 'bytes': None, 'sha256': None, 'reason': None,
|
|
1996
2138
|
}
|
|
1997
2139
|
|
|
2140
|
+
if file_info is not None:
|
|
2141
|
+
# 文档本体(appmsg type 6)走的是另一条路:没有缩略图、也不在图片索引里,能不能导出只看
|
|
2142
|
+
# 本地 `msg/file/` 有没有那份文件。不并入图片那条链,否则报告会把"文件没下载"写成"没有可靠标识"。
|
|
2143
|
+
record['kind'] = 'file'
|
|
2144
|
+
record['bytes'] = file_info.get('bytes') or None
|
|
2145
|
+
if file_hit:
|
|
2146
|
+
record['status'] = 'exported'
|
|
2147
|
+
record['mediaKey'] = 'file'
|
|
2148
|
+
else:
|
|
2149
|
+
record['status'] = 'missing'
|
|
2150
|
+
record['reason'] = 'file-not-in-local-storage'
|
|
2151
|
+
return record
|
|
2152
|
+
|
|
1998
2153
|
if image_b64:
|
|
1999
2154
|
hit = resolve_media(image_map, local_id, create_time, metadata_content,
|
|
2000
2155
|
resource_md5s)
|
|
@@ -2293,6 +2448,141 @@ p {{ margin:8px 0; font-size:14px; }}
|
|
|
2293
2448
|
</html>'''
|
|
2294
2449
|
|
|
2295
2450
|
|
|
2451
|
+
def _asset_file_name(title, used_names, source, md5):
|
|
2452
|
+
"""落盘的文档文件名:保持原样(只清洗非法字符),重名才加 md5 前 8 位。
|
|
2453
|
+
|
|
2454
|
+
不改名是刻意的 —— 那是用户自己的文件名,读者要靠它认东西;微信自己也会加 `(1)`。但两个不同来源
|
|
2455
|
+
的文件可能同名,那种情况必须区分,否则后来者会覆盖先到者、清单指错文件。
|
|
2456
|
+
"""
|
|
2457
|
+
safe = sanitize_filename(title).strip().strip('.') or 'file'
|
|
2458
|
+
if used_names.get(safe) in (None, source):
|
|
2459
|
+
return safe
|
|
2460
|
+
stem, ext = os.path.splitext(safe)
|
|
2461
|
+
return '%s_%s%s' % (stem, (md5 or 'copy')[:8], ext)
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
2465
|
+
"""把媒体**落盘**并把 `manifest.json` 写好;返回 `(assets 目录名, manifest, 新写文件数)`。
|
|
2466
|
+
|
|
2467
|
+
HTML 走的是"内嵌 base64 ⇒ 单文件可分享",而 markdown 与其它格式的用法是喂给编辑器/别的工具 ——
|
|
2468
|
+
那里 `` 多数不被认,图片必须是**文件 + 相对路径**。所以这里写文件,并留一份清单
|
|
2469
|
+
(键 `local_id:create_time` → `{path, kind, …}`):**抽取只做一遍**,json/txt/excel 只是消费它。
|
|
2470
|
+
|
|
2471
|
+
图片与文档本体都进同一个清单:图片来自内存里的 base64,文档是**从 `msg/file/` 拷过来**的(路径在
|
|
2472
|
+
`format_message` 阶段就定好了)。清单 v2 起值是对象(带 `kind`),读取端要兼容 v1 的裸字符串。
|
|
2473
|
+
"""
|
|
2474
|
+
import base64
|
|
2475
|
+
import hashlib
|
|
2476
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
2477
|
+
assets = assets_dir or os.path.join(out_dir, file_prefix + '_assets')
|
|
2478
|
+
os.makedirs(assets, exist_ok=True)
|
|
2479
|
+
|
|
2480
|
+
manifest = {}
|
|
2481
|
+
written = 0
|
|
2482
|
+
for item in formatted:
|
|
2483
|
+
blob = item.get('image_b64')
|
|
2484
|
+
if not blob:
|
|
2485
|
+
continue
|
|
2486
|
+
data = base64.b64decode(blob)
|
|
2487
|
+
# 扩展名按**字节**判:实测同一会话 556 张图里 87 个是 GIF、27 个是 PNG,一律叫 .jpg 会让
|
|
2488
|
+
# 编辑器按扩展名解码、把动画表情显示成坏图。
|
|
2489
|
+
mime = detect_mime_from_bytes(data[:32]) or 'image/jpeg'
|
|
2490
|
+
name = 'img_%s_%s%s' % (item.get('local_id'),
|
|
2491
|
+
hashlib.md5(blob[:64].encode()).hexdigest()[:8],
|
|
2492
|
+
_IMAGE_EXT_BY_MIME.get(mime, '.jpg'))
|
|
2493
|
+
path = os.path.join(assets, name)
|
|
2494
|
+
if not os.path.exists(path):
|
|
2495
|
+
with open(path, 'wb') as fh:
|
|
2496
|
+
fh.write(data)
|
|
2497
|
+
written += 1
|
|
2498
|
+
# 键带时间戳:**同一个 local_id 会在分片之间重复**(每条会话的表可能存在于多个分片,
|
|
2499
|
+
# id 各片自己编号)—— 只按 local_id 做键,json/txt/excel 会拿别人的图。
|
|
2500
|
+
manifest['%s:%s' % (item.get('local_id'), item.get('create_time'))] = {
|
|
2501
|
+
'path': os.path.relpath(path, out_dir).replace('\\', '/'),
|
|
2502
|
+
'kind': 'image',
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
file_exported = 0
|
|
2506
|
+
file_written = 0
|
|
2507
|
+
file_unavailable = 0
|
|
2508
|
+
files_dir = os.path.join(assets, _FILE_ASSET_SUBDIR)
|
|
2509
|
+
used_names = {}
|
|
2510
|
+
for item in formatted:
|
|
2511
|
+
info = item.get('file')
|
|
2512
|
+
if not info:
|
|
2513
|
+
continue
|
|
2514
|
+
source = info.get('path')
|
|
2515
|
+
size = 0
|
|
2516
|
+
if source:
|
|
2517
|
+
try:
|
|
2518
|
+
size = os.path.getsize(source)
|
|
2519
|
+
except OSError:
|
|
2520
|
+
source = None
|
|
2521
|
+
# 超限与"本地没有"归到同一档计数(都是"这条消息的文档没能带出来"),但不静默:计数写进清单顶部。
|
|
2522
|
+
if not source or size > MAX_ASSET_FILE_BYTES:
|
|
2523
|
+
file_unavailable += 1
|
|
2524
|
+
continue
|
|
2525
|
+
name = _asset_file_name(info.get('title') or os.path.basename(source), used_names, source,
|
|
2526
|
+
info.get('md5'))
|
|
2527
|
+
target = os.path.join(files_dir, name)
|
|
2528
|
+
if used_names.get(name) != source:
|
|
2529
|
+
os.makedirs(files_dir, exist_ok=True)
|
|
2530
|
+
if not os.path.exists(target):
|
|
2531
|
+
with open(target, 'wb') as dst, open(source, 'rb') as src:
|
|
2532
|
+
shutil.copyfileobj(src, dst)
|
|
2533
|
+
file_written += 1
|
|
2534
|
+
used_names[name] = source
|
|
2535
|
+
file_exported += 1
|
|
2536
|
+
manifest['%s:%s' % (item.get('local_id'), item.get('create_time'))] = {
|
|
2537
|
+
'path': os.path.relpath(target, out_dir).replace('\\', '/'),
|
|
2538
|
+
'kind': 'file',
|
|
2539
|
+
'name': info.get('title') or name,
|
|
2540
|
+
'bytes': size,
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
FILE_ASSET_STATE['exported'] = file_exported
|
|
2544
|
+
FILE_ASSET_STATE['written'] = file_written
|
|
2545
|
+
FILE_ASSET_STATE['unavailable'] = file_unavailable
|
|
2546
|
+
with open(os.path.join(assets, 'manifest.json'), 'w', encoding='utf-8') as fh:
|
|
2547
|
+
json.dump({'schema': 'weflow-media-manifest/v2', 'messages': manifest,
|
|
2548
|
+
'documents': {'exported': file_exported, 'unavailable': file_unavailable}},
|
|
2549
|
+
fh, ensure_ascii=False, indent=2)
|
|
2550
|
+
return os.path.basename(os.path.normpath(assets)), manifest, written + file_written
|
|
2551
|
+
|
|
2552
|
+
|
|
2553
|
+
def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
|
|
2554
|
+
"""markdown 正文 + 媒体落盘(落盘那半在 `write_assets`,两者共用同一份清单)。"""
|
|
2555
|
+
assets_name, manifest, written = write_assets(out_dir, file_prefix, formatted, assets_dir)
|
|
2556
|
+
|
|
2557
|
+
lines = ['# %s' % file_prefix, '']
|
|
2558
|
+
for item in formatted:
|
|
2559
|
+
who = '我' if (item.get('is_self') or item.get('is_send')) else (item.get('sender') or '')
|
|
2560
|
+
# 这个文件是 `import datetime`(模块),且它的时间戳一律是**本地时区**(见 render 那几处),
|
|
2561
|
+
# 所以这里也照它的写法,不带 tz —— 导出页里显示的时间与本文件其它时间戳保持一致。
|
|
2562
|
+
stamp = datetime.datetime.fromtimestamp(item.get('create_time') or 0).strftime('%Y-%m-%d %H:%M')
|
|
2563
|
+
body = plain_fragment(item.get('display') or '', limit=100000) or ''
|
|
2564
|
+
record = manifest.get('%s:%s' % (item.get('local_id'), item.get('create_time')))
|
|
2565
|
+
if record:
|
|
2566
|
+
# v1 的值是裸字符串;v2 起是对象。两种都要认(磁盘上可能还留着上一版的清单)。
|
|
2567
|
+
if isinstance(record, dict):
|
|
2568
|
+
rel = record.get('path') or ''
|
|
2569
|
+
kind = record.get('kind') or 'image'
|
|
2570
|
+
label = record.get('name') or ''
|
|
2571
|
+
else:
|
|
2572
|
+
rel, kind, label = record, 'image', ''
|
|
2573
|
+
if rel and kind == 'file':
|
|
2574
|
+
# 文档不是图片:`` 会被当成坏图,要写成链接,链接文字用原始文件名。
|
|
2575
|
+
keep = '' if (not body or body.startswith('[文件]')) else body + chr(10) + chr(10)
|
|
2576
|
+
body = keep + '[%s](%s)' % (label or os.path.basename(rel), rel)
|
|
2577
|
+
elif rel:
|
|
2578
|
+
body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10)) + '' % rel
|
|
2579
|
+
lines.extend(['**%s** %s' % (who, stamp), '', body.strip() or '(空)', ''])
|
|
2580
|
+
|
|
2581
|
+
md_path = os.path.join(out_dir, file_prefix + '.md')
|
|
2582
|
+
with open(md_path, 'w', encoding='utf-8') as fh:
|
|
2583
|
+
fh.write(chr(10).join(lines))
|
|
2584
|
+
return md_path, len(manifest), written, assets_name
|
|
2585
|
+
|
|
2296
2586
|
def main():
|
|
2297
2587
|
import argparse
|
|
2298
2588
|
parser = argparse.ArgumentParser(description='Export WeChat NT chat as HTML')
|
|
@@ -2302,6 +2592,12 @@ def main():
|
|
|
2302
2592
|
parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
|
|
2303
2593
|
parser.add_argument('--name', default=os.environ.get('WEFLOW_EXPORT_NAME', ''), help='Display name')
|
|
2304
2594
|
parser.add_argument('--out', default=os.environ.get('WEFLOW_EXPORT_OUTPUT', './output'), help='Output directory')
|
|
2595
|
+
parser.add_argument('--format', choices=('html', 'md'), default='html',
|
|
2596
|
+
help='html=内嵌图片的多分页页面(默认);md=markdown + 图片落盘到 assets/')
|
|
2597
|
+
parser.add_argument('--assets-only', action='store_true',
|
|
2598
|
+
help='只抽取媒体到 assets/ 并写清单,不写 markdown(给 json/txt/excel 的 --media 用)')
|
|
2599
|
+
parser.add_argument('--assets-dir', default='',
|
|
2600
|
+
help='md 模式下图片落到哪个目录(默认 <out>/<前缀>_assets;同时写 manifest.json)')
|
|
2305
2601
|
parser.add_argument('--parts', type=int, default=5, help='Number of parts to split into')
|
|
2306
2602
|
parser.add_argument('--wx-dir', default='', help='Traditional WeChat data dir (FileStorage fallback)')
|
|
2307
2603
|
parser.add_argument('--cache-dir', default=os.environ.get('WEFLOW_EXPORT_CACHE_DIR', ''), help='NT cache directory for image thumbnails')
|
|
@@ -2347,6 +2643,14 @@ def main():
|
|
|
2347
2643
|
value = shrink_embedded(value, max_side=720, force=True)
|
|
2348
2644
|
image_map[key] = value
|
|
2349
2645
|
print(f" Found {len(image_map)} media entries for embedding")
|
|
2646
|
+
|
|
2647
|
+
# 文档本体的索引:只 stat,不读内容(1,677 个文件的走一遍不到一秒)。没有它,`appmsg type=6`
|
|
2648
|
+
# 的消息就只能渲染成 `[文件] 名字` —— 正是反馈里"其余格式不支持导出文档"的那一半。
|
|
2649
|
+
file_index = {}
|
|
2650
|
+
if args.account_dir:
|
|
2651
|
+
file_index = scan_account_file_index(args.account_dir)
|
|
2652
|
+
if file_index:
|
|
2653
|
+
print(f" Account file index: {len(file_index)} key(s)")
|
|
2350
2654
|
_phase("media scan")
|
|
2351
2655
|
|
|
2352
2656
|
# Custom stickers: derive the local cache key from the account seed.
|
|
@@ -2462,7 +2766,7 @@ def main():
|
|
|
2462
2766
|
PREFETCH['sink'] = sink
|
|
2463
2767
|
try:
|
|
2464
2768
|
for row in messages:
|
|
2465
|
-
format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid)
|
|
2769
|
+
format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid, file_index)
|
|
2466
2770
|
finally:
|
|
2467
2771
|
PREFETCH['sink'] = None
|
|
2468
2772
|
if sink:
|
|
@@ -2483,7 +2787,7 @@ def main():
|
|
|
2483
2787
|
for i, row in enumerate(messages):
|
|
2484
2788
|
if i % progress_step == 0:
|
|
2485
2789
|
print(f" Formatting {i}/{len(messages)}...", flush=True)
|
|
2486
|
-
result = format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid)
|
|
2790
|
+
result = format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid, file_index)
|
|
2487
2791
|
if result.get('image_b64'):
|
|
2488
2792
|
img_hit_count += 1
|
|
2489
2793
|
if result.get('local_type') == 49:
|
|
@@ -2513,6 +2817,42 @@ def main():
|
|
|
2513
2817
|
# Use display name for filename if provided, otherwise fallback to wxid
|
|
2514
2818
|
file_prefix = sanitize_filename(display_name) if display_name else args.talker.replace('@', '_').replace('/', '_')
|
|
2515
2819
|
|
|
2820
|
+
if args.assets_only:
|
|
2821
|
+
# 只抽媒体:其它格式的 `--media` 会先跑这一步,再各自消费清单。
|
|
2822
|
+
assets_name, manifest, asset_new = write_assets(args.out, file_prefix, formatted, args.assets_dir)
|
|
2823
|
+
print(f"Assets: {len(manifest)} media item(s), {FILE_ASSET_STATE['exported']} document(s) "
|
|
2824
|
+
f"({FILE_ASSET_STATE['unavailable']} unavailable, {FILE_ASSET_STATE['written']} new) "
|
|
2825
|
+
f"({asset_new} new) -> {assets_name}")
|
|
2826
|
+
print(json.dumps({"success": True, "total": len(formatted),
|
|
2827
|
+
"files": [os.path.join(args.out, assets_name)], "format": "assets",
|
|
2828
|
+
"assets": len(manifest), "assetsNew": asset_new,
|
|
2829
|
+
"documents": FILE_ASSET_STATE['exported'],
|
|
2830
|
+
"documentsNew": FILE_ASSET_STATE['written'],
|
|
2831
|
+
"documentsUnavailable": FILE_ASSET_STATE['unavailable'],
|
|
2832
|
+
"assetsDir": assets_name}, ensure_ascii=False))
|
|
2833
|
+
conn.close()
|
|
2834
|
+
return
|
|
2835
|
+
if args.format == 'md':
|
|
2836
|
+
md_path, asset_total, asset_new, assets_name = write_markdown(args.out, file_prefix, formatted, args.assets_dir)
|
|
2837
|
+
if media_items:
|
|
2838
|
+
media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
|
|
2839
|
+
with open(media_report_path, 'w', encoding='utf-8') as handle:
|
|
2840
|
+
json.dump(build_media_report(media_items, display_name, args.talker, len(formatted)),
|
|
2841
|
+
handle, ensure_ascii=False, indent=2)
|
|
2842
|
+
print(f"Markdown: {len(formatted)} message(s), {asset_total} media item(s), "
|
|
2843
|
+
f"{FILE_ASSET_STATE['exported']} document(s) ({FILE_ASSET_STATE['unavailable']} unavailable, "
|
|
2844
|
+
f"{FILE_ASSET_STATE['written']} new) ({asset_new} new) -> {os.path.basename(md_path)}")
|
|
2845
|
+
print(json.dumps({"success": True, "total": len(formatted), "parts": 1,
|
|
2846
|
+
"files": [md_path], "format": "md",
|
|
2847
|
+
"assets": asset_total, "assetsNew": asset_new,
|
|
2848
|
+
"documents": FILE_ASSET_STATE['exported'],
|
|
2849
|
+
"documentsNew": FILE_ASSET_STATE['written'],
|
|
2850
|
+
"documentsUnavailable": FILE_ASSET_STATE['unavailable'],
|
|
2851
|
+
"assetsDir": assets_name},
|
|
2852
|
+
ensure_ascii=False))
|
|
2853
|
+
conn.close()
|
|
2854
|
+
return
|
|
2855
|
+
|
|
2516
2856
|
# Media coverage report. Written beside the HTML parts, under the same
|
|
2517
2857
|
# prefix, so it is always found next to what it describes.
|
|
2518
2858
|
media_report = None
|
package/scripts/fav_notes.py
CHANGED
|
@@ -99,7 +99,10 @@ def classify_source(item: dict) -> str:
|
|
|
99
99
|
def favorite_text(item: dict, fetch=None):
|
|
100
100
|
"""一条收藏的正文:本地文本优先,没有(或太短)就抓链接。返回 (正文, 来源说明)。
|
|
101
101
|
|
|
102
|
-
收藏分两种:笔记/文本类**自带内容**(`desc`/`content
|
|
102
|
+
收藏分两种:笔记/文本类**自带内容**(`desc`/`content`),文章类**基本**只有 `link`——
|
|
103
|
+
"基本"是量出来的:本机 1,234 篇文章里有 **7 条**的网页描述(`weburlitem/pagedesc`)超过
|
|
104
|
+
`MIN_ARTICLE_CHARS`,会被当成"自带正文"(那是**摘要不是正文**)。门槛 200 字挡住了短噪声
|
|
105
|
+
(例如本机 20 条 `type=1` 的 `desc` 只有 49 字、且是路径式字符串 ⇒ 全部落 none)。
|
|
103
106
|
抓取复用日报那条线的 `fetch_article_cached`——不重写第二份(那套微信 UA 绕 WAF、
|
|
104
107
|
gzip、重试是踩出来的),而且**带缓存**:日报抓过的文章这里零网络。
|
|
105
108
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""把知识库导成一张**2D** 图谱页面(更像 Obsidian 那种平面图;同样自包含、不联网)。
|
|
3
|
+
|
|
4
|
+
与 3D 那份(`graph_3d.py`)的分工:**图怎么建**是同一套(点 = 概念页,边 = 正文里的 `[[链接]]`,
|
|
5
|
+
排除 `## 来源` 那一段,也共用 `--min-degree` / `--line` 的过滤),直接 import 过来;
|
|
6
|
+
这里只换两件事:
|
|
7
|
+
|
|
8
|
+
1. **布局按 2D 算** —— 不是把 3D 的 x/y 拍扁。z 那一维也承载结构,拍扁会把两团本不相干的点叠在一起;
|
|
9
|
+
2D 的解也快得多,所以同一张图重算得起。
|
|
10
|
+
2. **渲染换成 canvas**(`resources/js/graph3d/viewer2d.js`)——不需要 three.js,这页一张图都不引。
|
|
11
|
+
|
|
12
|
+
为什么单独一个文件、而不是给 3D 那份加个开关:两套渲染器各两百来行,塞进一个文件就没人看得完了;
|
|
13
|
+
共用的部分留在 `graph_3d.py`,两个入口都从那里取。
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
24
|
+
sys.path.insert(0, str(REPO / 'scripts'))
|
|
25
|
+
sys.path.insert(0, str(REPO))
|
|
26
|
+
# 先切到仓库根(`wiki_lint.CARD_DIRS` 是导入时按 cwd 算的,见 graph_3d.py 里那段)
|
|
27
|
+
os.chdir(REPO)
|
|
28
|
+
import graph_3d as g # noqa: E402
|
|
29
|
+
|
|
30
|
+
VIEWER2D = g.LIB_DIR / 'viewer2d.js'
|
|
31
|
+
LAYOUT = g.LIB_DIR / 'layout.mjs'
|
|
32
|
+
DEFAULT_OUT = REPO / 'output' / 'knowledge-graph-2d.html'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_args(argv):
|
|
36
|
+
ap = argparse.ArgumentParser(description='把知识库导成一张自包含的 2D 图谱页面(只读本地、不联网)')
|
|
37
|
+
ap.add_argument('--vault', default=str(g.DEFAULT_VAULT), help='Vault 根目录(默认 output/wechat-vault)')
|
|
38
|
+
ap.add_argument('--out', default=str(DEFAULT_OUT), help='页面写到哪(默认 output/knowledge-graph-2d.html)')
|
|
39
|
+
ap.add_argument('--min-degree', dest='min_degree', metavar='N', type=int, default=0,
|
|
40
|
+
help='只画在原图里连接数 ≥N 的概念(去掉细枝,看骨架);0 = 全画')
|
|
41
|
+
ap.add_argument('--line', dest='line', metavar='WHICH', choices=['all', 'wiki', 'chat'], default='all',
|
|
42
|
+
help='只画某一条线:文章线的概念页 / 聊天线的概念页 / 两条都画')
|
|
43
|
+
ap.add_argument('--ticks', type=int, default=250, help='力导向迭代次数(2D 比 3D 快得多)')
|
|
44
|
+
ap.add_argument('--cache', default=str(g.CACHE_DIR), help='布局缓存目录(派生数据;测试请指到临时目录)')
|
|
45
|
+
ap.add_argument('--dry-run', action='store_true', help='只报概念与链接数:不写文件、也不算布局')
|
|
46
|
+
ap.add_argument('--json', action='store_true', help='输出机器可读结果')
|
|
47
|
+
return ap.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _missing():
|
|
51
|
+
# 2D 页面**不需要 three.js**:布局用 d3,渲染是裸 canvas。缺件要在跑布局之前说清楚,
|
|
52
|
+
# 否则报错会长得像"库坏了"。
|
|
53
|
+
wanted = [VIEWER2D, LAYOUT] + [g.LIB_DIR / f for f in g.LIB_D3]
|
|
54
|
+
return [p.name for p in wanted if not p.exists()]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv=None):
|
|
58
|
+
# 手跑时 stdout 是本机编码(这台机器是 GBK),中文会乱码、非 GBK 字符直接崩掉整次生成
|
|
59
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
60
|
+
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
|
61
|
+
vault = Path(args.vault)
|
|
62
|
+
# 与 `graph_3d.build_graph` 一样,清单从 `_utils.KNOWLEDGE_LINES` 读(经 `wiki_lint` 透出),
|
|
63
|
+
# 不在这里硬编码——硬编码的地方不会跟着清单变,只会静默少算。
|
|
64
|
+
if not any((vault / concepts).is_dir() for _line, _root, concepts in g.wl.KNOWLEDGE_LINES):
|
|
65
|
+
return g._fail('Vault 里没有概念目录:%s(先跑 `wiki compile`,或用 --vault 指定)' % vault, args.json)
|
|
66
|
+
|
|
67
|
+
nodes, links = g.build_graph(vault, min_degree=args.min_degree, line=args.line)
|
|
68
|
+
if args.dry_run:
|
|
69
|
+
if args.json:
|
|
70
|
+
print(json.dumps({'success': True, 'action': 'graph-2d', 'dryRun': True, 'dims': 2,
|
|
71
|
+
'nodes': len(nodes), 'links': len(links), 'vault': str(vault),
|
|
72
|
+
'minDegree': args.min_degree, 'line': args.line,
|
|
73
|
+
'readsLocalData': True, 'invokesAI': False, 'sendsNothing': True},
|
|
74
|
+
ensure_ascii=False))
|
|
75
|
+
else:
|
|
76
|
+
print('概念 %d 个、链接 %d 条(--dry-run:没有写文件)' % (len(nodes), len(links)))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
missing = _missing()
|
|
80
|
+
if missing:
|
|
81
|
+
return g._fail('缺件:%s(应当在 %s 下随包发)' % ('、'.join(missing), g.LIB_DIR), args.json)
|
|
82
|
+
|
|
83
|
+
data = json.dumps({'nodes': nodes, 'links': links}, ensure_ascii=False, separators=(',', ':'))
|
|
84
|
+
# 页面数据一份(含线的展示名),缓存键用抽掉展示名的那一份 —— 理由见 graph_3d.py
|
|
85
|
+
html_data = json.dumps({'nodes': nodes, 'links': links, 'lineLabels': g._utils.LINE_LABELS},
|
|
86
|
+
ensure_ascii=False, separators=(',', ':'))
|
|
87
|
+
# 与 3D 分开分槽:同一张图的 2D 与 3D 坐标不通用,各存一份(见 graph_3d.py 里那段注释)
|
|
88
|
+
digest = g.layout_key(data, 2, args.ticks)
|
|
89
|
+
cache = Path(args.cache)
|
|
90
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
graph_file = cache / ('graph-%s.json' % digest)
|
|
92
|
+
pos_file = cache / ('positions-%s.json' % digest)
|
|
93
|
+
same = graph_file.exists() and graph_file.read_text(encoding='utf-8') == data
|
|
94
|
+
if same and pos_file.exists():
|
|
95
|
+
print('图没变,沿用已有布局')
|
|
96
|
+
else:
|
|
97
|
+
graph_file.write_text(data, encoding='utf-8')
|
|
98
|
+
g.run_layout(cache, digest, 2, args.ticks)
|
|
99
|
+
pos = pos_file.read_text(encoding='utf-8')
|
|
100
|
+
|
|
101
|
+
viewer = VIEWER2D.read_text(encoding='utf-8')
|
|
102
|
+
page = f"""<!doctype html>
|
|
103
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
104
|
+
<title>知识库 2D({len(nodes)} 个概念)</title>
|
|
105
|
+
<style>
|
|
106
|
+
html,body {{ margin:0; height:100%; background:#05070e; color:#c9d3e6;
|
|
107
|
+
font:13px/1.5 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
|
|
108
|
+
overflow:hidden; user-select:none; }}
|
|
109
|
+
/* canvas 是**替换元素**:`inset:0` 不会让它铺满(right/bottom 被忽略,它保持固有的 300x150),
|
|
110
|
+
所以必须显式给 width/height。少了这一条,视图按 300x150 算,整张图画在左上角一小块里。 */
|
|
111
|
+
#cv {{ position:fixed; inset:0; width:100%; height:100%; display:block; cursor:grab; touch-action:none; }}
|
|
112
|
+
#hud {{ position:fixed; left:14px; top:12px; pointer-events:none; max-width:62vw; }}
|
|
113
|
+
#stats {{ color:#6d7a93; }}
|
|
114
|
+
#info {{ margin-top:6px; font-size:15px; min-height:22px; }}
|
|
115
|
+
#q {{ position:fixed; right:14px; top:12px; width:220px; padding:6px 10px;
|
|
116
|
+
background:#121826; color:#c9d3e6; border:1px solid #26304a; border-radius:8px; outline:none; }}
|
|
117
|
+
#q:focus {{ border-color:#3f6ad8; }}
|
|
118
|
+
#tag {{ position:fixed; right:14px; bottom:10px; color:#4d5a72; text-align:right; }}
|
|
119
|
+
#boot {{ position:fixed; inset:0; display:flex; align-items:center; justify-content:center;
|
|
120
|
+
background:#05070e; color:#6d7a93; }}
|
|
121
|
+
</style></head>
|
|
122
|
+
<body>
|
|
123
|
+
<canvas id="cv"></canvas>
|
|
124
|
+
<div id="hud"><div id="stats"></div><div id="info">点一个概念看它连着谁 · 拖动平移 · 滚轮缩放</div></div>
|
|
125
|
+
<input id="q" placeholder="搜概念,回车跳过去">
|
|
126
|
+
<div id="tag">2D · 坐标构建期算好 · 双击空白复位</div>
|
|
127
|
+
<div id="boot">正在画…</div>
|
|
128
|
+
<script>window.__DATA__ = {html_data};</script>
|
|
129
|
+
<script>window.__POS__ = "{pos}";</script>
|
|
130
|
+
<script>{viewer}</script>
|
|
131
|
+
</body></html>
|
|
132
|
+
"""
|
|
133
|
+
out = Path(args.out)
|
|
134
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
out.write_text(page, encoding='utf-8')
|
|
136
|
+
mb = out.stat().st_size / 1048576
|
|
137
|
+
if args.json:
|
|
138
|
+
print(json.dumps({'success': True, 'action': 'graph-2d', 'dims': 2,
|
|
139
|
+
'nodes': len(nodes), 'links': len(links), 'out': str(out),
|
|
140
|
+
'vault': str(vault), 'mb': round(mb, 2),
|
|
141
|
+
'minDegree': args.min_degree, 'line': args.line,
|
|
142
|
+
'selfContained': True, 'readsLocalData': True, 'invokesAI': False,
|
|
143
|
+
'sendsNothing': True}, ensure_ascii=False))
|
|
144
|
+
else:
|
|
145
|
+
print('已生成 %s' % out)
|
|
146
|
+
print(' 概念 %d / 链接 %d / 文件 %.1f MB · 2D' % (len(nodes), len(links), mb))
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == '__main__':
|
|
151
|
+
sys.exit(main())
|