效率工具

管理员想每月看一次:谁在用 Grammarly、用了多少次、改进的会话占多少

管理员想每月看一次:谁在用 Grammarly、用了多少次、改进的会话占多少。这篇做 analyticsreport.py: 先在本地校验日期范围(最早一年前、最晚两天前),然后按游标翻页拉取全部成员,导出 Excel 能直接打开的 CSV,并输出汇总。

BINBHO · 图文教程

管理员想每月看一次:谁在用 Grammarly、用了多少次、改进的会话占多少。这篇做 analytics_report.py: 先在本地校验日期范围(最早一年前、最晚两天前),然后按游标翻页拉取全部成员,导出 Excel 能直接打开的 CSV,并输出汇总。

先看清成品

  • 日期校验(真实运行):今天是 2026-10-21,date_to 最晚只能是 2026-10-19;2025-10-20 早于一年前,被拒绝;参数有错时不发送任何请求。
  • 替身环境:1000 个成员按 400 + 400 + 200 分三页拉取,写入 CSV。
  • 修复了一个只在 2 月 29 日才出现的 bug。pytest 9 项通过。

合规提醒:这些 API 只开放给 Grammarly Enterprise 和 Grammarly for Education 机构授权的管理员,凭据在管理后台 Organization → OAuth 2.0 credentials 中创建。Client Secret 只放在环境变量或密钥管理系统里,不提交到仓库,不共享账号或凭据。只给凭据勾选需要的 API 和 scope。成员统计、邮箱属于个人数据,导出前请确认机构的隐私政策,并告知成员;文件只在内部保存。本文不列价格,购买请联系 Grammarly 销售。

运行说明:box 上没有 Grammarly 企业版凭据,所以没有拿到真实结果。真实运行过的部分是用无效凭据访问正式接口(2026-10-21 19:34,UTC+8):token 接口返回 HTTP 404 {{"status":404,"error":"Not Found","message":"Invalid client"}};直接用无效 token 调用 scores、analytics、users 接口,都返回 HTTP 401 {{"title":"Unauthorized","status":401,"detail":"Invalid JWT token"}}。成功路径用的是我自己写的本地替身 fake_grammarly.py,字段格式依据官方文档中的示例。

做完之后你会得到什么?

成果 说明
analytics_report.py 日期校验 + 翻页 + CSV + 汇总
gclient.py、fake_grammarly.py 与 A 篇相同
test_analytics.py 9 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install requests pytest
凭据 勾选 Analytics API(analytics-api:read)

官方事实

事实 内容
接口 GET /ecosystem/api/v2/analytics/users
参数 date_from 和 date_to(YYYY-MM-DD,必填);cursor;limit 最大 400
日期 最早一年前,最晚两天前
返回 data[] 包含 id、name、email、days_active、sessions_count、sessions_improved_percent、prompt_count;paging 包含 next_cursor、has_more 等

来源:Analytics API 文档。

文件结构

65-grammarly-B-src/
├── analytics_report.py
├── gclient.py
├── fake_grammarly.py
└── test_analytics.py

共用客户端 gclient.py

三个项目都使用同一个客户端:获取 token;遇到 429 时按 2、4、8 秒指数退避;遇到 401 时重新获取一次 token。

"""Grammarly 企业版 API 的最小客户端(官方 REST 接口,只用 requests)。
- 用 OAuth client_credentials 获取 token(https://auth.grammarly.com/v4/api/oauth2/token,scope 用逗号分隔)
- 429:指数退避,基数 2 秒(官方文档的建议)
- 401:重新获取一次 token 再重试(文档没有写 token 有效期)
- 实测:client_id 或 secret 错误时,token 接口返回的是 HTTP 404 {"message":"Invalid client"},不是 401"""
import os, time, requests

UA = "binbho-tutorial/1.0"

class GrammarlyError(Exception):
    def __init__(self, status, body, hint=""):
        super().__init__(f"HTTP {status}: {body}{(' — ' + hint) if hint else ''}"); self.status = status; self.body = body

class Client:
    def __init__(self, scopes, client_id=None, client_secret=None, sleep=time.sleep, max_429=5):
        self.scopes = ",".join(scopes); self.cid = client_id or os.environ["GRAMMARLY_CLIENT_ID"]
        self.secret = client_secret or os.environ["GRAMMARLY_CLIENT_SECRET"]
        self.s = requests.Session(); self.s.headers["User-Agent"] = UA; self.token = None
        self.sleep = sleep; self.max_429 = max_429
        self.token_url = os.environ.get("GRAMMARLY_TOKEN_URL", "https://auth.grammarly.com/v4/api/oauth2/token")
        self.api = os.environ.get("GRAMMARLY_API", "https://api.grammarly.com/ecosystem/api")

    def _get_token(self):
        r = self.s.post(self.token_url, data={"grant_type": "client_credentials", "client_id": self.cid,
                                         "client_secret": self.secret, "scope": self.scopes}, timeout=30)
        if r.status_code in (400, 401, 404):
            raise GrammarlyError(r.status_code, r.text, "检查 Client ID、Secret,以及管理后台给这个凭据勾选的 API 和 scope")
        r.raise_for_status(); self.token = r.json()["access_token"]

    def request(self, method, path, **kw):
        if not self.token: self._get_token()
        refreshed, n429 = False, 0
        while True:
            r = self.s.request(method, self.api + path, headers={"Authorization": f"Bearer {self.token}", "Accept": "application/json"}, timeout=30, **kw)
            if r.status_code == 401 and not refreshed:
                self._get_token(); refreshed = True; continue
            if r.status_code == 429 and n429 < self.max_429:
                self.sleep(2 * 2 ** n429); n429 += 1; continue
            if r.status_code >= 400:
                raise GrammarlyError(r.status_code, r.text)
            return r

    def paged(self, path, params, limit):
        """按 paging.next_cursor 翻页,直到 has_more 为 false。"""
        cursor = None
        while True:
            p = dict(params, limit=limit, **({"cursor": cursor} if cursor else {}))
            j = self.request("GET", path, params=p).json()
            yield from j.get("data", [])
            pg = j.get("paging") or {}
            if not pg.get("has_more") or not pg.get("next_cursor"): return
            cursor = pg["next_cursor"]

第 1 步:报表脚本

"""把 Grammarly Analytics API 的成员使用统计导出成 CSV,并给出汇总。
接口:GET /ecosystem/api/v2/analytics/users?date_from=YYYY-MM-DD&date_to=YYYY-MM-DD&limit<=400&cursor=...
官方限制:date_from 最早为一年前,date_to 最晚为两天前。需要 scope analytics-api:read。"""
import argparse, csv, datetime as dt, statistics, sys
from gclient import Client, GrammarlyError

FIELDS = ["id", "name", "email", "days_active", "sessions_count", "sessions_improved_percent", "prompt_count"]

def one_year_ago(today):
    try: return today.replace(year=today.year - 1)
    except ValueError: return today.replace(year=today.year - 1, day=28)     # 2 月 29 日

def check_range(date_from, date_to, today=None):
    today = today or dt.date.today()
    f, t = dt.date.fromisoformat(date_from), dt.date.fromisoformat(date_to)
    errs = []
    if f > t: errs.append("date_from 晚于 date_to")
    if t > today - dt.timedelta(days=2): errs.append(f"date_to 最晚只能是 {today - dt.timedelta(days=2)}(两天前)")
    if f < one_year_ago(today):
        errs.append(f"date_from 不能早于一年前({one_year_ago(today)})")
    return errs

def summarize(rows):
    active = [r for r in rows if (r.get("days_active") or 0) > 0]
    return {"members": len(rows), "active": len(active), "inactive": len(rows) - len(active),
            "sessions_total": sum(r.get("sessions_count") or 0 for r in rows),
            "prompt_total": sum(r.get("prompt_count") or 0 for r in rows),
            "median_improved_pct": statistics.median([r["sessions_improved_percent"] for r in active if r.get("sessions_improved_percent") is not None] or [0])}

def main(argv=None, client=None):
    ap = argparse.ArgumentParser(); ap.add_argument("--from", dest="f", required=True); ap.add_argument("--to", dest="t", required=True)
    ap.add_argument("--out", default="analytics.csv"); ap.add_argument("--limit", type=int, default=400)
    a = ap.parse_args(argv)
    errs = check_range(a.f, a.t) + ([] if 1 <= a.limit <= 400 else ["limit 必须是 1~400"])
    if errs:
        for e in errs: print("[ERROR]", e)
        return 2
    client = client or Client(["analytics-api:read"])
    rows = list(client.paged("/v2/analytics/users", {"date_from": a.f, "date_to": a.t}, a.limit))
    with open(a.out, "w", newline="", encoding="utf-8-sig") as fh:      # utf-8-sig:Excel 打开中文名不乱码
        w = csv.DictWriter(fh, FIELDS, extrasaction="ignore"); w.writeheader(); w.writerows(rows)
    print(summarize(rows)); print("写入", a.out, len(rows), "行")
    return 0

if __name__ == "__main__":
    try: sys.exit(main())
    except GrammarlyError as e:
        print("API 错误:", e); sys.exit(1)

第 2 步:测试

import csv, datetime as d, pytest
import fake_grammarly as F, gclient, analytics_report as A

T = d.date(2026, 10, 21)
@pytest.mark.parametrize("f,t,ok", [("2026-09-01", "2026-10-19", True), ("2026-09-01", "2026-10-20", False),
    ("2025-10-20", "2026-10-01", False), ("2025-10-21", "2026-10-01", True), ("2026-10-05", "2026-10-01", False)])
def test_range(f, t, ok):
    assert (A.check_range(f, t, T) == []) is ok

def test_leap_day():
    # 调试时发现的 bug:条件表达式优先级写错,2 月 29 日这一天任何 date_from 都会被判为“早于一年前”
    assert A.check_range("2023-03-01", "2023-12-31", d.date(2024, 2, 29)) == []
    assert A.check_range("2023-02-27", "2023-12-31", d.date(2024, 2, 29))

@pytest.fixture
def env(monkeypatch):
    st = F.State(); srv = F.make_server(st)
    for k, v in F.env_for(srv).items(): monkeypatch.setenv(k, v)
    monkeypatch.setenv("GRAMMARLY_CLIENT_ID", "demo"); monkeypatch.setenv("GRAMMARLY_CLIENT_SECRET", "good")
    st.analytics = [{"id": i, "name": f"成员{i}", "email": f"u{i}@example.com", "days_active": i % 4, "sessions_count": i * 3,
                     "sessions_improved_percent": 50 + i % 30, "prompt_count": i % 5} for i in range(1, 1001)]
    yield st; srv.shutdown()

def test_pagination_and_csv(env, tmp_path):
    out = tmp_path / "a.csv"
    assert A.main(["--from", "2026-09-01", "--to", "2026-10-19", "--out", str(out)]) == 0
    rows = list(csv.DictReader(open(out, encoding="utf-8-sig")))
    assert len(rows) == 1000 and rows[0]["name"] == "成员1"
    assert [c for c in env.calls if c[1].endswith("analytics/users")].__len__() == 3     # 400 + 400 + 200

def test_bad_args_no_api_call(env):
    assert A.main(["--from", "2026-09-01", "--to", "2026-10-21", "--limit", "500"]) == 2 and env.calls == []

def test_summary():
    s = A.summarize([{"days_active": 0, "sessions_count": 0}, {"days_active": 3, "sessions_count": 10, "sessions_improved_percent": 40, "prompt_count": 2}])
    assert s == {"members": 2, "active": 1, "inactive": 1, "sessions_total": 10, "prompt_total": 2, "median_improved_pct": 40}

第 3 步:运行

$ python -c "import analytics_report as A, datetime as d; print(A.check_range('2026-09-01','2026-10-20', d.date(2026,10,21)))"
['date_to 最晚只能是 2026-10-19(两天前)']
$ GRAMMARLY_CLIENT_ID=invalid-demo GRAMMARLY_CLIENT_SECRET=invalid python analytics_report.py --from 2026-09-01 --to 2026-10-19
API 错误: HTTP 404: {... "message":"Invalid client"} — 检查 Client ID、Secret,以及管理后台给这个凭据勾选的 API 和 scope
$ python -m pytest -q
9 passed

有真实凭据时运行 python analytics_report.py --from 2026-09-01 --to 2026-09-30,会得到 analytics.csv 和一行汇总,例如 {'members': ..., 'active': ..., ...}。

怎么检查结果?

  • CSV 的行数应该等于管理后台显示的成员数。
  • 用 Excel 打开时,中文姓名不乱码(文件用 utf-8-sig 编码)。
  • inactive 表示所选时间段内 days_active 为 0 的人数。

调试示例

只在 2 月 29 日出现的 bug。 第一版用一行条件表达式计算“一年前”:

if f < today.replace(year=today.year - 1) if not (today.month == 2 and today.day == 29) else today - dt.timedelta(days=365):

我原意是“和一年前的日期比较”,但 Python 把它解析成了 (f < X) if 条件 else Y。平时结果正常;到了 2 月 29 日,表达式的值变成一个日期对象,总是为真,于是任何 date_from 都会被判为“早于一年前”。 我用 2024-02-29 作为“今天”测试时发现了这个问题:2024-03-01 起的范围也报错。改成单独的 one_year_ago() 函数(2 月 29 日回退到前一年的 2 月 28 日),并加了 test_leap_day。

常见问题

现象 原因 处理
日期报错 超出一年前到两天前的范围 调整日期
只拿到 400 行 没有按 next_cursor 翻页 用 paged() 循环到 has_more 为 false
403 凭据没有勾选 Analytics API 在管理后台修改凭据
429 请求太快 客户端会自动按 2、4、8 秒退避

模板规则

  • 参数在本地校验,错误参数不发请求。
  • 翻页以 has_more 和 next_cursor 为准。
  • CSV 只在内部共享,不用于考核个人。

项目资源

用途 地址
API 文档首页 https://developer.grammarly.com/
OAuth 2.0 凭据和 scope https://developer.grammarly.com/oauth-credentials.html
第一个 API 请求 https://developer.grammarly.com/your-first-api-request.html
Analytics API https://developer.grammarly.com/analytics-api.html

每月跑一次报表,就能看清 Grammarly 在团队里的实际使用情况。