Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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 };