Skip to content

Commit 914a985

Browse files
feat: add estimatePageReadTime to notion-utils
1 parent 55de26e commit 914a985

File tree

3 files changed

+249
-0
lines changed

3 files changed

+249
-0
lines changed

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"watch": "run-p watch:* --continue-on-error",
2020
"watch:tsc": "tsc --build --watch --preserveWatchOutput",
2121
"watch:tsup": "lerna run watch --no-private --parallel",
22+
"link": "lerna exec \"yarn link\" --no-private",
2223
"dev": "run-s watch",
2324
"prebuild": "run-s clean",
2425
"prewatch": "run-s clean",

Diff for: packages/notion-utils/src/estimate-page-read-time.ts

+247
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { Block, ExtendedRecordMap, PageBlock } from 'notion-types'
2+
import { getBlockTitle } from './get-block-title'
3+
import { getPageTableOfContents } from './get-page-table-of-contents'
4+
5+
type EstimatePageReadTimeOptions = {
6+
wordsPerMinute?: number
7+
imageReadTimeInSeconds?: number
8+
}
9+
10+
type ContentStats = {
11+
numWords: number
12+
numImages: number
13+
}
14+
15+
type PageReadTimeEstimate = ContentStats & {
16+
totalWordsReadTimeInMinutes: number
17+
totalImageReadTimeInMinutes: number
18+
totalReadTimeInMinutes: number
19+
}
20+
21+
/**
22+
* Returns an estimate for the time it would take for a person to read the content
23+
* in the given Notion page.
24+
*
25+
* Uses Medium for inspiration.
26+
*
27+
* @see https://blog.medium.com/read-time-and-you-bc2048ab620c
28+
* @see https://github.com/ngryman/reading-time
29+
*
30+
* TODO: handle non-english content.
31+
*/
32+
export function estimatePageReadTime(
33+
block: Block,
34+
recordMap: ExtendedRecordMap,
35+
{
36+
wordsPerMinute = 275,
37+
imageReadTimeInSeconds = 12
38+
}: EstimatePageReadTimeOptions = {}
39+
): PageReadTimeEstimate {
40+
const stats = getBlockContentStats(block, recordMap)
41+
const totalWordsReadTimeInMinutes = stats.numWords / wordsPerMinute
42+
const totalImageReadTimeInSeconds =
43+
stats.numImages > 10
44+
? (stats.numImages / 2) * (imageReadTimeInSeconds + 3) +
45+
(stats.numImages - 10) * 3 // n/2(a+b) + 3 sec/image
46+
: (stats.numImages / 2) *
47+
(2 * imageReadTimeInSeconds + (1 - stats.numImages)) // n/2[2a+(n-1)d]
48+
const totalImageReadTimeInMinutes = totalImageReadTimeInSeconds / 60
49+
50+
const totalReadTimeInMinutes =
51+
totalWordsReadTimeInMinutes + totalImageReadTimeInMinutes
52+
53+
return {
54+
...stats,
55+
totalWordsReadTimeInMinutes,
56+
totalImageReadTimeInMinutes,
57+
totalReadTimeInMinutes
58+
}
59+
}
60+
61+
/**
62+
* Same as `estimatePageReadTime`, except it returns the total time estimate as
63+
* a human-readable string.
64+
*
65+
* For example, "9 minutes" or "less than a minute".
66+
*/
67+
export function estimatePageReadTimeAsHumanizedString(
68+
block: Block,
69+
recordMap: ExtendedRecordMap,
70+
opts: EstimatePageReadTimeOptions
71+
) {
72+
const estimate = estimatePageReadTime(block, recordMap, opts)
73+
return humanizeReadTime(estimate.totalReadTimeInMinutes)
74+
}
75+
76+
function getBlockContentStats(
77+
block: Block,
78+
recordMap: ExtendedRecordMap
79+
): ContentStats {
80+
const stats: ContentStats = {
81+
numWords: 0,
82+
numImages: 0
83+
}
84+
85+
if (!block) {
86+
return stats
87+
}
88+
89+
for (const childId of block.content || []) {
90+
const child = recordMap.block[childId]?.value
91+
let recurse = false
92+
if (!child) continue
93+
94+
switch (child.type) {
95+
case 'quote':
96+
// fallthrough
97+
case 'alias':
98+
// fallthrough
99+
case 'header':
100+
// fallthrough
101+
case 'sub_header':
102+
// fallthrough
103+
case 'sub_sub_header': {
104+
const title = getBlockTitle(child, recordMap)
105+
stats.numWords += countWordsInText(title)
106+
break
107+
}
108+
109+
case 'callout':
110+
// fallthrough
111+
case 'toggle':
112+
// fallthrough
113+
case 'to_do':
114+
// fallthrough
115+
case 'bulleted_list':
116+
// fallthrough
117+
case 'numbered_list':
118+
// fallthrough
119+
case 'text': {
120+
const title = getBlockTitle(child, recordMap)
121+
stats.numWords += countWordsInText(title)
122+
recurse = true
123+
break
124+
}
125+
126+
case 'embed':
127+
// fallthrough
128+
case 'tweet':
129+
// fallthrough
130+
case 'maps':
131+
// fallthrough
132+
case 'pdf':
133+
// fallthrough
134+
case 'figma':
135+
// fallthrough
136+
case 'typeform':
137+
// fallthrough
138+
case 'codepen':
139+
// fallthrough
140+
case 'excalidraw':
141+
// fallthrough
142+
case 'gist':
143+
// fallthrough
144+
case 'video':
145+
// fallthrough
146+
case 'drive':
147+
// fallthrough
148+
case 'audio':
149+
// fallthrough
150+
case 'file':
151+
// fallthrough
152+
case 'image':
153+
// treat all embeds as images
154+
stats.numImages += 1
155+
break
156+
157+
case 'bookmark':
158+
// treat bookmarks as quarter images since they aren't as content-ful as embedd images
159+
stats.numImages += 0.25
160+
break
161+
162+
case 'code':
163+
// treat code blocks as double the complexity of images
164+
stats.numImages += 2
165+
break
166+
167+
case 'table':
168+
// fallthrough
169+
case 'collection_view':
170+
// treat collection views as double the complexity of images
171+
stats.numImages += 2
172+
break
173+
174+
case 'column':
175+
// fallthrough
176+
case 'column_list':
177+
// fallthrough
178+
case 'transclusion_container':
179+
recurse = true
180+
break
181+
182+
case 'table_of_contents': {
183+
const page = block as PageBlock
184+
if (!page) continue
185+
186+
const toc = getPageTableOfContents(page, recordMap)
187+
for (const tocItem of toc) {
188+
stats.numWords += countWordsInText(tocItem.text)
189+
}
190+
191+
break
192+
}
193+
194+
case 'transclusion_reference': {
195+
const referencePointerId =
196+
child?.format?.transclusion_reference_pointer?.id
197+
198+
if (!referencePointerId) {
199+
continue
200+
}
201+
const referenceBlock = recordMap.block[referencePointerId]?.value
202+
if (referenceBlock) {
203+
mergeContentStats(
204+
stats,
205+
getBlockContentStats(referenceBlock, recordMap)
206+
)
207+
}
208+
break
209+
}
210+
211+
default:
212+
// ignore unrecognized blocks
213+
break
214+
}
215+
216+
if (recurse) {
217+
mergeContentStats(stats, getBlockContentStats(child, recordMap))
218+
}
219+
}
220+
221+
return stats
222+
}
223+
224+
function mergeContentStats(statsA: ContentStats, statsB: ContentStats) {
225+
statsA.numWords += statsB.numWords
226+
statsA.numImages += statsB.numImages
227+
}
228+
229+
function countWordsInText(text: string): number {
230+
if (!text) {
231+
return 0
232+
}
233+
234+
return (text.match(/\w+/g) || []).length
235+
}
236+
237+
function humanizeReadTime(time: number): string {
238+
if (time < 0.5) {
239+
return 'less than a minute'
240+
}
241+
242+
if (time < 1.5) {
243+
return '1 minute'
244+
}
245+
246+
return `${Math.ceil(time)} minutes`
247+
}

Diff for: packages/notion-utils/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ export * from './normalize-title'
2121
export * from './merge-record-maps'
2222
export * from './format-date'
2323
export * from './format-notion-date-time'
24+
export * from './estimate-page-read-time'

0 commit comments

Comments
 (0)