daochu 0.1.0__py3-none-any.whl
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.
- daochu/__init__.py +20 -0
- daochu/cli.py +146 -0
- daochu/exporter.py +94 -0
- daochu/models.py +61 -0
- daochu/parser.py +292 -0
- daochu/templates.py +193 -0
- daochu-0.1.0.dist-info/METADATA +144 -0
- daochu-0.1.0.dist-info/RECORD +12 -0
- daochu-0.1.0.dist-info/WHEEL +5 -0
- daochu-0.1.0.dist-info/entry_points.txt +2 -0
- daochu-0.1.0.dist-info/licenses/LICENSE +21 -0
- daochu-0.1.0.dist-info/top_level.txt +1 -0
daochu/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DaoChu (导出) — Export WeChat conversations to HTML or Markdown.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
daochu export <db_path> <contact> [--format html|md] [-o output]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .exporter import export, render_markdown
|
|
9
|
+
from .parser import parse_csv, parse_db, parse_json
|
|
10
|
+
from .templates import render_html
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"export",
|
|
15
|
+
"parse_db",
|
|
16
|
+
"parse_csv",
|
|
17
|
+
"parse_json",
|
|
18
|
+
"render_html",
|
|
19
|
+
"render_markdown",
|
|
20
|
+
]
|
daochu/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""CLI for DaoChu — 微信聊天记录导出工具."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .exporter import export
|
|
12
|
+
from .parser import auto_parse, parse_csv, parse_db, parse_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
16
|
+
@click.version_option(__version__, "-V", "--version", message="DaoChu %(version)s")
|
|
17
|
+
def main():
|
|
18
|
+
"""DaoChu(导出)— 将微信聊天记录导出为 HTML 或 Markdown。
|
|
19
|
+
|
|
20
|
+
支持三种数据源:
|
|
21
|
+
\b
|
|
22
|
+
1. 解密后的微信 SQLite 数据库(MSG.db)
|
|
23
|
+
2. CSV 导出文件
|
|
24
|
+
3. JSON 导出文件
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@main.command("export")
|
|
29
|
+
@click.argument("source", type=click.Path(exists=True))
|
|
30
|
+
@click.argument("contact")
|
|
31
|
+
@click.option(
|
|
32
|
+
"-o", "--output", default=".",
|
|
33
|
+
type=click.Path(),
|
|
34
|
+
help="输出目录或文件路径(默认当前目录)",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"-f", "--format", "fmt",
|
|
38
|
+
type=click.Choice(["html", "md"]),
|
|
39
|
+
default="html",
|
|
40
|
+
help="输出格式 (默认 html)",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--me", "my_name",
|
|
44
|
+
default="Me",
|
|
45
|
+
help="自己的显示名称 (默认 Me)",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--source-type", "source_type",
|
|
49
|
+
type=click.Choice(["auto", "db", "csv", "json"]),
|
|
50
|
+
default="auto",
|
|
51
|
+
help="数据源类型 (默认 auto 自动检测)",
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--table",
|
|
55
|
+
default="MSG",
|
|
56
|
+
help="数据库表名 (默认 MSG,仅 db 模式)",
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--encoding",
|
|
60
|
+
default="utf-8-sig",
|
|
61
|
+
help="CSV/JSON 文件编码 (默认 utf-8-sig)",
|
|
62
|
+
)
|
|
63
|
+
def export_cmd(
|
|
64
|
+
source, contact, output, fmt, my_name, source_type, table, encoding
|
|
65
|
+
):
|
|
66
|
+
"""导出与某个联系人的全部聊天记录。
|
|
67
|
+
|
|
68
|
+
SOURCE: 数据文件路径(解密后的 .db / .csv / .json)\n
|
|
69
|
+
CONTACT: 联系人 wxid 或显示名
|
|
70
|
+
"""
|
|
71
|
+
source_path = Path(source)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if source_type == "auto":
|
|
75
|
+
conv = auto_parse(source_path, contact, my_name, table=table)
|
|
76
|
+
elif source_type == "db":
|
|
77
|
+
conv = parse_db(source_path, contact, my_name, table=table)
|
|
78
|
+
elif source_type == "csv":
|
|
79
|
+
conv = parse_csv(source_path, contact, my_name, encoding=encoding)
|
|
80
|
+
elif source_type == "json":
|
|
81
|
+
conv = parse_json(source_path, contact, my_name, encoding=encoding)
|
|
82
|
+
else:
|
|
83
|
+
click.echo(f"未知数据源类型: {source_type}", err=True)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
click.echo(f"❌ 解析失败: {e}", err=True)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
if not conv.messages:
|
|
90
|
+
click.echo(f"⚠ 未找到与 '{contact}' 的聊天记录。")
|
|
91
|
+
sys.exit(0)
|
|
92
|
+
|
|
93
|
+
export(conv, output, fmt)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@main.command("list-contacts")
|
|
97
|
+
@click.argument("source", type=click.Path(exists=True))
|
|
98
|
+
def list_contacts(source):
|
|
99
|
+
"""列出数据库中的所有联系人。
|
|
100
|
+
|
|
101
|
+
SOURCE: 解密后的微信 SQLite 数据库路径。
|
|
102
|
+
"""
|
|
103
|
+
import sqlite3
|
|
104
|
+
|
|
105
|
+
conn = sqlite3.connect(str(source))
|
|
106
|
+
cur = conn.cursor()
|
|
107
|
+
|
|
108
|
+
# Find MSG table
|
|
109
|
+
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
110
|
+
tables = [r[0] for r in cur.fetchall()]
|
|
111
|
+
msg_tables = [t for t in tables if "MSG" in t.upper()]
|
|
112
|
+
|
|
113
|
+
if not msg_tables:
|
|
114
|
+
click.echo("❌ 未找到消息表。", err=True)
|
|
115
|
+
conn.close()
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
table = msg_tables[0]
|
|
119
|
+
|
|
120
|
+
# Try common contact column names
|
|
121
|
+
for col in ("StrTalker", "talker", "sender", "contact"):
|
|
122
|
+
try:
|
|
123
|
+
cur.execute(
|
|
124
|
+
f'SELECT DISTINCT {col}, COUNT(*) as cnt FROM "{table}" '
|
|
125
|
+
f"GROUP BY {col} ORDER BY cnt DESC"
|
|
126
|
+
)
|
|
127
|
+
rows = cur.fetchall()
|
|
128
|
+
if rows:
|
|
129
|
+
click.echo(f"\n📋 联系人列表 (来自 {table}.{col}):\n")
|
|
130
|
+
click.echo(f"{'联系人 wxid/名称':<40} {'消息数':>8}")
|
|
131
|
+
click.echo("-" * 52)
|
|
132
|
+
for name, cnt in rows:
|
|
133
|
+
name_str = str(name)[:38]
|
|
134
|
+
click.echo(f"{name_str:<40} {cnt:>8}")
|
|
135
|
+
click.echo("")
|
|
136
|
+
break
|
|
137
|
+
except sqlite3.OperationalError:
|
|
138
|
+
continue
|
|
139
|
+
else:
|
|
140
|
+
click.echo("❌ 无法识别联系人列。请手动指定。", err=True)
|
|
141
|
+
|
|
142
|
+
conn.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
daochu/exporter.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Export a Conversation to HTML or Markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .models import Conversation
|
|
9
|
+
from .templates import render_html
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_markdown(conv: Conversation) -> str:
|
|
13
|
+
"""Render a Conversation to Markdown."""
|
|
14
|
+
lines: list[str] = []
|
|
15
|
+
lines.append(f"# 💬 与 {conv.contact_name} 的微信聊天记录")
|
|
16
|
+
lines.append("")
|
|
17
|
+
|
|
18
|
+
if conv.messages:
|
|
19
|
+
first = conv.messages[0].date_str
|
|
20
|
+
last = conv.messages[-1].date_str
|
|
21
|
+
lines.append(f"> 📅 {first} — {last} · 📝 {conv.message_count} 条消息")
|
|
22
|
+
lines.append("")
|
|
23
|
+
lines.append("---")
|
|
24
|
+
lines.append("")
|
|
25
|
+
|
|
26
|
+
for date, msgs in conv.group_by_date():
|
|
27
|
+
lines.append(f"### {date}")
|
|
28
|
+
lines.append("")
|
|
29
|
+
for m in msgs:
|
|
30
|
+
role = conv.my_name if m.is_self else m.sender_name
|
|
31
|
+
time_str = m.short_time
|
|
32
|
+
content = m.content.replace("\n", "\n> ")
|
|
33
|
+
lines.append(f"**{role}** _{time_str}_")
|
|
34
|
+
lines.append(f"> {content}")
|
|
35
|
+
lines.append("")
|
|
36
|
+
lines.append("")
|
|
37
|
+
|
|
38
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
39
|
+
lines.append("---")
|
|
40
|
+
lines.append(f"*由 [DaoChu](https://pypi.org/project/daochu/) 导出 · {now}*")
|
|
41
|
+
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def export(
|
|
46
|
+
conv: Conversation,
|
|
47
|
+
output_path: str | Path,
|
|
48
|
+
fmt: str = "html",
|
|
49
|
+
) -> Path:
|
|
50
|
+
"""
|
|
51
|
+
Export a conversation to a file.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
conv : Conversation
|
|
56
|
+
The parsed conversation.
|
|
57
|
+
output_path : str | Path
|
|
58
|
+
Destination file path (auto-suffixed if it's a directory).
|
|
59
|
+
fmt : str
|
|
60
|
+
``"html"`` or ``"md"`` / ``"markdown"``.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
Path
|
|
65
|
+
The path of the written file.
|
|
66
|
+
"""
|
|
67
|
+
output_path = Path(output_path)
|
|
68
|
+
|
|
69
|
+
if output_path.is_dir() or output_path.suffix not in (".html", ".md", ".markdown"):
|
|
70
|
+
safe_name = _safe_filename(conv.contact_name)
|
|
71
|
+
suffix = ".html" if fmt == "html" else ".md"
|
|
72
|
+
output_path = output_path / f"{safe_name}_chat{suffix}"
|
|
73
|
+
|
|
74
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
77
|
+
|
|
78
|
+
if fmt in ("md", "markdown"):
|
|
79
|
+
content = render_markdown(conv)
|
|
80
|
+
else:
|
|
81
|
+
content = render_html(conv, now_str)
|
|
82
|
+
|
|
83
|
+
output_path.write_text(content, encoding="utf-8")
|
|
84
|
+
print(f"✅ 导出成功 → {output_path} ({conv.message_count} 条消息)")
|
|
85
|
+
return output_path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _safe_filename(name: str) -> str:
|
|
89
|
+
"""Sanitize a name for use as a filename."""
|
|
90
|
+
unsafe = r'<>:"/\|?*'
|
|
91
|
+
safe = name
|
|
92
|
+
for ch in unsafe:
|
|
93
|
+
safe = safe.replace(ch, "_")
|
|
94
|
+
return safe.strip()
|
daochu/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Data models for WeChat messages and conversations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Message:
|
|
12
|
+
"""A single WeChat message."""
|
|
13
|
+
|
|
14
|
+
id: int
|
|
15
|
+
timestamp: datetime
|
|
16
|
+
sender_name: str # who sent it
|
|
17
|
+
content: str # text content
|
|
18
|
+
msg_type: int = 1 # 1=text, 3=image, 34=voice, 47=emoji, etc.
|
|
19
|
+
is_self: bool = False # sent by the account owner
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def formatted_time(self) -> str:
|
|
23
|
+
return self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def short_time(self) -> str:
|
|
27
|
+
return self.timestamp.strftime("%H:%M")
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def date_str(self) -> str:
|
|
31
|
+
return self.timestamp.strftime("%Y-%m-%d")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Conversation:
|
|
36
|
+
"""A conversation with a specific contact."""
|
|
37
|
+
|
|
38
|
+
contact_name: str
|
|
39
|
+
messages: list[Message] = field(default_factory=list)
|
|
40
|
+
my_name: str = "Me"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def date_range(self) -> tuple[str, str]:
|
|
44
|
+
if not self.messages:
|
|
45
|
+
return ("", "")
|
|
46
|
+
first = self.messages[0].date_str
|
|
47
|
+
last = self.messages[-1].date_str
|
|
48
|
+
return (first, last)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def message_count(self) -> int:
|
|
52
|
+
return len(self.messages)
|
|
53
|
+
|
|
54
|
+
def group_by_date(self) -> list[tuple[str, list[Message]]]:
|
|
55
|
+
"""Group messages by date, returns [(date_str, [msg, ...]), ...]."""
|
|
56
|
+
groups: list[tuple[str, list[Message]]] = []
|
|
57
|
+
for msg in self.messages:
|
|
58
|
+
if not groups or groups[-1][0] != msg.date_str:
|
|
59
|
+
groups.append((msg.date_str, []))
|
|
60
|
+
groups[-1][1].append(msg)
|
|
61
|
+
return groups
|
daochu/parser.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Parser: read WeChat messages from SQLite database or CSV/JSON exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .models import Conversation, Message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── SQLite (decrypted WeChat DB) ────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
def _try_parse_ts(val) -> Optional[datetime]:
|
|
19
|
+
"""WeChat uses Unix timestamps (seconds or milliseconds)."""
|
|
20
|
+
if val is None:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
n = int(val)
|
|
24
|
+
if n > 1_000_000_000_000: # milliseconds
|
|
25
|
+
n //= 1000
|
|
26
|
+
return datetime.fromtimestamp(n)
|
|
27
|
+
except (ValueError, OSError):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_db(
|
|
32
|
+
db_path: str | Path,
|
|
33
|
+
contact_name: str,
|
|
34
|
+
my_name: str = "Me",
|
|
35
|
+
*,
|
|
36
|
+
contact_col: str = "StrTalker",
|
|
37
|
+
content_col: str = "StrContent",
|
|
38
|
+
time_col: str = "CreateTime",
|
|
39
|
+
type_col: str = "Type",
|
|
40
|
+
is_sender_col: str = "IsSender",
|
|
41
|
+
table: str = "MSG",
|
|
42
|
+
) -> Conversation:
|
|
43
|
+
"""
|
|
44
|
+
Parse a decrypted WeChat Msg database.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
db_path : str | Path
|
|
49
|
+
Path to the decrypted SQLite database (e.g. ``MSG0.db``).
|
|
50
|
+
contact_name : str
|
|
51
|
+
The wxid or display name of the contact whose conversation to export.
|
|
52
|
+
my_name : str
|
|
53
|
+
How to label your own messages (default "Me").
|
|
54
|
+
contact_col, content_col, time_col, type_col, is_sender_col, table :
|
|
55
|
+
Column / table names; defaults work for common WeChat versions.
|
|
56
|
+
"""
|
|
57
|
+
db_path = Path(db_path)
|
|
58
|
+
if not db_path.exists():
|
|
59
|
+
raise FileNotFoundError(f"Database not found: {db_path}")
|
|
60
|
+
|
|
61
|
+
conn = sqlite3.connect(str(db_path))
|
|
62
|
+
conn.row_factory = sqlite3.Row
|
|
63
|
+
cur = conn.cursor()
|
|
64
|
+
|
|
65
|
+
# Determine the actual table name
|
|
66
|
+
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
67
|
+
tables = [r["name"] for r in cur.fetchall()]
|
|
68
|
+
if table not in tables:
|
|
69
|
+
# Try to find a suitable table
|
|
70
|
+
candidates = [t for t in tables if "MSG" in t.upper()]
|
|
71
|
+
if candidates:
|
|
72
|
+
table = candidates[0]
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Table '{table}' not found. Available tables: {tables}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Determine columns
|
|
79
|
+
cur.execute(f"PRAGMA table_info('{table}')")
|
|
80
|
+
cols = {r["name"].lower(): r["name"] for r in cur.fetchall()}
|
|
81
|
+
|
|
82
|
+
def _col(key: str) -> str:
|
|
83
|
+
return cols.get(key.lower(), key)
|
|
84
|
+
|
|
85
|
+
# Query messages for this contact (sorted by time)
|
|
86
|
+
try:
|
|
87
|
+
cur.execute(
|
|
88
|
+
f'SELECT * FROM "{table}" WHERE {_col(contact_col)} = ? ORDER BY {_col(time_col)} ASC',
|
|
89
|
+
(contact_name,),
|
|
90
|
+
)
|
|
91
|
+
except sqlite3.OperationalError as e:
|
|
92
|
+
# Maybe the contact is stored in another column
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"Failed to query table '{table}'. Tried contact column '{contact_col}'. "
|
|
95
|
+
f"Available columns: {list(cols.values())}. Error: {e}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
rows = cur.fetchall()
|
|
99
|
+
conn.close()
|
|
100
|
+
|
|
101
|
+
messages: list[Message] = []
|
|
102
|
+
for row in rows:
|
|
103
|
+
ts = _try_parse_ts(row[_col(time_col)])
|
|
104
|
+
content = row[_col(content_col)] or ""
|
|
105
|
+
msg_type = int(row[_col(type_col)] or 1)
|
|
106
|
+
|
|
107
|
+
# Determine if self-sent
|
|
108
|
+
is_self = False
|
|
109
|
+
raw_is_sender = row[_col(is_sender_col)]
|
|
110
|
+
if raw_is_sender is not None:
|
|
111
|
+
is_self = bool(int(raw_is_sender))
|
|
112
|
+
|
|
113
|
+
# Skip non-text system messages
|
|
114
|
+
if msg_type == 10000: # system message
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Format non-text messages
|
|
118
|
+
if msg_type != 1:
|
|
119
|
+
content = _format_media(content, msg_type)
|
|
120
|
+
|
|
121
|
+
messages.append(Message(
|
|
122
|
+
id=row.get("localId", row.get("msgId", len(messages))),
|
|
123
|
+
timestamp=ts or datetime.now(),
|
|
124
|
+
sender_name=my_name if is_self else contact_name,
|
|
125
|
+
content=content,
|
|
126
|
+
msg_type=msg_type,
|
|
127
|
+
is_self=is_self,
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
if not messages:
|
|
131
|
+
print(f"⚠ No messages found for contact '{contact_name}' in table '{table}'.")
|
|
132
|
+
|
|
133
|
+
return Conversation(contact_name=contact_name, messages=messages, my_name=my_name)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _format_media(content: str, msg_type: int) -> str:
|
|
137
|
+
"""Replace binary/media placeholders with readable labels."""
|
|
138
|
+
type_labels = {
|
|
139
|
+
3: "[图片]",
|
|
140
|
+
34: "[语音]",
|
|
141
|
+
43: "[视频]",
|
|
142
|
+
47: "[表情]",
|
|
143
|
+
49: "[链接/文件]",
|
|
144
|
+
10000: "[系统消息]",
|
|
145
|
+
10002: "[群系统消息]",
|
|
146
|
+
}
|
|
147
|
+
label = type_labels.get(msg_type, f"[消息类型:{msg_type}]")
|
|
148
|
+
|
|
149
|
+
# Try to extract useful text from XML content
|
|
150
|
+
if msg_type == 49 and content:
|
|
151
|
+
title = re.search(r"<title>(.*?)</title>", content)
|
|
152
|
+
if title:
|
|
153
|
+
label = f"[链接] {title.group(1)}"
|
|
154
|
+
desc = re.search(r"<des>(.*?)</des>", content)
|
|
155
|
+
if desc:
|
|
156
|
+
label += f"\n{desc.group(1)}"
|
|
157
|
+
|
|
158
|
+
return f"{label}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── CSV 导出 ─────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def parse_csv(
|
|
164
|
+
csv_path: str | Path,
|
|
165
|
+
contact_name: str,
|
|
166
|
+
my_name: str = "Me",
|
|
167
|
+
*,
|
|
168
|
+
encoding: str = "utf-8-sig",
|
|
169
|
+
) -> Conversation:
|
|
170
|
+
"""
|
|
171
|
+
Parse a CSV export with columns: timestamp, sender, content[, type].
|
|
172
|
+
|
|
173
|
+
The CSV can be exported by tools like 微信聊天记录导出 or similar.
|
|
174
|
+
"""
|
|
175
|
+
csv_path = Path(csv_path)
|
|
176
|
+
if not csv_path.exists():
|
|
177
|
+
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
178
|
+
|
|
179
|
+
messages: list[Message] = []
|
|
180
|
+
with open(csv_path, encoding=encoding, newline="") as f:
|
|
181
|
+
reader = csv.DictReader(f)
|
|
182
|
+
for i, row in enumerate(reader):
|
|
183
|
+
# try common column names
|
|
184
|
+
ts_str = row.get("timestamp") or row.get("time") or row.get("CreateTime") or ""
|
|
185
|
+
sender = row.get("sender") or row.get("talker") or row.get("StrTalker") or ""
|
|
186
|
+
content = row.get("content") or row.get("message") or row.get("StrContent") or ""
|
|
187
|
+
msg_type = int(row.get("type") or row.get("Type") or 1)
|
|
188
|
+
|
|
189
|
+
ts = _try_parse_ts(ts_str)
|
|
190
|
+
if ts is None:
|
|
191
|
+
try:
|
|
192
|
+
ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
|
|
193
|
+
except ValueError:
|
|
194
|
+
ts = datetime.now()
|
|
195
|
+
|
|
196
|
+
is_self = sender.strip().lower() in (my_name.lower(), "me", "1", "true")
|
|
197
|
+
if is_self:
|
|
198
|
+
display_sender = my_name
|
|
199
|
+
else:
|
|
200
|
+
display_sender = sender.strip()
|
|
201
|
+
|
|
202
|
+
content = content or ""
|
|
203
|
+
if msg_type != 1:
|
|
204
|
+
content = _format_media(content, msg_type)
|
|
205
|
+
|
|
206
|
+
messages.append(Message(
|
|
207
|
+
id=i,
|
|
208
|
+
timestamp=ts,
|
|
209
|
+
sender_name=display_sender,
|
|
210
|
+
content=content,
|
|
211
|
+
msg_type=msg_type,
|
|
212
|
+
is_self=is_self,
|
|
213
|
+
))
|
|
214
|
+
|
|
215
|
+
return Conversation(contact_name=contact_name, messages=messages, my_name=my_name)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── JSON 导出 ─────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def parse_json(
|
|
221
|
+
json_path: str | Path,
|
|
222
|
+
contact_name: str,
|
|
223
|
+
my_name: str = "Me",
|
|
224
|
+
*,
|
|
225
|
+
encoding: str = "utf-8",
|
|
226
|
+
) -> Conversation:
|
|
227
|
+
"""
|
|
228
|
+
Parse a JSON export (list of message objects).
|
|
229
|
+
|
|
230
|
+
Each object should have: timestamp (or time), sender (or talker), content (or message).
|
|
231
|
+
"""
|
|
232
|
+
json_path = Path(json_path)
|
|
233
|
+
if not json_path.exists():
|
|
234
|
+
raise FileNotFoundError(f"JSON file not found: {json_path}")
|
|
235
|
+
|
|
236
|
+
data = json.loads(json_path.read_text(encoding=encoding))
|
|
237
|
+
if isinstance(data, dict):
|
|
238
|
+
data = data.get("messages") or data.get("data") or []
|
|
239
|
+
|
|
240
|
+
messages: list[Message] = []
|
|
241
|
+
for i, obj in enumerate(data):
|
|
242
|
+
ts_str = obj.get("timestamp") or obj.get("time") or obj.get("CreateTime") or ""
|
|
243
|
+
sender = obj.get("sender") or obj.get("talker") or obj.get("StrTalker") or ""
|
|
244
|
+
content = obj.get("content") or obj.get("message") or obj.get("StrContent") or ""
|
|
245
|
+
msg_type = int(obj.get("type") or obj.get("Type") or 1)
|
|
246
|
+
|
|
247
|
+
ts = _try_parse_ts(ts_str)
|
|
248
|
+
if ts is None:
|
|
249
|
+
try:
|
|
250
|
+
ts = datetime.strptime(str(ts_str), "%Y-%m-%d %H:%M:%S")
|
|
251
|
+
except ValueError:
|
|
252
|
+
ts = datetime.now()
|
|
253
|
+
|
|
254
|
+
is_self = str(sender).strip().lower() in (my_name.lower(), "me", "1", "true")
|
|
255
|
+
display_sender = my_name if is_self else str(sender).strip()
|
|
256
|
+
|
|
257
|
+
if msg_type != 1:
|
|
258
|
+
content = _format_media(content, msg_type)
|
|
259
|
+
|
|
260
|
+
messages.append(Message(
|
|
261
|
+
id=i,
|
|
262
|
+
timestamp=ts,
|
|
263
|
+
sender_name=display_sender,
|
|
264
|
+
content=content,
|
|
265
|
+
msg_type=msg_type,
|
|
266
|
+
is_self=is_self,
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
return Conversation(contact_name=contact_name, messages=messages, my_name=my_name)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── Auto-detect ──────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
def auto_parse(
|
|
275
|
+
path: str | Path,
|
|
276
|
+
contact_name: str,
|
|
277
|
+
my_name: str = "Me",
|
|
278
|
+
**kwargs,
|
|
279
|
+
) -> Conversation:
|
|
280
|
+
"""Auto-detect file type and parse accordingly."""
|
|
281
|
+
path = Path(path)
|
|
282
|
+
suffix = path.suffix.lower()
|
|
283
|
+
|
|
284
|
+
if suffix in (".db", ".sqlite", ".sqlite3", ""):
|
|
285
|
+
return parse_db(path, contact_name, my_name, **kwargs)
|
|
286
|
+
elif suffix == ".csv":
|
|
287
|
+
return parse_csv(path, contact_name, my_name, **kwargs)
|
|
288
|
+
elif suffix == ".json":
|
|
289
|
+
return parse_json(path, contact_name, my_name, **kwargs)
|
|
290
|
+
else:
|
|
291
|
+
# try as db first
|
|
292
|
+
return parse_db(path, contact_name, my_name, **kwargs)
|
daochu/templates.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""HTML template for WeChat conversation export."""
|
|
2
|
+
|
|
3
|
+
HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
4
|
+
<html lang="zh-CN">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="UTF-8">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
8
|
+
<title>与 {{ conv.contact_name }} 的微信聊天记录</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
--bg: #ededed;
|
|
12
|
+
--card-bg: #ffffff;
|
|
13
|
+
--bubble-self: #95ec69;
|
|
14
|
+
--bubble-other: #ffffff;
|
|
15
|
+
--text: #111;
|
|
16
|
+
--muted: #999;
|
|
17
|
+
--border: #d9d9d9;
|
|
18
|
+
--date-bg: #dcdcdc;
|
|
19
|
+
}
|
|
20
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
21
|
+
body {
|
|
22
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
|
23
|
+
"Microsoft YaHei", "Helvetica Neue", sans-serif;
|
|
24
|
+
background: var(--bg);
|
|
25
|
+
color: var(--text);
|
|
26
|
+
line-height: 1.6;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ── header ── */
|
|
30
|
+
.header {
|
|
31
|
+
background: var(--card-bg);
|
|
32
|
+
padding: 32px 24px 24px;
|
|
33
|
+
text-align: center;
|
|
34
|
+
border-bottom: 1px solid var(--border);
|
|
35
|
+
position: sticky;
|
|
36
|
+
top: 0;
|
|
37
|
+
z-index: 10;
|
|
38
|
+
box-shadow: 0 1px 6px rgba(0,0,0,.06);
|
|
39
|
+
}
|
|
40
|
+
.header h1 { font-size: 1.4rem; font-weight: 600; margin-bottom: 4px; }
|
|
41
|
+
.header .meta { font-size: 0.85rem; color: var(--muted); }
|
|
42
|
+
|
|
43
|
+
/* ── container ── */
|
|
44
|
+
.container {
|
|
45
|
+
max-width: 680px;
|
|
46
|
+
margin: 0 auto;
|
|
47
|
+
padding: 16px 12px 48px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ── date divider ── */
|
|
51
|
+
.date-divider {
|
|
52
|
+
text-align: center;
|
|
53
|
+
margin: 20px 0 12px;
|
|
54
|
+
}
|
|
55
|
+
.date-divider span {
|
|
56
|
+
display: inline-block;
|
|
57
|
+
background: var(--date-bg);
|
|
58
|
+
color: #666;
|
|
59
|
+
font-size: 0.75rem;
|
|
60
|
+
padding: 3px 12px;
|
|
61
|
+
border-radius: 3px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ── message row ── */
|
|
65
|
+
.msg-row {
|
|
66
|
+
display: flex;
|
|
67
|
+
margin-bottom: 12px;
|
|
68
|
+
padding: 0 8px;
|
|
69
|
+
}
|
|
70
|
+
.msg-row.self { justify-content: flex-end; }
|
|
71
|
+
.msg-row.other { justify-content: flex-start; }
|
|
72
|
+
|
|
73
|
+
/* ── bubble ── */
|
|
74
|
+
.bubble {
|
|
75
|
+
max-width: 72%;
|
|
76
|
+
padding: 10px 14px;
|
|
77
|
+
border-radius: 8px;
|
|
78
|
+
font-size: 0.95rem;
|
|
79
|
+
word-break: break-word;
|
|
80
|
+
white-space: pre-wrap;
|
|
81
|
+
position: relative;
|
|
82
|
+
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
|
83
|
+
}
|
|
84
|
+
.self .bubble {
|
|
85
|
+
background: var(--bubble-self);
|
|
86
|
+
border-top-right-radius: 2px;
|
|
87
|
+
}
|
|
88
|
+
.other .bubble {
|
|
89
|
+
background: var(--bubble-other);
|
|
90
|
+
border: 1px solid var(--border);
|
|
91
|
+
border-top-left-radius: 2px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ── sender label ── */
|
|
95
|
+
.sender-label {
|
|
96
|
+
font-size: 0.7rem;
|
|
97
|
+
color: var(--muted);
|
|
98
|
+
margin-bottom: 2px;
|
|
99
|
+
padding: 0 4px;
|
|
100
|
+
}
|
|
101
|
+
.self .sender-label { text-align: right; }
|
|
102
|
+
|
|
103
|
+
/* ── time ── */
|
|
104
|
+
.time-tag {
|
|
105
|
+
font-size: 0.65rem;
|
|
106
|
+
color: #b0b0b0;
|
|
107
|
+
margin-top: 4px;
|
|
108
|
+
text-align: right;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ── media tag ── */
|
|
112
|
+
.media-tag {
|
|
113
|
+
color: #5865a0;
|
|
114
|
+
font-style: italic;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── footer ── */
|
|
118
|
+
.footer {
|
|
119
|
+
text-align: center;
|
|
120
|
+
padding: 24px;
|
|
121
|
+
color: var(--muted);
|
|
122
|
+
font-size: 0.78rem;
|
|
123
|
+
}
|
|
124
|
+
.footer a { color: var(--muted); }
|
|
125
|
+
|
|
126
|
+
/* ── stats bar ── */
|
|
127
|
+
.stats {
|
|
128
|
+
display: flex;
|
|
129
|
+
justify-content: center;
|
|
130
|
+
gap: 18px;
|
|
131
|
+
flex-wrap: wrap;
|
|
132
|
+
margin-top: 8px;
|
|
133
|
+
font-size: 0.8rem;
|
|
134
|
+
color: var(--muted);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@media (max-width: 480px) {
|
|
138
|
+
.container { padding: 8px 4px 40px; }
|
|
139
|
+
.bubble { max-width: 85%; font-size: 0.9rem; }
|
|
140
|
+
.header { padding: 20px 12px 16px; }
|
|
141
|
+
}
|
|
142
|
+
</style>
|
|
143
|
+
</head>
|
|
144
|
+
<body>
|
|
145
|
+
|
|
146
|
+
<div class="header">
|
|
147
|
+
<h1>💬 {{ conv.contact_name }}</h1>
|
|
148
|
+
<div class="meta">微信聊天记录</div>
|
|
149
|
+
<div class="stats">
|
|
150
|
+
{% if conv.messages %}
|
|
151
|
+
<span>📅 {{ conv.messages[0].date_str }} — {{ conv.messages[-1].date_str }}</span>
|
|
152
|
+
<span>📝 {{ conv.message_count }} 条消息</span>
|
|
153
|
+
{% endif %}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div class="container">
|
|
158
|
+
{% for date, msgs in conv.group_by_date() %}
|
|
159
|
+
<div class="date-divider"><span>{{ date }}</span></div>
|
|
160
|
+
{% for m in msgs %}
|
|
161
|
+
<div class="msg-row {{ 'self' if m.is_self else 'other' }}">
|
|
162
|
+
<div>
|
|
163
|
+
{% if not m.is_self %}
|
|
164
|
+
<div class="sender-label">{{ m.sender_name }}</div>
|
|
165
|
+
{% endif %}
|
|
166
|
+
<div class="bubble">
|
|
167
|
+
<span class="{{ 'media-tag' if m.msg_type != 1 else '' }}">{{ m.content }}</span>
|
|
168
|
+
<div class="time-tag">{{ m.short_time }}</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
{% endfor %}
|
|
173
|
+
{% endfor %}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div class="footer">
|
|
177
|
+
<p>由 <a href="https://pypi.org/project/daochu/">DaoChu</a> 导出 · {{ now }}</p>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
</body>
|
|
181
|
+
</html>"""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def render_html(conv, now_str: str = "") -> str:
|
|
185
|
+
"""Render a Conversation to a complete HTML page."""
|
|
186
|
+
from jinja2 import Template
|
|
187
|
+
from datetime import datetime
|
|
188
|
+
|
|
189
|
+
tpl = Template(HTML_TEMPLATE)
|
|
190
|
+
return tpl.render(
|
|
191
|
+
conv=conv,
|
|
192
|
+
now=now_str or datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
193
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: daochu
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 导出微信聊天记录为 HTML / Markdown — Export WeChat conversations to HTML or Markdown
|
|
5
|
+
Author-email: DaoChu Contributors <daochu@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/daochu/daochu
|
|
8
|
+
Project-URL: Repository, https://github.com/daochu/daochu
|
|
9
|
+
Keywords: wechat,export,chat,html,markdown,backup
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Communications :: Chat
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: click>=8.0
|
|
23
|
+
Requires-Dist: jinja2>=3.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# DaoChu(导出)
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/daochu/)
|
|
32
|
+
[](https://pypi.org/project/daochu/)
|
|
33
|
+
[](https://opensource.org/licenses/MIT)
|
|
34
|
+
|
|
35
|
+
**DaoChu**(导出)是一个 Python 命令行工具,能将微信聊天记录导出为精美的 **HTML** 页面或 **Markdown** 文件。
|
|
36
|
+
|
|
37
|
+
> 支持解密后的微信 SQLite 数据库、CSV 导出、JSON 导出三种数据源。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## ✨ 特性
|
|
42
|
+
|
|
43
|
+
- 🎨 **精美 HTML 输出** — 仿微信气泡样式,带日期分组、响应式设计
|
|
44
|
+
- 📝 **Markdown 输出** — 纯文本,适合归档或导入笔记工具
|
|
45
|
+
- 🔍 **自动联系人发现** — `list-contacts` 命令快速查看所有联系人
|
|
46
|
+
- 🧠 **智能解析** — 自动识别文件类型(.db / .csv / .json)
|
|
47
|
+
- 🖼️ **媒体标注** — 图片、语音、视频、链接等自动标注为可读文本
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 📦 安装
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install daochu
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
要求 Python ≥ 3.10。
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 🚀 快速开始
|
|
62
|
+
|
|
63
|
+
### 1. 列出联系人
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
daochu list-contacts /path/to/decrypted/MSG.db
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2. 导出聊天记录
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# 导出为 HTML(默认)
|
|
73
|
+
daochu export MSG.db "wxid_abc123"
|
|
74
|
+
|
|
75
|
+
# 导出为 Markdown
|
|
76
|
+
daochu export MSG.db "wxid_abc123" -f md
|
|
77
|
+
|
|
78
|
+
# 指定输出路径
|
|
79
|
+
daochu export MSG.db "wxid_abc123" -o ~/Desktop/chat.html
|
|
80
|
+
|
|
81
|
+
# 自定义自己的显示名
|
|
82
|
+
daochu export MSG.db "wxid_abc123" --me "小明"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 📂 数据源说明
|
|
88
|
+
|
|
89
|
+
| 类型 | 说明 |
|
|
90
|
+
|------|------|
|
|
91
|
+
| **SQLite (.db)** | 解密后的微信 Msg 数据库,最常见的方式 |
|
|
92
|
+
| **CSV (.csv)** | 从第三方工具导出的 CSV,需含 timestamp/sender/content 列 |
|
|
93
|
+
| **JSON (.json)** | 消息对象数组,格式 `[{timestamp, sender, content}]` |
|
|
94
|
+
|
|
95
|
+
> **如何获取解密数据库?** 微信数据库默认加密存储在 `Documents/WeChat Files/<wxid>/Msg/` 下。可使用开源工具解密后配合 DaoChu 使用。
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 🖥️ 命令行参考
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Usage: daochu [OPTIONS] COMMAND [ARGS]...
|
|
103
|
+
|
|
104
|
+
Commands:
|
|
105
|
+
export 导出与某个联系人的全部聊天记录
|
|
106
|
+
list-contacts 列出数据库中的所有联系人
|
|
107
|
+
|
|
108
|
+
export 选项:
|
|
109
|
+
-o, --output PATH 输出目录或文件路径
|
|
110
|
+
-f, --format [html|md] 输出格式
|
|
111
|
+
--me TEXT 自己的显示名称
|
|
112
|
+
--source-type [auto|db|csv|json]
|
|
113
|
+
--table TEXT 数据库表名
|
|
114
|
+
--encoding TEXT 文件编码
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 🐍 Python API
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from daochu.parser import parse_db
|
|
123
|
+
from daochu.exporter import export
|
|
124
|
+
|
|
125
|
+
conv = parse_db("MSG0.db", "wxid_abc123", my_name="我")
|
|
126
|
+
export(conv, "output.html", fmt="html")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 🖼️ 效果预览
|
|
132
|
+
|
|
133
|
+
HTML 输出效果:
|
|
134
|
+
|
|
135
|
+
- 顶部:联系人名称、日期范围、消息总数
|
|
136
|
+
- 每一条消息:微信风格气泡,自己的消息靠右显示为绿色
|
|
137
|
+
- 日期分组:按天自动插入日期分隔线
|
|
138
|
+
- 移动端适配:响应式设计,手机上也能流畅阅读
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 📄 License
|
|
143
|
+
|
|
144
|
+
MIT © DaoChu Contributors
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
daochu/__init__.py,sha256=6yJxcAoJsxKQN9Y3E0PrctzJj7I3myK_M8zML1x-ctg,432
|
|
2
|
+
daochu/cli.py,sha256=44fPMRpSTIssLuQAL1COWjtSoCOCmlUv2sx-uTOaZzI,4273
|
|
3
|
+
daochu/exporter.py,sha256=bxEjGCv9jUDZbJC-7nSjgWl4zQ43NCdJsaRFtBt8cZ4,2704
|
|
4
|
+
daochu/models.py,sha256=UpNCJNdDhghEDGCTl0SyRHrlfvYLIiM-rNmoL1oxivU,1714
|
|
5
|
+
daochu/parser.py,sha256=TQl3_5pMcjXKeKQJZ4FPQlF5tjZ2I0YI9oE07cMseBo,9926
|
|
6
|
+
daochu/templates.py,sha256=H7KK15IlE0WFh-K2o2hS9tnAZm9_g-AYvXtkES0rTfc,4613
|
|
7
|
+
daochu-0.1.0.dist-info/licenses/LICENSE,sha256=Ry3TWNrzj22_Qkohgb65cS2jtac_h7k1BplBCMHGfXE,1076
|
|
8
|
+
daochu-0.1.0.dist-info/METADATA,sha256=d8hxOwWtVFstkzEGS-lwFBHKO9wNAG5WzgAErtJ6qiY,4152
|
|
9
|
+
daochu-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
daochu-0.1.0.dist-info/entry_points.txt,sha256=ourEDdHbDQvHmws4DWvtgqZGEgJ9eWHVEu9qlkLFWGY,43
|
|
11
|
+
daochu-0.1.0.dist-info/top_level.txt,sha256=ecD1GHxY170ailpyEm0Qyd1sHj1pKPCgv6lfNMzT82M,7
|
|
12
|
+
daochu-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 DaoChu Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
daochu
|