| 1 | const port = Number(process.env.PORT ?? 3000); |
| 2 | const width = 768; |
| 3 | const cacheImageDir = path.resolve(".clover/question_images"); |
| 4 | |
| 5 | const getBrowser = RefCountedExpirable( |
| 6 | () => |
| 7 | playwright.chromium.launch({ |
| 8 | args: ["--no-sandbox", "--disable-setuid-sandbox"], |
| 9 | }), |
| 10 | (b) => b.close(), |
| 11 | ); |
| 12 | |
| 13 | export async function renderQuestionImage(question: Question) { |
| 14 | using sharedBrowser = await getBrowser(); |
| 15 | const b = sharedBrowser.value; |
| 16 | const ctx = await b.newContext({ |
| 17 | userAgent: "clover-qa-image", |
| 18 | deviceScaleFactor: 1.5, |
| 19 | }); |
| 20 | try { |
| 21 | const p = await ctx.newPage(); |
| 22 | await p.setViewportSize({ width, height: 400 }); |
| 23 | await p.goto(`http://localhost:${port}/q+a/${question.id}`); |
| 24 | try { |
| 25 | await p.waitForLoadState("networkidle", { timeout: 500 }); |
| 26 | } catch {} |
| 27 | const height = await p.evaluate(() => { |
| 28 | document.body.style.background = "#1a1b1c"; |
| 29 | return document.querySelector("main")!.getBoundingClientRect().height; |
| 30 | }); |
| 31 | return await p.screenshot({ |
| 32 | type: "png", |
| 33 | clip: { x: 0, y: 0, width, height }, |
| 34 | }); |
| 35 | } finally { |
| 36 | await ctx.close(); |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | export async function getQuestionImage( |
| 41 | question: Question, |
| 42 | headOnly: boolean, |
| 43 | ): Promise<Response> { |
| 44 | const hash = crypto |
| 45 | .createHash("sha1") |
| 46 | .update(question.qmid + question.type + question.text) |
| 47 | .digest("hex"); |
| 48 | |
| 49 | const headers = { |
| 50 | "Content-Type": "image/png", |
| 51 | "Cache-Control": "public, max-age=31536000", |
| 52 | ETag: `"${hash}"`, |
| 53 | "Last-Modified": question.date.toUTCString(), |
| 54 | }; |
| 55 | |
| 56 | if (headOnly) { |
| 57 | return new Response(null, { headers }); |
| 58 | } |
| 59 | |
| 60 | const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`); |
| 61 | let buf: Buffer; |
| 62 | try { |
| 63 | buf = await fs.readFile(cachedFilePath); |
| 64 | } catch (e: any) { |
| 65 | if (e.code !== "ENOENT") throw e; |
| 66 | buf = await renderQuestionImage(question); |
| 67 | fs.writeMkdir(cachedFilePath, buf).catch(() => {}); |
| 68 | } |
| 69 | |
| 70 | return new Response(buf, { headers }); |
| 71 | } |
| 72 | |
| 73 | import * as fs from "#sitegen/fs"; |
| 74 | import * as view from "#sitegen/view"; |
| 75 | import { Question } from "#src/q+a/models/Question.ts"; |
| 76 | import { RefCountedExpirable } from "@clo/lib/async"; |
| 77 | import * as crypto from "node:crypto"; |
| 78 | import * as path from "node:path"; |
| 79 | import * as playwright from "playwright"; |