name: wechat-web-viewer description: Flask web app for browsing WeChat messages with article cards, image/voice media, video号 finder cards, daily report generation (skill-driven via Hermes CLI subprocess), and sending report images to WeChat contacts.
category: wechat
WeChat Web Viewer
When to Use
- Building, modifying, or debugging the WeChat message viewer web app at
~/wechat-viewer/ - Adding/changing message type handling, media serving, daily report generation
- Fixing WeChat content parsing (XML, CDATA, sender prefixes, image paths)
- Understanding the skill-driven subprocess architecture pattern
Design Principle: Skill-driven, not hardcoded Python
User explicit requirement: "应该调用 Hermes 子任务来完成。不是写死 Python 代码,未来 skill 变化时没法迭代。"
Two operations delegate to Hermes CLI subprocesses with skills:
- Report generation:
hermes chat -s wechat-group-digest --yolo -t terminal,file,web - Image generation:
hermes chat -s gpt-image-gen --yolo -t terminal,file,web
Image sending does NOT use hermes subprocess — send_wechat_image is an MCP tool inaccessible via -t terminal. Instead, Flask directly imports send_image_via_hook() from ~/.hermes/event_bus/wechat_hook_send.py.
Article summarize/learn also use Hermes CLI subprocesses:
POST /api/articles/summarize→hermes chat -q "fetch + extract key points" --yolo -t terminal,webPOST /api/articles/learn→hermes chat -s learn-and-build --yolo -t terminal,web,file- Results saved to
daily_reportstable withchatroom_id="article"
Flask only does: task file creation, subprocess spawning in daemon thread, result reading, SQLite persistence. All intelligence lives in skills.
Architecture
~/wechat-viewer/
├── config.py # DB paths, SQLCipher key, Flask config (DEBUG=False)
├── wechat_db.py # SQLCipher read layer (su + sqlcipher CLI)
├── local_db.py # Local SQLite: daily_reports, report_images, article_queue, image_analysis, chat_summaries
├── app.py # Flask routes + message parser + media proxy + Hermes subprocess spawn
├── restart.sh # Kill old + fuser -k 5000 + start fresh
├── templates/
│ ├── base.html # Nav tabs (消息浏览/数据中心/日报列表/日报图片)
│ ├── index.html # Session list with 📋 button on groups → opens chat page
│ ├── chat.html # Chat: newest-first infinite scroll, 📋 generate-report, 📝/🔧/🖼 on article cards
│ ├── circle.html # Data center
│ ├── reports.html # Report list + 生成图片 + delete buttons
│ ├── report_detail.html # Markdown viewer
│ └── report_images.html # Image gallery with 发送微信 + delete buttons
Navigation Tabs
| Tab | Route | Function |
|---|---|---|
| 消息浏览 | / |
Session list → /chat/<talker> |
| 文章列表 | /articles |
All articles deduplicated, search/filter/sort, 📝摘要 + 🔧学习 buttons |
| 日报列表 | /reports |
Three-type report list (群日报/文章摘要/学习记录), filterable, 生成图片 for ALL types |
| 日报图片 | /report-images |
Image gallery with modal preview + 发送微信 button |
Article Actions (2026-07-08)
Each article card in /articles and /chat/<talker> has three buttons:
| Button | API | Hermes CLI | Skill | Timeout |
|---|---|---|---|---|
| 📝 摘要 | POST /api/articles/summarize |
hermes chat -q "fetch + extract" --yolo -t terminal,web,file |
none | 180s |
| 🔧 学习 | POST /api/articles/learn |
hermes chat -s learn-and-build --yolo -t terminal,web,file |
learn-and-build | 300s |
| 🖼 图片 | POST /api/articles/generate-image |
hermes chat -s gpt-image-gen --yolo -t terminal,web,file |
gpt-image-gen | 300s |
Summary and learn results → daily_reports (chatroom_name=article title, report_date=summary/learn).
Image results → report_images (report_id=0 for article images).
Three Button Mobile Layout Pattern
CRITICAL for mobile usability: On all list pages (reports, report-images, articles), buttons must be:
- Placed at the bottom of each card, not cramped on the right side
flex:1withpadding: 10-12pxandborder-radius: 8pxfor thumb-friendly tapping- Each card's body content scrolls independently
- Page must override base.html's
body { overflow: hidden }:<style>body{overflow-y:auto!important;height:auto!important;}</style>
Article Actions (2026-07-08)
Each article in /articles has two buttons that spawn Hermes CLI subprocesses:
| Button | API | Hermes CLI | Skill | Timeout |
|---|---|---|---|---|
| 📝 摘要 | POST /api/articles/summarize |
hermes chat -q "fetch + extract" --yolo -t terminal,web,file |
none | 180s |
| 🔧 学习 | POST /api/articles/learn |
hermes chat -s learn-and-build --yolo -t terminal,web,file |
learn-and-build | 300s |
Both save results to daily_reports with chatroom_id="article" and report_date="summary" or "learn". Results appear in 日报列表.
Article Summary Dedup (2026-07-08)
Before spawning a Hermes subprocess, both routes check daily_reports for an existing record with the same dedup_key + report_date. If found, returns {status: "cached", content: "..."} immediately without spawning. Dedup key is extracted from the WeChat URL via _extract_dedup_key(url) — same biz|mid|idx|sn algorithm as article list dedup.
daily_reports table gained two columns:
dedup_key TEXT— thebiz|mid|idx|snkey for fast lookuparticle_url TEXT— original URL for reference
Index: CREATE INDEX idx_daily_reports_dedup ON daily_reports(dedup_key, report_date)
Batch check API (POST /api/articles/check-summaries):
- Accepts
{urls: [{url, title}, ...], type: "summary"|"learn"|""} - Returns
{results: {dedup_key: {id, content, ...}}, missing: [{url, title, dedup_key}]} - Used by: group digest workflow (pre-checks before re-summarizing), frontend (grays out cached buttons)
Frontend: On /articles page load, fetches /api/articles/check-summaries twice (summary + learn) for all articles. Renders cached articles with grayed-out ✅ 已有摘要 / ✅ 已学习 buttons. Info bar shows 共 N 篇(已有摘要 X / 已学习 Y).
_extract_dedup_key(url) helper (in app.py): extracts biz|mid|idx|sn from mp.weixin.qq.com URLs, returns raw URL for non-WeChat URLs.
local_db.py additions:
find_article_summary(dedup_key, report_type)— single lookupfind_article_summaries_batch(dedup_keys, report_type)— batch lookup, returns{dedup_key: {...}}
Critical: Both subprocesses need -t terminal,web,file — without file, the hermes subprocess cannot write the result file and the operation silently fails.
Article Deduplication
WeChat URLs have per-share tracking params (&chksm=..., &sharer_shareinfo=...) that make identical articles look different. Full-URL dedup fails → same article appears 3-7 times.
Fix (2026-07-08): Extract __biz, mid, idx, sn from the URL and use f"{biz}|{mid}|{idx}|{sn}" as the dedup key. These four params uniquely identify a WeChat article regardless of tracking garbage.
if "mp.weixin.qq.com" in url:
biz = re.search(r"__biz=([^&]+)", url)
mid = re.search(r"mid=(\d+)", url)
idx = re.search(r"idx=(\d+)", url)
sn = re.search(r"sn=([a-f0-9]+)", url)
if biz and mid and idx and sn:
dedup_key = f"{biz.group(1)}|{mid.group(1)}|{idx.group(1)}|{sn.group(1)}"
Store canonical URL separately: url_groups[dedup_key]["url"] = url (first-seen URL wins).
Article List (文章列表)
/articles — 2026-07-08 added, replacing 数据中心 tab.
API (/api/articles): Two-pass: first groups by dedup key (see above), collecting talkers per article; second resolves talker → display name via wechat_db.get_talker_info(). Returns {title, url, description, createTime, shareCount, groups: [names...]}.
Frontend: Search by title, filter by group (dropdown), sort by time↕ or hotness↕. All client-side after initial load.
Deduplication pattern: Two-pass algorithm — first pass builds {dedup_key: {title, desc, createTime, url, talkers: set()}}, second pass resolves talker → display name, sorts by createTime DESC.
Report List: Three-Type System (2026-07-08)
The /reports page now shows three report types with color-coded badges:
| Type | badge | filter button | report_date value | generate-image button |
|---|---|---|---|---|
| 群日报 | 群日报 (#2c3e50) |
群日报 | date like 2026-07-07 |
✅ |
| 文章摘要 | 文章摘要 (#3498db) |
文章摘要 | "summary" |
✅ |
| 学习记录 | 学习记录 (#9b59b6) |
学习记录 | "learn" |
✅ |
All three types have the 生成图片 button — user explicitly required this (2026-07-08).
POST /api/reports/generate {chatroom_id, date} spawns Hermes subprocess with wechat-group-digest skill. Returns immediately with {status: "started", task_id}. Frontend polls /api/reports/generate/status every 3s until content appears.
Sub-agent does: MCP query → parse messages → fetch article URLs → LLM topic summarization → Markdown output.
Report Image Generation
POST /api/reports/<id>/generate-image spawns Hermes subprocess with gpt-image-gen skill.
Sub-agent does: read report Markdown → compose vintage copperplate-style Chinese prompt → GPT Image 2 API (wanjiedata.com) → base64 decode → save PNG.
Image style (2026-07-08, user-specified): 19世纪黑白铜版画蚀刻风,泛黄做旧牛皮纸折痕毛边污渍撕裂,顶部粗黑体刊头=群名仅标日期不写版号ISSN期号,中间大幅复古工业插画,右侧竖栏市场简报文字框,底部分栏文字板块,油墨印刷颗粒质感细腻素描排线,严肃商务科技AI主题,黑白单色调做旧滤镜无多余色彩,高细节8K。
GPT Image 2 API details: model "GPT Image 2" (with spaces), never include responseFormat, timeout >= 120s.
Sending Images to WeChat
POST /api/report-image/<id>/send {to: wxid} copies image to /sdcard/Pictures/ then calls send_image_via_hook(wxid=to, image_path=dest) directly — NOT via hermes subprocess.
Why direct call, not hermes subprocess: Hermes CLI spawned with -t terminal has no access to MCP tools. send_wechat_image is an MCP tool, not a terminal tool. Spawning hermes for this creates 60s+ overhead for a sub-second operation. The Flask app imports from wechat_hook_send import send_image_via_hook from ~/.hermes/event_bus/ and calls it in a daemon thread.
Frontend: "发送微信" button → modal with contact list from /api/sessions (sorted by latest message, searchable) → select → send.
JS Bug: closeSendModal() nulls currentImageId
If sendToEl() calls closeSendModal() (which sets currentImageId = null) before using currentImageId in the fetch() URL, the URL becomes /api/report-image/null/send → 404 HTML → r.json() throws "is not valid json". Always save to local var first: var id = currentImageId; closeSendModal(); fetch('/path/' + id).
Image viewing: modal, not new tab
Use onclick="showImg(url)" with a full-screen overlay modal. Avoid <a target="_blank"> — users need a back button equivalent (click-anywhere-to-close modal).
Message Type Reference
| type | meaning | parsed as |
|---|---|---|
| 1 | text | Strip sender:\n prefix |
| 3 | image | [图片] + thumbnail |
| 34 | voice | [语音] + audio file |
| 43 | video | [视频] |
| 47 | emoji | [表情] |
| 49 | article | Card (title, desc, url, thumbnail) |
| 754974769 | finder | Finder card (nickname, avatar, cover, video url, duration) |
| 822083633 | refermsg | Extract title from <refermsg> |
Media Path Resolution
Images (2026-07-08, corrected): WeChat stores BOTH thumbnails AND original images in image2/:
- Thumbnail:
image2/{hash[:2]}/{hash[2:4]}/th_<hash>(2-12KB, no extension) - Original:
image2/{hash[:2]}/{hash[2:4]}/<hash>.jpg(10-123KB, with.jpgextension) - Same hash is used for both — the
.jpgfile is the cached full-resolution version - Not all images have cached originals; some only have thumbnails
_resolve_image_path() search priority (in app.py):
<hash>.jpg— cached original<hash>.png— PNG variant<hash>— no-extension fallbackth_<hash>— thumbnail (lowest priority)
The old claim "full-size images are CDN-only" was wrong — WeChat caches originals locally when you view them in the app. See references/wechat-image-cdn-investigation.md for the discovery notes.
Chat template (chat.html): inline images use /media/image (auto-selects best available). Clicking opens modal with /media/image/full (explicit full-resolution attempt, falls back to thumbnail).
Voice: imgPath = raw hash → use su -c "find voice2 -name '*<hash>*'" to locate msg_<hash>.amr (directory structure is NOT hash-prefix based).
Key Pitfalls
SQLCipher output
sqlcipher .mode json prepends ok\n before JSON. Strip with output[2:].strip().
WeChat file permissions
Termux user cannot stat WeChat /data/data/ files. All file ops need su -c. Copy to /tmp before serving via send_file().
Group names
chatroom.displayname = member list (e.g. "丁鹏、火星人..."). Use COALESCE(NULLIF(r.nickname, ''), c.displayname) to get actual custom group name.
CDATA wrapping
Strip at parse entry: re.sub(r"<!\\[CDATA\\[(.*?)\\]\\]>", r"\\1", content, flags=re.DOTALL)
CRITICAL: Flask route placement
@app.route() decorators MUST appear BEFORE if __name__ == "__main__": block. When running python3 app.py, app.run() blocks at import time — any @app.route() decorators defined after app.run() are NEVER evaluated, returning 404. Verify: python3 -c "from app import app; [print(r) for r in app.url_map.iter_rules()]"
Multi-line f-string SyntaxError
Hermes subprocess prompts use f-strings. Literal newlines inside f-strings cause SyntaxError. Always use \\n escape: f"line1\\nline2" NOT f"line1\nline2".
JavaScript template strings
Literal newlines inside JS single-quoted strings in Jinja templates break the entire page — the page spins forever. Always use \\n escape.
CRITICAL: Avoid JS template literals (backticks) nested inside Jinja template rendering with ${} interpolation. The escaping cascade is fragile and debug-resistant. Prefer plain string concatenation: '<div onclick="fn(' + val + ')">' over backtick templates.
closeSendModal() nulls currentImageId
If a function calls closeSendModal() (which sets currentImageId = null) before using currentImageId in a fetch() URL, the URL becomes /api/report-image/null/send → 404 HTML → r.json() throws "is not valid json". Always save to local var first: var id = currentImageId; closeSendModal(); fetch('/path/' + id).
body { overflow: hidden } breaks scrollable pages
base.html sets body { overflow: hidden; height: 100vh } for the sidebar layout. Pages that need scrolling (articles, reports, report-images) must override: <style>body { overflow-y: auto !important; height: auto !important; }</style> in the template body block.
NEVER blame browser cache first
When user reports "page not working", "spinning forever", or "404": verify backend FIRST. Check curl the API, python3 -c "import app" for syntax errors, grep rendered HTML. Only after server-side passes should client caching be considered. User was frustrated by repeated cache-blame for real code bugs.
Article dedup race condition
When two summarize/learn requests for the same article arrive simultaneously, both pass the dedup check before either hermes finishes and writes to daily_reports. This means the second request spawns a redundant hermes subprocess. The dedup only prevents re-processing after the first result is fully persisted. Acceptable for now — practical dedup savings come from cross-session reuse (group digests, next-day page loads), not from same-second double-clicks.
Server management
DEBUG = False(auto-reload creates notification storms from killed processes)- Template changes require
restart.sh restart.shflow:pkill -f 'python3 app.py'→fuser -k 5000/tcp→python3 app.py
CRITICAL: Never delete large blocks of app.py without verifying boundaries
When deleting code blocks from app.py, ALWAYS read the deletion range first with read_file(offset, limit) to confirm exactly what will be removed. A single misaligned deletion can remove hundreds of lines including ALL main routes (/reports, /articles, /api/sessions, etc.). If file becomes corrupted, check: python3 -c "import app; print(sum(1 for _ in app.app.url_map.iter_rules()))" — a healthy app.py has 29-30 routes. Anything less than 20 means routes were lost.
DB Tables
-- daily_reports: generated Markdown reports + article summaries/learn
daily_reports (
id, chatroom_id, chatroom_name, report_date, content,
msg_count, image_count, article_count,
dedup_key TEXT, -- 2026-07-08: biz|mid|idx|sn for article dedup
article_url TEXT, -- 2026-07-08: original URL for reference
created_at
)
-- Index: idx_daily_reports_dedup ON (dedup_key, report_date)
-- report_images: generated WSJ-style infographics
report_images (id, report_id, image_path, style, created_at)
-- existing tables
article_queue, image_analysis, chat_summaries, processed_messages
Hermes CLI Subprocess Pattern
See references/hermes-cli-subprocess-pattern.md for the complete reference on spawning Hermes from Flask.
Key flags:
| flag | purpose |
|------|---------|
| -q "prompt" | Non-interactive single query |
| -s skill | Preload skill(s) |
| --yolo | Skip confirmations |
| -t groups | Enable tool groups (terminal,file,web) |
| capture_output=True | Capture stdout/stderr |
| timeout=300 | 5-min timeout for long operations |
Do NOT use --quiet — it suppresses output generation. Use stdout as fallback when result files aren't written.
References
references/hermes-cli-subprocess-pattern.md— Complete guide for spawning Hermes CLI subprocesses from Flaskreferences/message-xml-patterns.md— WeChat message XML formats and parsingreferences/hermes-subprocess-pitfalls.md— Common failures and fixesreferences/frontend-js-pitfalls.md— JS bugs: closeSendModal before fetch, template literal escaping, image modals, route orderingreferences/article-test-script.md— Automated test: URL validity → summarize → learn-buildreferences/report-list-ui-patterns.md— Three report types, filtering, mobile button layoutscripts/test_articles.py— Rerunnable test script for article links + summarize + learn pipelinetemplates/restart.sh— Server restart script
LAN Access
Set HOST = "0.0.0.0" in config.py to allow Mac/other devices on same WiFi to access. Phone IP: ifconfig | grep wlan0 -A1 | grep inet. Access from Mac: http://<phone-ip>:5000。
references/wechat-image-cdn-investigation.md— Full-resolution image CDN investigation (2026-07-08): why only thumbnails are local, hex-encoded CDN params, viable download approachesreferences/article-test-script.md— Automated test script: URL validity → summarize → learn-build