From 9e1472a0eecc00c234aec46d83ecb9887b6892d2 Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 16:13:23 -0300 Subject: [PATCH 01/21] ups --- src/routes/Chart.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/Chart.svelte b/src/routes/Chart.svelte index db7a058..e496616 100644 --- a/src/routes/Chart.svelte +++ b/src/routes/Chart.svelte @@ -111,7 +111,6 @@ throw new Error("duplicate of same kind"); map.set(id, likePlusRetweet(tweet, existing)); } else if ("retweetAt" in tweet) { - console.log(tweet, existing); if ("retweetAt" in existing) throw new Error("duplicate of same kind"); map.set(id, likePlusRetweet(existing, tweet)); From 328b5af255b369655fbe77dedd5af966f20525b2 Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 16:15:10 -0300 Subject: [PATCH 02/21] siempre usar mismo color --- src/routes/Chart.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/Chart.svelte b/src/routes/Chart.svelte index e496616..9f20d1e 100644 --- a/src/routes/Chart.svelte +++ b/src/routes/Chart.svelte @@ -163,7 +163,7 @@ align: "center", clamp: true, // offset: 1, - color: $isDark ? "#ffffff" : "000000", + color: "#000000", }; let datasets: Datasets; @@ -231,7 +231,7 @@ autoSkip: true, minRotation: 0, maxRotation: 0, - color: $isDark ? "#aaaaaa" : "000000", + color: $isDark ? "#aaaaaa" : "#000000", }, grid: { display: false, From 4668208f89bd22a0a4b13c2e5f4d250fc5ea8a7a Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 16:24:18 -0300 Subject: [PATCH 03/21] scraper(cli): arreglar scrap likes --- scraper/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scraper/index.ts b/scraper/index.ts index 458ac45..286661d 100644 --- a/scraper/index.ts +++ b/scraper/index.ts @@ -19,7 +19,7 @@ const scrapLikesCommand = command({ async handler({ n }) { const db = await connectDb(process.env.DB_PATH); const scraper = new Scraper(db); - const cuenta = await this.getRandomAccount(); + const cuenta = await scraper.getRandomAccount(); await scraper.scrap(cuenta, n); await scraper.browser?.close(); }, From 1b90ca77248672d87e894f12ce2b6654dd3fe1d5 Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 16:24:28 -0300 Subject: [PATCH 04/21] notificar cuando se scrapea likes bien --- scraper/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scraper/index.ts b/scraper/index.ts index 286661d..a6219fe 100644 --- a/scraper/index.ts +++ b/scraper/index.ts @@ -240,6 +240,7 @@ class Scraper { totalTweetsSeen: count, }) .where(eq(schema.scraps.id, scrapId)); + return count; } catch (error) { console.error(`oneoff[${cuenta?.id}]:`, error); } finally { @@ -254,7 +255,10 @@ class Scraper { let i = 0; while (true) { const cuenta = await this.getRandomAccount(); - await this.scrap(cuenta); + { + const count = await this.scrap(cuenta); + if (count) console.info(`scrapped likes, seen ${count}`); + } i--; if (i <= 0) { try { From 0c2804daea1480a85a757c374a703a99b1f7c581 Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 16:25:06 -0300 Subject: [PATCH 05/21] scraper(cli): notificar scrap retweets --- scraper/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scraper/index.ts b/scraper/index.ts index a6219fe..950a360 100644 --- a/scraper/index.ts +++ b/scraper/index.ts @@ -264,6 +264,7 @@ class Scraper { try { const result = await this.scrapTweets({ cuenta }); await this.saveTweetsScrap(result); + console.info(`scrapped retweets, seen ${result.tweetsSeen}`); } catch (error) { console.error(`tweets[${cuenta.id}]:`, error); } From bb4227b697817bbb7c3a234977f3bdc10f77b8d8 Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 16:25:36 -0300 Subject: [PATCH 06/21] conseguir ultimo scrap for lastupdated fixes #28 --- src/routes/+page.server.ts | 7 ++++--- src/routes/+page.svelte | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index a56e71a..1e58dd1 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,5 +1,5 @@ import { db } from "$lib/db"; -import { desc } from "drizzle-orm"; +import { desc, isNotNull } from "drizzle-orm"; import { likedTweets, retweets, scraps } from "../schema"; import type { PageServerLoad } from "./$types"; @@ -20,8 +20,9 @@ export const load: PageServerLoad = async ({ params, setHeaders }) => { }, orderBy: desc(retweets.retweetAt), }); - const lastUpdated = await db.query.likedTweets.findFirst({ - orderBy: desc(likedTweets.firstSeenAt), + const lastUpdated = await db.query.scraps.findFirst({ + orderBy: desc(scraps.at), + where: isNotNull(scraps.totalTweetsSeen), }); setHeaders({ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b08cea9..7483f6b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -235,7 +235,7 @@

{filteredTweets.length}

última vez actualizado {dateFormatter.format( - data.lastUpdated?.firstSeenAt, + data.lastUpdated?.at, )} From 7a793454e8adcefaacc98dc6933b9fdabf043490 Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 16:45:41 -0300 Subject: [PATCH 07/21] actualizar readme con nuevo scraper --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 291e8b6..8046781 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,27 @@ pnpm dev anda a `/admin` y logeate con la contraseña. agrega una cuenta de Twitter copiando las cookies `auth_token` y `ct0`. -los scraps en dev corren solo una vez, en prod corren cada ~1 minuto. +### scraper + +podes correrlo manualmente: + +``` +pnpm scraper:run scrap retweets +pnpm scraper:run scrap likes +# y fijate con --help porque hay flags para debuggear, etc +``` + +o lo que se usa en prod que es el cron: + +``` +pnpm scraper:run cron +``` ## producción ``` git pull && pnpm install && pnpm build && cp -r drizzle build/ node -r dotenv/config build +# en otra tty +pnpm scraper:run cron ``` From c07d42e01fe915ef8a4b6f89cdbe27500653d5d2 Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 16:46:16 -0300 Subject: [PATCH 08/21] agregar un dia mas a semanal https://x.com/rquiroga777/status/1759664118133322206?s=20 --- src/routes/+page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7483f6b..c703ee4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -169,6 +169,7 @@ .startOf("day"); const days = [ + today.subtract(7, "day"), today.subtract(6, "day"), today.subtract(5, "day"), today.subtract(4, "day"), From 3072374cae5da3c7fd085dcaadb4f46c7f55ac61 Mon Sep 17 00:00:00 2001 From: Nulo Date: Mon, 19 Feb 2024 17:24:17 -0300 Subject: [PATCH 09/21] scrapear mas tuits por defecto --- scraper/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scraper/index.ts b/scraper/index.ts index 950a360..debce6f 100644 --- a/scraper/index.ts +++ b/scraper/index.ts @@ -290,7 +290,7 @@ class Scraper { * @returns la cantidad de tweets vistos (no guardados) */ async scrapTweets({ - n = 5, + n = 10, saveApiResponses = false, cuenta, }: { From 5cbfc0a0aa9407e09debb4feefe1396bfd69feff Mon Sep 17 00:00:00 2001 From: Nulo Date: Tue, 20 Feb 2024 00:17:41 -0300 Subject: [PATCH 10/21] no romperse si la api tira cualquiera --- scraper/index.ts | 84 +++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/scraper/index.ts b/scraper/index.ts index debce6f..105cbdf 100644 --- a/scraper/index.ts +++ b/scraper/index.ts @@ -307,48 +307,52 @@ class Scraper { // setup API response capturing and parsing page.on("response", async (response) => { - const req = response.request(); - const url = req.url(); - if ( - url.includes("/graphql/") && - url.includes("UserTweets?") && - req.method() === "GET" - ) { - const json = await response.json(); - if (saveApiResponses) { - await mkdir("debug-api-responses", { recursive: true }); - await writeFile( - `debug-api-responses/${+new Date()}-${nanoid()}.json`, - JSON.stringify(json, undefined, 2), - ); - } - - const parsed = zUserTweetsRes.parse(json); - const entries = - parsed.data.user.result.timeline_v2.timeline.instructions - .filter( - (x): x is z.infer => - "entries" in x, - ) - .flatMap((x) => - x.entries - .map((e) => e.content) - .filter( - (y): y is TimelineTimelineItem => - y.entryType === "TimelineTimelineItem", - ), - ) - // filtrar publicidades - .filter((e) => !e.itemContent.tweet_results.promotedMetadata) - .map((e) => e.itemContent.tweet_results.result) - // filtrar publicidades - .filter( - (e): e is z.infer => - e.__typename === "Tweet", + try { + const req = response.request(); + const url = req.url(); + if ( + url.includes("/graphql/") && + url.includes("UserTweets?") && + req.method() === "GET" + ) { + const json = await response.json(); + if (saveApiResponses) { + await mkdir("debug-api-responses", { recursive: true }); + await writeFile( + `debug-api-responses/${+new Date()}-${nanoid()}.json`, + JSON.stringify(json, undefined, 2), ); - for (const entry of entries) { - map.set(entry.legacy.id_str, entry.legacy); + } + + const parsed = zUserTweetsRes.parse(json); + const entries = + parsed.data.user.result.timeline_v2.timeline.instructions + .filter( + (x): x is z.infer => + "entries" in x, + ) + .flatMap((x) => + x.entries + .map((e) => e.content) + .filter( + (y): y is TimelineTimelineItem => + y.entryType === "TimelineTimelineItem", + ), + ) + // filtrar publicidades + .filter((e) => !e.itemContent.tweet_results.promotedMetadata) + .map((e) => e.itemContent.tweet_results.result) + // filtrar publicidades + .filter( + (e): e is z.infer => + e.__typename === "Tweet", + ); + for (const entry of entries) { + map.set(entry.legacy.id_str, entry.legacy); + } } + } catch (error) { + console.error(`no pude capturar pedido API`, error); } }); From b3304175bd6deee2588039357c53aca19cf3130a Mon Sep 17 00:00:00 2001 From: Nulo Date: Tue, 20 Feb 2024 00:24:55 -0300 Subject: [PATCH 11/21] errata --- src/routes/+page.svelte | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c703ee4..7d22213 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -23,6 +23,11 @@ $: dudoso = filteredTweets.some((t) => dayjs(t.firstSeenAt).isBefore(dayjs("2024-02-12", "YYYY-MM-DD")), ); + $: dudosoCrashScraper = filteredRetweets.some( + (t) => + dayjs(t.retweetAt).isAfter(dayjs("2024-02-19T20:00:00.000-03:00")) && + dayjs(t.retweetAt).isBefore(dayjs("2024-02-20T01:00:00.000-03:00")), + ); function startTimeFromFilter(filter: Filter) { switch (filter) { @@ -257,6 +262,16 @@ start={startTimeFilter} /> + {#if dudosoCrashScraper} +
+

+ ¡Ojo! Los datos de los likes de la noche del 19 de febrero y las + primeras horas del 20 de febrero de 2024 pueden estar levemente mal (se + acumulan likes en las 00:00hs que deberían estar en la noche del 19 de + febrero de 2024) +

+
+ {/if}
From 33e51f854db08a5da1b44c81e01c5aad2c7dc4c1 Mon Sep 17 00:00:00 2001 From: Nulo Date: Tue, 20 Feb 2024 00:26:56 -0300 Subject: [PATCH 12/21] 00hs no 00:00hs --- src/routes/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7d22213..88d6ad2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -267,7 +267,7 @@

¡Ojo! Los datos de los likes de la noche del 19 de febrero y las primeras horas del 20 de febrero de 2024 pueden estar levemente mal (se - acumulan likes en las 00:00hs que deberían estar en la noche del 19 de + acumulan likes en las 00hs que deberían estar en la noche del 19 de febrero de 2024)

From c3ad7b81aeeef47ee920daeeacb0963dbf39af1e Mon Sep 17 00:00:00 2001 From: Nulo Date: Tue, 20 Feb 2024 00:27:29 -0300 Subject: [PATCH 13/21] refactor index prepararse para API para bot --- src/lib/data-processing/mostLiked.ts | 15 +++ src/lib/data-processing/screenTime.ts | 86 ++++++++++++++ src/lib/data-processing/weekly.ts | 44 +++++++ src/routes/+page.svelte | 165 ++------------------------ 4 files changed, 154 insertions(+), 156 deletions(-) create mode 100644 src/lib/data-processing/mostLiked.ts create mode 100644 src/lib/data-processing/screenTime.ts create mode 100644 src/lib/data-processing/weekly.ts diff --git a/src/lib/data-processing/mostLiked.ts b/src/lib/data-processing/mostLiked.ts new file mode 100644 index 0000000..f878a86 --- /dev/null +++ b/src/lib/data-processing/mostLiked.ts @@ -0,0 +1,15 @@ +import type { LikedTweet } from "../../schema"; + +export function sortMost(tweets: LikedTweet[]) { + const map = new Map(); + for (const tweet of tweets) { + const matches = tweet.url.match(/^https:\/\/twitter.com\/(.+?)\//); + if (!matches) continue; + const [, username] = matches; + map.set(username, (map.get(username) ?? 0) + 1); + } + return Array.from(map) + .filter(([, n]) => n > 3) + .sort(([, a], [, b]) => b - a) + .slice(0, 10); +} diff --git a/src/lib/data-processing/screenTime.ts b/src/lib/data-processing/screenTime.ts new file mode 100644 index 0000000..8c3ed27 --- /dev/null +++ b/src/lib/data-processing/screenTime.ts @@ -0,0 +1,86 @@ +import type { Dayjs } from "dayjs"; +import type { LikedTweet } from "../../schema"; +import dayjs from "dayjs"; +import { formatDuration, intervalToDuration } from "date-fns"; +import { es } from "date-fns/locale"; + +export type Duration = { start: Dayjs; end: Dayjs }; +export function calculateScreenTime(tweets: LikedTweet[]): Duration[] { + const n = 3; + const durations = tweets + .map((t) => dayjs(t.firstSeenAt)) + .map((d) => ({ start: d, end: d.add(n, "minute") })); + + type StartEnd = { + type: "start" | "end"; + date: Dayjs; + }; + const startEnds: Array = durations + .flatMap(({ start, end }) => [ + { type: "start", date: start }, + { type: "end", date: end }, + ]) + .sort(({ date: a }, { date: b }) => a.diff(b)); + + // console.debug(startEnds.map((x) => [x.type, x.date.toDate()])); + + let finalStartEnds: Array = []; + + // https://stackoverflow.com/questions/45109429/merge-sets-of-overlapping-time-periods-into-new-one + let i = 0; + for (const startEnd of startEnds) { + if (startEnd.type === "start") { + i++; + if (i === 1) finalStartEnds.push(startEnd); + } else { + if (i === 1) finalStartEnds.push(startEnd); + i--; + } + } + // console.debug(finalStartEnds.map((x) => [x.type, x.date.toDate()])); + + let finalDurations: Array = []; + + while (finalStartEnds.length > 0) { + const [start, end] = finalStartEnds.splice(0, 2); + if (start.type !== "start") throw new Error("expected start"); + if (end.type !== "end") throw new Error("expected end"); + finalDurations.push({ + start: start.date, + end: end.date.subtract(n, "minute").add(2, "minute"), + }); + } + return finalDurations; +} + +/** + * @returns number - en milisegundos + */ +export function totalFromDurations(durations: Duration[]): number { + let total = 0; + for (const duration of durations) { + const time = duration.end.diff(duration.start); + total += time; + } + return total; +} + +// https://stackoverflow.com/a/65711327 +export function formatDurationFromMs(ms: number) { + const duration = intervalToDuration({ start: 0, end: ms }); + return formatDuration(duration, { + locale: es, + delimiter: ", ", + format: ["hours", "minutes"], + }); +} +export function formatTinyDurationFromMs(ms: number) { + const duration = intervalToDuration({ start: 0, end: ms }); + // https://github.com/date-fns/date-fns/issues/2134 + return formatDuration(duration, { + locale: es, + format: ["hours", "minutes"], + }) + .replace(/ horas?/, "h") + .replace(/ minutos?/, "m"); +} diff --git a/src/lib/data-processing/weekly.ts b/src/lib/data-processing/weekly.ts new file mode 100644 index 0000000..f45dc62 --- /dev/null +++ b/src/lib/data-processing/weekly.ts @@ -0,0 +1,44 @@ +import dayjs from "dayjs"; +import Utc from "dayjs/plugin/utc"; +import Tz from "dayjs/plugin/timezone"; +dayjs.extend(Utc); +dayjs.extend(Tz); +import type { LikedTweet, MiniRetweet } from "../../schema"; +import { calculateScreenTime, totalFromDurations } from "./screenTime"; + +export function lastWeek( + allLiked: Array, + allRetweets: Array, +) { + const today = dayjs + .tz(undefined, "America/Argentina/Buenos_Aires") + .startOf("day"); + + const days = [ + today.subtract(7, "day"), + today.subtract(6, "day"), + today.subtract(5, "day"), + today.subtract(4, "day"), + today.subtract(3, "day"), + today.subtract(2, "day"), + today.subtract(1, "day"), + today, + ]; + + return days.map((day) => { + const tweets = allLiked.filter((t) => { + const date = dayjs(t.firstSeenAt); + return date.isAfter(day) && date.isBefore(day.add(1, "day")); + }); + const retweets = allRetweets.filter((t) => { + const date = dayjs(t.retweetAt); + return date.isAfter(day) && date.isBefore(day.add(1, "day")); + }); + return { + day, + tweets, + retweets, + screenTime: totalFromDurations(calculateScreenTime(tweets)), + }; + }); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 88d6ad2..6d0b479 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,17 +1,18 @@ + +
+
+ +
¡Ey!
+
+ Esta página es un experimento, y puede ser particularmente imprecisa o en + general ser una bosta. +
+
+ +

Últimos 200 likes de @JMilei

+ +
    + {#each data.tweets as tweet} +
  • + @{getUsernameFromUrl(tweet.url)}: + {tweet.text} + (likeado aprox. {timeFormatter.format(tweet.firstSeenAt)}) +
  • + {/each} +
+
From 711e2410ad62ff5c8f9c6cd3269246d61ee2b188 Mon Sep 17 00:00:00 2001 From: Nulo Date: Fri, 23 Feb 2024 23:28:21 -0300 Subject: [PATCH 20/21] chore: agregar devbox run dev --- devbox.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/devbox.json b/devbox.json index 9c851ef..b272a94 100644 --- a/devbox.json +++ b/devbox.json @@ -6,13 +6,9 @@ "sqlite@latest" ], "shell": { - "init_hook": [ - "echo 'Welcome to devbox!' > /dev/null" - ], + "init_hook": ["echo 'Welcome to devbox!' > /dev/null"], "scripts": { - "test": [ - "echo \"Error: no test specified\" && exit 1" - ] + "dev": ["pnpm dev"] } } } From 68d295228a8161d2d233e5f33f5741ceabcf4bfa Mon Sep 17 00:00:00 2001 From: Nulo Date: Fri, 23 Feb 2024 23:32:42 -0300 Subject: [PATCH 21/21] WIP: sleep time --- src/lib/data-processing/sleepTime.ts | 33 ++++++++++++++++++++++++++++ src/routes/+page.svelte | 29 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/lib/data-processing/sleepTime.ts diff --git a/src/lib/data-processing/sleepTime.ts b/src/lib/data-processing/sleepTime.ts new file mode 100644 index 0000000..d9279f0 --- /dev/null +++ b/src/lib/data-processing/sleepTime.ts @@ -0,0 +1,33 @@ +import dayjs from "dayjs"; +import Tz from "dayjs/plugin/timezone"; +dayjs.extend(Tz); +import type { MiniLikedTweet } from "../../schema"; + +export function getLastSleepTime(likedTweets: MiniLikedTweet[]) { + const diffs = likedTweets + .sort((a, b) => -b.firstSeenAt - -a.firstSeenAt) + .map((value, index, array) => { + const next = array[index + 1]; + if (next) { + return { ...value, diff: +next.firstSeenAt - +value.firstSeenAt }; + } + return value; + }) + .toReversed() + .map((x, index) => ({ ...x, index })); + const last = diffs.find( + (x): x is { index: number; diff: number } & MiniLikedTweet => { + const time = dayjs(x.firstSeenAt) + .tz("America/Argentina/Buenos_Aires") + .hour(); + return ( + "diff" in x && x.diff > 5 * 60 * 60 * 1000 && (time > 21 || time < 4) + ); + }, + ); + if (!last) return null; + const ultimoTuitAntesDeDormir = last; + const primerTuitAlDespertarse = diffs[last.index - 1]; + + return { primerTuitAlDespertarse, ultimoTuitAntesDeDormir }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3f11ea2..74c1c07 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -13,6 +13,7 @@ } from "$lib/data-processing/screenTime"; import { sortMost } from "$lib/data-processing/mostLiked"; import { lastWeek } from "$lib/data-processing/weekly"; + import { getLastSleepTime } from "$lib/data-processing/sleepTime"; export let data: PageData; @@ -63,6 +64,9 @@ $: ultimaSemana = lastWeek(data.tweets, data.retweets); + // TODO: calcular aplicando filtro + $: sleepTime = getLastSleepTime(data.tweets); + const timeFormatter = Intl.DateTimeFormat("es-AR", { timeStyle: "medium", timeZone: "America/Argentina/Buenos_Aires", @@ -74,6 +78,13 @@ timeZone: "America/Argentina/Buenos_Aires", }); + const sleepTimeFormatter = Intl.DateTimeFormat("es-AR", { + weekday: "short", + hour: "2-digit", + minute: "2-digit", + timeZone: "America/Argentina/Buenos_Aires", + }); + const weekDayFormatter = Intl.DateTimeFormat("es-AR", { day: "2-digit", weekday: "short", @@ -121,6 +132,24 @@ start={startTimeFilter} /> + + {#if sleepTime} + {@const { + ultimoTuitAntesDeDormir: dormir, + primerTuitAlDespertarse: despertar, + } = sleepTime} +
+ Durmió entre + {sleepTimeFormatter.format(dormir.firstSeenAt)} y {sleepTimeFormatter.format( + despertar.firstSeenAt, + )} + + (sería {formatDurationFromMs( + +despertar.firstSeenAt - +dormir.firstSeenAt, + )}) +
+ {/if} + {#if dudosoCrashScraper}