---
name: we-mp-rss
description: Operate and debug the we-mp-rss (WeChat MP RSS) service — QR code login, auth flow, article fetching, Playwright vs HTTP auth, common pitfalls.
---

# We-MP-RSS Operations & Debugging

Trigger: user mentions we-mp-rss, rss.gdcjgk.net, 微信公众号订阅, QR code login not loading, article fetching broken, or any we-mp-rss service issue.

## Communication Style for This Skill

When the user says "把我当小白", "简单一点", "不太懂", or shows confusion:
- Use **simple analogies** first (e.g. DNS = 电话簿, Cloudflare Tunnel = 地下通道)
- Give **numbered step-by-step lists** with single actions per step
- **Don't** dump multi-command blocks — break them into individual steps with explanations
- **Don't** overcomplicate — always seek the simplest path before proposing complex multi-server solutions

## Architecture

Simple FastAPI server on port 8001 with SQLite DB. No MCP, no SSE.

**Domain:** `https://rss.gdcjgk.net` (prod).

Served via Cloudflare Tunnel `40f60a3f` → `localhost:8001` (systemd service `cloudflared-tunnel.service`).

**Fresh deployment:** See wecom-topic-assistant → `references/we-mp-rss-deploy.md`.

## Critical Fix: DB Write Lock Blocking
|----------|-------|-----------|-------------|
| **HTTP requests** | `WeChatAPI` (`driver/wx_api.py`) | Direct `requests` to `mp.weixin.qq.com` | ✅ Reliable — no browser needed |
| **Playwright browser** | `Wx` (`driver/wx.py`) | Headless Chromium via Playwright | ❌ Fragile — threading, browser deps |

The `/qr/code` endpoint originally used `WX_API.GetCode()` (Playwright). **Prefer `get_qr_code()` from `driver/wx_api.py`** (HTTP) instead.

## API Routing

- Base: `http://localhost:8001`
- API prefix: `/api/v1/wx/` (defined in `core/base.py` as `API_BASE = "/api/v1/wx"`)
- Login: `POST /api/v1/wx/auth/login` (form-urlencoded)
- QR code: `GET /api/v1/wx/auth/qr/code`
- QR status: `GET /api/v1/wx/auth/qr/status`
- AK/SK auth header: `Authorization: AK-SK <ak>:<sk>` (from `WE_MP_RSS_AK` / `WE_MP_RSS_SK` env vars)
- Bearer auth for user-facing endpoints

## QR Code Login Flow (HTTP approach)

1. `WeChat_api._force_release_lock()` — clear any stale lock
2. Delete old `static/wx_qrcode.png` if it exists
3. `get_qr_code(callback)` — does:
   a. GET `https://mp.weixin.qq.com/`
   b. POST `startlogin` → get UUID from JSON response body
   c. GET `scanloginqrcode?action=getqrcode&uuid=...` → save image to `static/wx_qrcode.png`
   d. Start background timer polling status every 2s
4. Frontend polls `/qr/status` → when status goes 0→4→6→1 = login success
5. On success: `_handle_login_success()` → extracts cookies, token, stores to Redis

## Critical Bug: UUID Extraction

The `start_login()` method tries `response.cookies.get('uuid')` — but the UUID comes in the **JSON response body**:
```json
{"base_resp":{"err_msg":"ok","ret":0},"uuid":"b90aef41..."}
```
Fix: parse `response.json()['uuid']` first, fallback to cookies/headers. Applied in `driver/wx_api.py`.

## Critical Bug: Browser Type (playwright_driver.py and wxarticle.py)

PlaywrightController() defaults to browser_type="webkit", but only Chromium is installed on China servers (webkit download blocked). Affects THREE places.

**Fix 1 — playwright_driver.py default (MANDATORY on new deploy):**

Change line 53 from "webkit" to "chromium":
```
# playwright_driver.py line 53 — BEFORE:
    browser_type: str = "webkit",
# AFTER:
    browser_type: str = "chromium",
```

Without this fix, every PlaywrightController() call without explicit browser_type fails (driver/wx.py lines 38/368/467). Error: Executable doesn't exist at .../webkit-*/pw_run.sh

**Fix 2 — wxarticle.py** already reads BROWSER_TYPE env var with chromium default. Ensure BROWSER_TYPE=chromium in .env and systemd unit.

**Fix 3 — QR code import conflict** (see new section below).

## Critical Bug: QR Code Import Conflict (auth.py)

auth.py line 22 imports WX_API from driver.base, but line 30 re-imports from driver.wx — the Playwright version overrides the HTTP version. Result: QR code generation tries to launch a browser in a thread, fails silently, and static/wx_qrcode.png is never created.

**Fix:** Replace `from driver.wx import WX_API` with the HTTP-based singleton:
```python
# auth.py line 30 — REMOVE:
from driver.wx import WX_API
# REPLACE WITH:
WX_API = __import__('driver.wx_api', fromlist=['WeChat_api']).WeChat_api
```

**Verification:** After restart, QR code returns image immediately (qr=True at 0s).

## Fresh Deployment Checklist

When deploying we-mp-rss from source on a new server:

1. **Stub message_task.py** — if the file is empty or missing, `web.py` import fails at startup:
   ```python
   from fastapi import APIRouter
   router = APIRouter(prefix="/wx/message_tasks", tags=["消息任务"])
   ```

2. **Init database** — `python main.py -init True` creates tables and default user

3. **Default credentials** — username=`$USER` (env USERNAME), password=`$PASSWORD` (env PASSWORD, defaults to `admin@123`). **本环境实际凭据：** 用户名 `gdcjgk`，密码 `admin888`（非默认值，是部署时通过 `PASSWORD=admin888` 环境变量设定的）

4. **Fix browser type** — change playwright_driver.py default from "webkit" to "chromium" (see Critical Bug section)

5. **Fix QR import** — change auth.py to use HTTP-based WX_API (see Critical Bug section)

6. **Systemd env vars** — ensure DB, PORT, ENABLE_JOB, BROWSER_TYPE, WE_MP_RSS_AK/SK are set

7. **Cloudflared ingress** — add `rss.gdcjgk.net → localhost:8001` to config.yml AND Cloudflare dashboard

`WeChatAPI.check_lock()` checks if `static/wx_qrcode.png` **exists** — treating the QR image itself as a lock. After first generation, all subsequent requests return "请勿重复运行".

**Fix in `/qr/code` endpoint:** always call `WeChat_api._force_release_lock()` and delete old QR file before generating a new one.

## Service Management

### Systemd (production — preferred)

Service installed at `/etc/systemd/system/we-mp-rss.service`. Real Redis runs as separate `redis-server.service`.

```bash
# Status
systemctl status we-mp-rss redis-server cloudflared-tunnel

# Restart (after config changes)
sudo systemctl daemon-reload
sudo systemctl restart we-mp-rss

# Logs
journalctl -u we-mp-rss -f
```

Key env vars in the unit file: `REDIS_SERVER_ENABLED=False` (use real Redis), `REDIS_URL=redis://127.0.0.1:6379/0`, `DB=sqlite://///home/ubuntu/project/we-mp-rss-data/db.db`.

Systemd service templates are available in the skill:
- [`templates/we-mp-rss.service`](templates/we-mp-rss.service) — we-mp-rss unit file
- [`templates/cloudflared-tunnel.service`](templates/cloudflared-tunnel.service) — Cloudflare tunnel unit file

### Manual start (dev/debug only)

```bash
cd ~/project/we-mp-rss-main && \
source venv/bin/activate && \
DB="sqlite://///home/ubuntu/project/we-mp-rss-data/db.db" \
ENABLE_JOB=True PORT=8001 BROWSER_TYPE=chromium \
SPAN_INTERVAL=300 SEND_CODE=False \
REDIS_SERVER_ENABLED=True REDIS_URL="redis://127.0.0.1:6379/0" \
python main.py -job True
```

Served via Cloudflare Tunnel at `https://rss.gdcjgk.net` → `localhost:8001`.

### Cloudflare Tunnel

The tunnel `icoach-tunnel` runs via systemd `cloudflared-tunnel.service` from `~/.cloudflared/config.yml`. Current ingress:

| Hostname | Service |
|---|---|
| `rss.gdcjgk.net` | `localhost:8001` | 北京 (we-mp-rss) |
| fallback | `http_status:404` |

Restart after config changes: `sudo systemctl restart cloudflared-tunnel`

### Adding a New Domain to the Tunnel (step-by-step)

When adding a new domain (e.g. `rss.gdcjgk.net`) that isn't already on Cloudflare:

1. **Add domain to Cloudflare**: dash.cloudflare.com → Add Site → Cloudflare auto-scans and imports all existing DNS records from the current DNS provider
2. **Change nameservers at registrar**: Update nameservers to Cloudflare's assigned ones (e.g. `pedro.ns.cloudflare.com`, `sydney.ns.cloudflare.com`)
3. **Add ingress rule**: Update `~/.cloudflared/config.yml` with new hostname → restart tunnel
4. **Add Public Hostname in Cloudflare Dashboard**: Zero Trust → Networks → Tunnels → icoach-tunnel → Configure → Public Hostname → Add: Subdomain=`rss`, Domain=`gdcjgk.net`, Type=HTTP, URL=`localhost:8001`
5. **Wait for SSL**: Cloudflare auto-issues edge certificate (2-5 min). HTTP works immediately; HTTPS once cert provisions.

The Cloudflare DNS auto-creates a "Tunnel" record, but this alone doesn't route traffic — step 4 (Public Hostname) is mandatory.

### Lighthouse (轻量应用服务器) Firewall Notes

This server is Tencent Cloud Lighthouse (`ins-jrstx6f5`, Beijing region). Key differences from CVM:
- Firewall is managed under **防火墙** tab (NOT 安全组)
- Lighthouse machines don't accept direct inbound connections on their public IP — all external access goes through Cloudflare Tunnel
- Firewall rules use dropdown for source (全选IPv4), not free-text IP input
- Source IP format: CIDR required (e.g. `47.120.3.199/32`)

## Redis Management

we-mp-rss uses Redis to store WeChat login tokens (cookies, session data). **If Redis is down, the entire service breaks silently**: QR login fails, article fetching stops, `login_status` returns false even if you were logged in before.

### Two Redis modes

| Mode | Config | Reliability | Notes |
|------|--------|-------------|-------|
| Built-in Python Redis | `REDIS_SERVER_ENABLED=True` (default) | ⭐⭐ Fragile | Runs in same process as we-mp-rss. Thread crash = silent data loss, no auto-recovery. |
| Real Redis (recommended) | `REDIS_SERVER_ENABLED=False` + `apt install redis-server` | ⭐⭐⭐⭐⭐ Reliable | Independent systemd service, survives we-mp-rss restarts. |

### Diagnosing Redis issues

**Symptoms of Redis failure:**
- Login works but `/qr/status` returns `login_status: false` even after scanning
- No new articles for days despite feeds being active
- AK/SK auth returns 401 unexpectedly
- `redis-cli ping` hangs or returns connection refused

**Quick health check:**
```bash
redis-cli ping                    # Should return PONG
sudo ss -tlnp | grep 6379         # Should show redis-server listening
systemctl is-active redis-server  # Should be active
```

### Recovery when Redis is down

1. If built-in Redis crashed: restart we-mp-rss (systemd does this automatically with `Restart=always`)
2. If using real Redis and it failed: `sudo systemctl restart redis-server`
3. After Redis recovery, **user must re-scan the QR code** — old WeChat sessions are lost when Redis data evaporates

**Startup ordering pitfall:** If `REDIS_SERVER_ENABLED=True` (built-in Redis) and `redis-server.service` also starts, they fight over port 6379. Winner is random — built-in Redis wins if it starts first, blocking real Redis. The systemd unit MUST set `REDIS_SERVER_ENABLED=False` when using real Redis. The we-mp-rss unit should also declare `After=redis-server.service` to ensure real Redis is ready before we-mp-rss connects.

## Login Status Verification

**Critical**: `WeChatAPI._islogin` is an **in-memory Python variable** that resets to `False` on every service restart. Never trust the API `/qr/status` endpoint alone after a restart — it will report `false` even when valid cookies/tokens exist in Redis.

**Correct way to check login** — read Redis directly:

```bash
redis-cli GET werss:login:status
# "1" = logged in, nil/empty = not logged in
```

Or use the health check script: `~/.hermes/scripts/check_werss_login.py`
Script details → see `references/health-check.md`

**Daily health check cron**: `cronjob eba217dfc155` runs at 9:00 CST daily, checks Redis login status, sends 企微群 webhook alert if expired.

## Cloudflare Tunnel Recovery

Tunnel config: `~/.cloudflared/config.yml` (only rss.gdcjgk.net — icoach decommissioned).

If `cert.pem` / `credentials.json` are lost (e.g., after Redis flush):
1. Get tunnel token from Cloudflare Zero Trust dashboard → Tunnels → icoach-tunnel
2. Create `~/.cloudflared/<tunnel-id>.json` with `{"AccountTag":"...","TunnelID":"...","TunnelSecret":"..."}`
3. Update `~/.cloudflared/config.yml` with the tunnel ID
4. `sudo systemctl restart cloudflared-tunnel`

## Service Management

Systemd services:
| Service | Port | Notes |
|---------|------|-------|
| `we-mp-rss.service` | 8001 | Main app |
| `redis-server.service` | 6379 | Real Redis (not built-in) |
| `cloudflared-tunnel.service` | — | Tunnel (rss.gdcjgk.net → :8001) |

**⚠️ systemd daemon-reload hangs** on this server. Avoid it; use `systemctl restart <service>` directly.

## Verification Commands

```bash
# Health check
curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/

# External access (via Cloudflare Tunnel)
curl -s -o /dev/null -w "%{http_code}" http://rss.gdcjgk.net/

# Login + get token
TOKEN=$(curl -s -X POST 'http://localhost:8001/api/v1/wx/auth/login' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'username=ubuntu&password=admin@123' | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])")

# Get QR code
curl -s "http://localhost:8001/api/v1/wx/auth/qr/code" -H "Authorization: Bearer $TOKEN"

**⚠️ Fresh deploy note**: After first install, run `python main.py -init True` to create database tables. Default web login: username=`$USER` (usually `ubuntu`), password=`admin@123`. If env vars `USERNAME`/`PASSWORD` were set during init, the credentials will differ — check the DB to recover them. **This deployment: `gdcjgk` / `admin888`** (verify with `sqlite3 ~/project/we-mp-rss-data/db.db "SELECT username FROM users"`).

### Recovering Lost Credentials

If the web login fails, discover the actual username and test passwords directly against the bcrypt hash in the database:

```bash
# 1. Find the username
cd ~/project/we-mp-rss-main && source venv/bin/activate && python3 -c "
import sqlite3
db = sqlite3.connect('/home/ubuntu/project/we-mp-rss-data/db.db')
for row in db.execute('SELECT username, password_hash FROM users'):
    print(f'username: {row[0]}')
    print(f'hash: {row[1]}')
"

# 2. Test candidate passwords against the hash
python3 -c "
import bcrypt
hash = '\$2b\$12\$...'  # from step 1
for pw in ['admin@123', 'admin888', 'the_username', 'gdcjgk']:
    if bcrypt.checkpw(pw.encode(), hash.encode()):
        print(f'CORRECT PASSWORD: {pw}')
        break
"
``` If `message_task.py` is empty, stub it with a minimal FastAPI router. See `wecom-topic-assistant` skill → `references/we-mp-rss-deploy.md` for full deployment guide.

## Pitfalls

- **Credentials NOT always `admin/admin@123`**: The init script (`init_sys.py`) reads `os.getenv("USERNAME", "admin")` / `os.getenv("PASSWORD", "admin@123")`. If env vars were set during `-init True`, the credentials differ. On this server: `gdcjgk` / `admin888` (NOT `gdcjgk`/`gdcjgk`). Always verify by reading the `users` table and testing passwords against the bcrypt hash.
- **Never test QR code in the browser first then tell user to refresh** — the first test sets the lock. Always test via curl + then have user try.
- The `HEAD /null` errors in logs are from the frontend's `axios.head()` polling — harmless, means the QR code file URL was null.
- If the process dies, the Redis token should persist, but the in-memory `WeChatAPI._islogin` is lost.
- **Redis silently dead = entire service broken**: No login, no article fetch, no error visible to user. Always verify with `redis-cli ping` before debugging anything else. The built-in Python Redis (`tools/redis_server.py`) runs in the same process and can crash without the main process detecting it. Prefer real Redis (`apt install redis-server`).

## Critical Bug: Wrong Article Fetch API Endpoint

`core/wx/wx.py` → `get_Articles()` calls the **wrong** WeChat API endpoint:

| | Broken (old) | Working (fix) |
|---|---|---|
| URL | `/cgi-bin/appmsgpublish` | `/cgi-bin/appmsg` |
| Params | `sub=list`, `sub_action=list_ex` | `action=list_ex`, `type=9` |
| Response key | `publish_page` | `app_msg_list` |

The `appmsgpublish` endpoint returns the draft/publish box — **always empty for mass-sent articles**. The `appmsg` endpoint returns the actual published article list.

**Manual verification** (when token is in Redis):
```python
from core.redis_client import redis_client
import json, requests
value = redis_client._client.get('werss:token:data')
token_data = json.loads(value)

resp = requests.get(
    "https://mp.weixin.qq.com/cgi-bin/appmsg",
    params={"action": "list_ex", "begin": 0, "count": 5,
            "fakeid": faker_id, "type": "9", "query": "",
            "token": token_data['token'], "lang": "zh_CN", "f": "json", "ajax": 1},
    headers={"Cookie": token_data['cookie']}
)
articles = resp.json().get('app_msg_list', [])
```

## 企微消息推送配置

文章抓取入库后**不会自动推送**到企微群 — 有两种推送方式：

### 方式一：外部脚本推送（当前部署使用）

脚本 `~/project/scripts/fetch_and_push.py` 由 Hermes cron job `94d230f73e7f`（春考选题-文章抓取）每 120 分钟调用。流程：用 web 登录获取 JWT → 触发公众号更新 → 取最新 10 篇 → POST 到群 webhook。**不依赖** we-mp-rss 内置的 message_tasks 表。

排查群推送问题应先查此 cron job，而非 message_tasks API。

### 方式二：内置消息任务（需手动配置）

### 创建推送任务

```bash
AUTH="AK-SK $AK:$SK"
BASE="http://localhost:8001/api/v1/wx"

# 1. 查看现有任务（返回 null = 无任务）
curl -s -H "Authorization: $AUTH" "$BASE/message_tasks"

# 2. 创建推送任务
# ⚠️ mps_id 必须是 [{"id":"MP_WXS_xxx"}] 数组-of-objects 格式，不是字符串数组！
# ⚠️ message_template 必须用 {{ variable }} 双花括号，引擎是 jinja2 风格
curl -s -X POST "$BASE/message_tasks" \
  -H "Authorization: $AUTH" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "企微群文章推送",
    "message_type": 0,
    "message_template": "📰 {{ feed.mp_name }} 更新\n{% for article in articles %}\n> **{{ article.title }}**\n> 🔗 {{ article.url }}\n{% endfor %}",
    "web_hook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REAL_KEY",
    "mps_id": "[{\"id\": \"MP_WXS_3886864470\"}, {\"id\": \"MP_WXS_3596295911\"}]",
    "cron_exp": "*/30 * * * *",
    "status": 1
  }'

# 3. ⚠️ 必须重载任务调度器，否则新建/修改的任务不会生效！
curl -s -X PUT -H "Authorization: $AUTH" "$BASE/message_tasks/job/fresh"

# 4. 手动触发一次验证推送
curl -s -H "Authorization: $AUTH" "$BASE/message_tasks/<task_id>/run?isTest=false"
```

### 消息推送配置的三个关键陷阱

| 陷阱 | 错误写法 | 正确写法 |
|------|---------|---------|
| **mps_id 格式** | `"[\"MP_WXS_xxx\"]"` (字符串数组) | `"[{\"id\": \"MP_WXS_xxx\"}]"` (对象数组) |
| **模板语法** | `{title}`, `{mp_name}` (单花括号) | `{{ article.title }}`, `{{ feed.mp_name }}` (双花括号) |
| **任务生效** | 创建后直接等 cron | 必须调用 `/job/fresh` 重载 |

**mps_id 陷阱的根因**：`get_feeds()` 代码是 `item["id"] for item in mps`，如果 mps_id 是字符串数组 `["MP_WXS_xxx"]`，则 `item["id"]` 报错 `"string indices must be integers, not 'str'"`。

**模板可用变量**（`message_type=0` 走 `send_message` 路径）：
- `{{ feed }}` — Feed 对象（`.mp_name`, `.mp_cover`, `.mp_intro` 等）
- `{{ articles }}` — Article 列表，需用 `{% for article in articles %}` 遍历
- `{{ article.title }}`, `{{ article.url }}`, `{{ article.description }}`, `{{ article.publish_time }}`

### 获取公众号 ID 列表

```bash
# 查询数据库直接获取
python3 -c "
import sqlite3
db = sqlite3.connect('/home/ubuntu/project/we-mp-rss-data/db.db')
for row in db.execute('SELECT id, mp_name FROM feeds'):
    print(f'{row[0]}  →  {row[1]}')
"
```

### 关键陷阱：消息任务只推"本次新抓取"的文章

we-mp-rss 的消息任务设计：**只推送本次抓取到的新文章**。如果文章已经被之前的定时抓取任务入库了，消息任务运行时会看到 `wx.articles` 为空（`UpdateArticle` 对已有文章返回 False），`web_hook` 直接 `return`，不发送任何消息。

**症状**：任务运行返回 "执行成功"，群里没有消息，`message_tasks_logs` 为空。

**临时解决**：直接读数据库手动推送今日文章：
```python
import sqlite3, requests
from datetime import datetime, timezone, timedelta

db = sqlite3.connect("/home/ubuntu/project/we-mp-rss-data/db.db")
beijing = timezone(timedelta(hours=8))
now = datetime.now(beijing)
today_start = int(datetime(now.year, now.month, now.day, tzinfo=beijing).timestamp())
today_end = today_start + 86400

articles = db.execute("""
    SELECT a.title, a.url, a.publish_time, f.mp_name 
    FROM articles a JOIN feeds f ON a.mp_id = f.id 
    WHERE a.publish_time >= ? AND a.publish_time < ?
""", (today_start, today_end)).fetchall()

# Group by mp_name, format as markdown, POST to webhook
```

**长期行为**：cron 定时运行后，新发布的文章会被自动推送。首次创建任务时如果已有历史文章需要手动补推。

### 模板语法与变量

引擎是 jinja2 风格（`core/lax/template_parser.py`），**必须用双花括号**：

| 可用变量 | 说明 |
|----------|------|
| `{{ feed.mp_name }}` | 公众号名称 |
| `{{ feed.mp_cover }}` | 公众号头像 |
| `{{ article.title }}` | 文章标题 |
| `{{ article.url }}` | 文章链接 |
| `{{ article.description }}` | 文章摘要 |
| `{{ article.publish_time }}` | 发布时间 |
| `{{ now }}` | 当前时间 |

遍历文章必须用 `{% for article in articles %}...{% endfor %}`。

### 测试 webhook 是否可以推送

```bash
curl -s "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REAL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"msgtype":"text","text":{"content":"✅ 测试消息 — 推送通道正常"}}'
# 返回 {"errcode":0,"errmsg":"ok"} = 成功
```

Webhook URL 存储在项目的 `~/project/.env` 文件中（`QW_WEBHOOK_URL`）。如果 key 被脱敏（`***`），需要用户提供完整 URL。
### 手动触发文章抓取

```bash
AUTH="AK-SK $AK:$SK"
# 广东中职菌
curl -s -H "Authorization: $AUTH" "http://localhost:8001/api/v1/wx/mps/update/MP_WXS_3886864470"
# 中职生
curl -s -H "Authorization: $AUTH" "http://localhost:8001/api/v1/wx/mps/update/MP_WXS_3596295911"
```

## Daily Monitoring (Cron Job)

A daily cron job checks login status and alerts via 企微群 webhook if expired.

**Script:** [`scripts/check_werss_login.py`](scripts/check_werss_login.py) — reads `werss:login:status` from Redis directly. On expired login, sends alert with link to `https://rss.gdcjgk.net` for re-auth.

**Cron job:** Runs daily at 9:00 AM Beijing time. Managed via `hermes cronjob` (job ID: `eba217dfc155`).

**How it works:**
- Login OK → script exits 0, no notification sent (silent pass)
- Login expired → script sends 企微群 alert + exits 1

**Manual check anytime:**
```bash
~/project/we-mp-rss-main/venv/bin/python3 ~/.hermes/scripts/check_werss_login.py
```

**⚠️ Cron shebang pitfall:** `#!/usr/bin/env python3` resolves to system Python inside cron's stripped environment — system Python often lacks venv-installed packages (`redis`, `requests`). For cron-run scripts that need packages from a project venv, use an explicit venv path in the shebang:
```python
#!/home/ubuntu/project/we-mp-rss-main/venv/bin/python3
```
This script already has that fix applied. When creating new cron scripts, start with a venv shebang if they import anything beyond stdlib.

## 调试原则

遇到问题时**先验证后端 API 本身是否正常**，不要只看前端表现。用 `curl` 直接测 API 端点。
用户不喜欢反复修补表面问题——如果第一次修复没解决，需要深挖根因而非打补丁。

## Critical Pitfall: `_islogin` In-Memory Flag vs Redis Reality

`WeChatAPI._islogin` is an **in-memory Python variable** that resets to `False` on every service restart — even when valid cookies/tokens exist in Redis. The `/qr/status` endpoint reports `_islogin`, so after restart it always shows `login_status: false` regardless of actual state.

**The ground truth is Redis:** `redis-cli GET werss:login:status` (value `"1"` = logged in). If Redis says you're logged in, you ARE logged in — article fetching will work. The API endpoint is lying.

**Do NOT:** restart the service and then tell the user "you're not logged in, scan QR again" based on `/qr/status`. Check Redis first.
**Do NOT:** rely on the API `/qr/status` for monitoring scripts. Read Redis directly (see `scripts/check_werss_login.py`).

## Startup Ordering Pitfall

When using real Redis (`REDIS_SERVER_ENABLED=False`), the we-mp-rss systemd unit MUST declare `After=redis-server.service` and `Wants=redis-server.service`. Without this, we-mp-rss can start before Redis and fail to connect, triggering a restart loop.

If `REDIS_SERVER_ENABLED=True` (built-in) and `redis-server.service` also runs, they fight over port 6379. Always use one or the other, never both.
