9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
159 lines
6.6 KiB
JavaScript
159 lines
6.6 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import {
|
|
callGateway,
|
|
emptyResult,
|
|
formatDate,
|
|
formatDuration,
|
|
requireChoice,
|
|
requirePositiveInt,
|
|
} from './utils.js';
|
|
|
|
/**
|
|
* `/readdata/detail` — reading statistics summary.
|
|
* Per SKILL.md every duration field is in SECONDS — `totalReadTime`,
|
|
* `dayAverageReadTime`, `readLongest[].readTime`, `preferTime`, etc.
|
|
* `preferAuthor[].readTime` is the one exception (already formatted by server
|
|
* as "X小时Y分钟"); we surface it verbatim.
|
|
*
|
|
* Output uses a `section` column so callers can filter:
|
|
* - summary — single overview row
|
|
* - longest — books / albums sorted by read time
|
|
* - readStat — counts (读过 / 读完 / 阅读 / 笔记)
|
|
* - preferCategory / preferAuthor / preferPublisher
|
|
* - preferTime — 24h time distribution (starts at 6am per SKILL.md)
|
|
*/
|
|
const MODE_CHOICES = ['weekly', 'monthly', 'annually', 'overall'];
|
|
|
|
cli({
|
|
site: 'weread-official',
|
|
name: 'readdata',
|
|
access: 'read',
|
|
description: 'Reading statistics: time, streak, preferences, top books',
|
|
domain: 'weread.qq.com',
|
|
strategy: Strategy.PUBLIC,
|
|
browser: false,
|
|
args: [
|
|
{ name: 'mode', default: 'monthly', choices: MODE_CHOICES, help: 'Stat window: weekly / monthly / annually / overall' },
|
|
{ name: 'base-time', type: 'int', help: 'Optional Unix timestamp inside the target period; default is current period' },
|
|
],
|
|
columns: ['section', 'idx', 'key', 'value', 'detail'],
|
|
func: async (args) => {
|
|
const mode = requireChoice(args.mode, MODE_CHOICES, 'mode', 'monthly');
|
|
const params = { mode };
|
|
if (args['base-time'] !== undefined && args['base-time'] !== null && args['base-time'] !== '') {
|
|
params.baseTime = requirePositiveInt(args['base-time'], 'base-time');
|
|
}
|
|
const payload = await callGateway('/readdata/detail', params);
|
|
|
|
const rows = [];
|
|
const totalReadTime = Number(payload?.totalReadTime ?? 0);
|
|
const dayAverage = Number(payload?.dayAverageReadTime ?? 0);
|
|
const readDays = Number(payload?.readDays ?? 0);
|
|
const compareRaw = payload?.compare;
|
|
const compare = Number.isFinite(Number(compareRaw)) ? Number(compareRaw) : null;
|
|
|
|
const summary = [
|
|
['mode', mode, ''],
|
|
['baseTime', formatDate(payload?.baseTime), String(payload?.baseTime ?? '')],
|
|
['readDays', String(readDays), ''],
|
|
['totalReadTime', formatDuration(totalReadTime), `${totalReadTime}s`],
|
|
['dayAverageReadTime', formatDuration(dayAverage), `${dayAverage}s`],
|
|
['compareToPrev', compare === null ? '' : `${(compare * 100).toFixed(1)}%`, ''],
|
|
['readRate', payload?.readRate !== undefined ? `${Number(payload.readRate).toFixed(1)}%` : '', ''],
|
|
['preferTimeWord', String(payload?.preferTimeWord ?? ''), ''],
|
|
['preferCategoryWord', String(payload?.preferCategoryWord ?? ''), ''],
|
|
];
|
|
for (let i = 0; i < summary.length; i += 1) {
|
|
const [key, value, detail] = summary[i];
|
|
rows.push({ section: 'summary', idx: i + 1, key, value, detail });
|
|
}
|
|
|
|
const longest = Array.isArray(payload?.readLongest) ? payload.readLongest : [];
|
|
longest.forEach((entry, i) => {
|
|
const book = entry?.book ?? {};
|
|
const albumInfo = entry?.albumInfo ?? null;
|
|
const title = albumInfo ? String(albumInfo?.name ?? '') : String(book?.title ?? '');
|
|
const author = albumInfo ? String(albumInfo?.authorName ?? '') : String(book?.author ?? '');
|
|
const tags = Array.isArray(entry?.tags) ? entry.tags.join(',') : '';
|
|
const readTime = Number(entry?.readTime ?? 0);
|
|
rows.push({
|
|
section: 'longest',
|
|
idx: i + 1,
|
|
key: title,
|
|
value: formatDuration(readTime),
|
|
detail: `${author}${tags ? ` [${tags}]` : ''}`,
|
|
});
|
|
});
|
|
|
|
const readStat = Array.isArray(payload?.readStat) ? payload.readStat : [];
|
|
readStat.forEach((entry, i) => {
|
|
rows.push({
|
|
section: 'readStat',
|
|
idx: i + 1,
|
|
key: String(entry?.stat ?? ''),
|
|
value: String(entry?.counts ?? ''),
|
|
detail: '',
|
|
});
|
|
});
|
|
|
|
const preferCategory = Array.isArray(payload?.preferCategory) ? payload.preferCategory : [];
|
|
preferCategory.forEach((entry, i) => {
|
|
const seconds = Number(entry?.readingTime ?? 0);
|
|
rows.push({
|
|
section: 'preferCategory',
|
|
idx: i + 1,
|
|
key: String(entry?.categoryTitle ?? ''),
|
|
value: formatDuration(seconds),
|
|
detail: `${Number(entry?.readingCount ?? 0)}本`,
|
|
});
|
|
});
|
|
|
|
const preferAuthor = Array.isArray(payload?.preferAuthor) ? payload.preferAuthor : [];
|
|
preferAuthor.forEach((entry, i) => {
|
|
rows.push({
|
|
section: 'preferAuthor',
|
|
idx: i + 1,
|
|
key: String(entry?.name ?? ''),
|
|
// preferAuthor[].readTime is server-formatted ("5小时30分钟"), not seconds.
|
|
value: String(entry?.readTime ?? ''),
|
|
detail: `${Number(entry?.count ?? 0)}本`,
|
|
});
|
|
});
|
|
|
|
const preferPublisher = Array.isArray(payload?.preferPublisher) ? payload.preferPublisher : [];
|
|
preferPublisher.forEach((entry, i) => {
|
|
rows.push({
|
|
section: 'preferPublisher',
|
|
idx: i + 1,
|
|
key: String(entry?.name ?? ''),
|
|
value: `${Number(entry?.count ?? 0)}本`,
|
|
detail: '',
|
|
});
|
|
});
|
|
|
|
const preferTime = Array.isArray(payload?.preferTime) ? payload.preferTime : [];
|
|
if (preferTime.length === 24) {
|
|
// SKILL.md: order starts at 06:00 and wraps to 05:00. Translate to a flat
|
|
// (hour-of-day → duration) view so consumers do not need to know the offset.
|
|
for (let i = 0; i < 24; i += 1) {
|
|
const hour = (6 + i) % 24;
|
|
const seconds = Number(preferTime[i] ?? 0);
|
|
rows.push({
|
|
section: 'preferTime',
|
|
idx: i + 1,
|
|
key: `${String(hour).padStart(2, '0')}:00`,
|
|
value: formatDuration(seconds),
|
|
detail: `${seconds}s`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
emptyResult('readdata', `No reading data for mode=${mode}.`);
|
|
}
|
|
return rows;
|
|
},
|
|
});
|
|
|
|
export { MODE_CHOICES };
|