Add friendly scrape names and a completeness check

This commit is contained in:
Fisto
2026-09-21 10:40:53 +02:00
parent 19bf6e16fc
commit 3bba051971
7 changed files with 219 additions and 8 deletions
+4 -3
View File
@@ -117,9 +117,10 @@ pnpm testTypes # tsc --noEmit
pnpm lint pnpm lint
cd poc cd poc
pnpm check # type check the daemon pnpm check # type check the daemon
pnpm test:comm # unit tests pnpm test:comm # unit tests
pnpm test:ui # optional browser smoke test for the Media tab pnpm test:ui # optional browser smoke test for the Media tab
pnpm verify:scrapes # scrape completeness report (exit 1 when a scrape looks truncated)
``` ```
`pnpm test:ui` is optional. It needs a running daemon with media data and a Chromium: set `pnpm test:ui` is optional. It needs a running daemon with media data and a Chromium: set
+9
View File
@@ -40,6 +40,15 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C59 Scrape names and the completeness check. Scrape listings (History, Clean media,
Bot-tab job/lane cards) now lead with the first two words of the channel or group-chat name
plus the scrape id. New `poc/scripts/verify-scrapes.ts` (`pnpm verify:scrapes`) checks every
index: done state, no lastError, messages non-zero, messages vs the Discord search total, and
the oldest message against the source's snowflake creation time (60-day grace). Scrapes a
later full walk supersedes are reported as `old`, not failures. Result: 10 complete, 4
superseded, 1 genuinely incomplete — the early DM test scrape
`805255536223715370-20260916215657` (10 messages, pagination stopped before the fix, never
re-scraped).
- [x] C58 Titles from Discord's embed cache. Gone YouTube videos have no oEmbed answer, but - [x] C58 Titles from Discord's embed cache. Gone YouTube videos have no oEmbed answer, but
Discord keeps the embed title (and, with the rebuilt core bundle, the author/channel). The Discord keeps the embed title (and, with the rebuilt core bundle, the author/channel). The
title lane now asks the source message's embed first and only then the provider oEmbed; the title lane now asks the source message's embed first and only then the provider oEmbed; the
+14
View File
@@ -61,6 +61,20 @@ The mock also covers tiers, continuation, polls, reactions, and the action log.
with `set_puppet_config` before a test. Inject a reaction with with `set_puppet_config` before a test. Inject a reaction with
`TEST_TRIGGER_REACTION=1 TEST_REACTION_MESSAGE_ID=msg-1 TEST_REACTION_EMOJI="1️⃣" pnpm test:trigger`. `TEST_TRIGGER_REACTION=1 TEST_REACTION_MESSAGE_ID=msg-1 TEST_REACTION_EMOJI="1️⃣" pnpm test:trigger`.
## Scrape completeness
`pnpm verify:scrapes` (from `poc/`) walks every scrape index and reports whether the walk
plausibly reached the start of the source:
- state is `done`, no `lastError`, and the message count is non-zero
- the message count matches the recorded Discord search total when there is one
- the oldest message is close to the source's creation time, derived from the channel ID
snowflake (`--grace <days>` sets the allowance, default 60)
The age check is a heuristic: a channel can sit empty after creation, and a ranged scrape is
expected to stop early. A flagged scrape that a later full walk of the same source supersedes is
reported as `old` rather than a problem. Exit code is 1 when a scrape is genuinely incomplete.
## Web UI smoke test ## Web UI smoke test
The web UI starts with the daemon. The mock core is enough for this test. The web UI starts with the daemon. The mock core is enough for this test.
+11
View File
@@ -657,6 +657,17 @@ export class ScrapeStore {
.run(preview.hash, preview.bytes, preview.ext ?? "webp", id); .run(preview.hash, preview.bytes, preview.ext ?? "webp", id);
} }
/** Message span of the walk, for completeness checks. */
dbSummary(): { messages: number; oldest: string | null; newest: string | null } {
const row = this.db
.prepare(
"SELECT COUNT(*) AS messages, MIN(timestamp) AS oldest, MAX(timestamp) AS newest FROM messages"
)
.get() as { messages: number; oldest: string | null; newest: string | null } | undefined;
return { messages: row?.messages ?? 0, oldest: row?.oldest ?? null, newest: row?.newest ?? null };
}
/** Preview state for the whole scrape and per kind, for the backfill UI. */ /** Preview state for the whole scrape and per kind, for the backfill UI. */
previewCounts(): { previewCounts(): {
total: number; total: number;
+1
View File
@@ -12,6 +12,7 @@
"control:bridge": "tsx src/control-bridge.ts", "control:bridge": "tsx src/control-bridge.ts",
"check": "tsc --noEmit", "check": "tsc --noEmit",
"control:mock": "tsx src/mock-core.ts", "control:mock": "tsx src/mock-core.ts",
"verify:scrapes": "tsx scripts/verify-scrapes.ts",
"test:trigger": "tsx src/test-trigger.ts", "test:trigger": "tsx src/test-trigger.ts",
"cdp:dispatch": "tsx src/cdp-dispatch.ts", "cdp:dispatch": "tsx src/cdp-dispatch.ts",
"test:cache": "tsx src/test-cache.ts", "test:cache": "tsx src/test-cache.ts",
+28 -5
View File
@@ -866,7 +866,7 @@
for (const job of jobs) { for (const job of jobs) {
const row = el("div", `vk-bot-job vk-bot-job-${job.state}`); const row = el("div", `vk-bot-job vk-bot-job-${job.state}`);
const head = el("div", "vk-bot-job-head"); const head = el("div", "vk-bot-job-head");
head.append(el("span", "vk-bot-job-title", channelLabel(job.chatId))); head.append(el("span", "vk-bot-job-title", scrapeName(job.chatId)));
head.append(el("span", `vk-bot-state vk-bot-state-${job.state}`, job.state)); head.append(el("span", `vk-bot-state vk-bot-state-${job.state}`, job.state));
const parts = [`${job.pending} pending`]; const parts = [`${job.pending} pending`];
@@ -953,7 +953,7 @@
if (status.failed) parts.push(`${status.failed} failed`); if (status.failed) parts.push(`${status.failed} failed`);
if (status.perMinute) parts.push(`${status.perMinute}/min`); if (status.perMinute) parts.push(`${status.perMinute}/min`);
if (status.pending > 0) parts.push(`ETA ${formatEta(status.etaMs)}`); if (status.pending > 0) parts.push(`ETA ${formatEta(status.etaMs)}`);
if (status.currentScrapeId) parts.push(`on ${channelLabel(status.currentScrapeId)}`); if (status.currentScrapeId) parts.push(`on ${scrapeName(status.currentScrapeId)}`);
head.append(el("span", "vk-bot-job-meta", parts.join(" \u00b7 "))); head.append(el("span", "vk-bot-job-meta", parts.join(" \u00b7 ")));
titleLane.append(head); titleLane.append(head);
@@ -1005,7 +1005,7 @@
if (status.recovered) parts.push(`${status.recovered} recovered`); if (status.recovered) parts.push(`${status.recovered} recovered`);
if (status.deferred) parts.push(`${status.deferred} deferred`); if (status.deferred) parts.push(`${status.deferred} deferred`);
if (status.gone) parts.push(`${status.gone} unavailable`); if (status.gone) parts.push(`${status.gone} unavailable`);
if (status.currentScrapeId) parts.push(`on ${channelLabel(status.currentScrapeId)}`); if (status.currentScrapeId) parts.push(`on ${scrapeName(status.currentScrapeId)}`);
head.append(el("span", "vk-bot-job-meta", parts.join(" \u00b7 "))); head.append(el("span", "vk-bot-job-meta", parts.join(" \u00b7 ")));
recoverLane.append(head); recoverLane.append(head);
@@ -2451,6 +2451,20 @@
return state.channels.find(channel => channel.id === channelId)?.label ?? channelId; return state.channels.find(channel => channel.id === channelId)?.label ?? channelId;
} }
/** Short scrape name: the first two words of the channel or group-chat name. */
function scrapeName(channelId) {
const label = channelLabel(channelId);
const cleaned = label.includes("/#")
? label.split("/#")[1]
: label.replace(/\s*\((group DM|DM)\)\s*$/i, "");
const words = cleaned
.replace(/[_\-]+/g, " ")
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
return words.join(" ") || label;
}
async function openHistoryModal() { async function openHistoryModal() {
const list = historyModal.querySelector("#history-list"); const list = historyModal.querySelector("#history-list");
list.replaceChildren(el("div", null, "loading...")); list.replaceChildren(el("div", null, "loading..."));
@@ -2469,7 +2483,9 @@
for (const scrape of scrapes) { for (const scrape of scrapes) {
const media = scrape.media ?? {}; const media = scrape.media ?? {};
const row = el("div", "vk-media-history-row"); const row = el("div", "vk-media-history-row");
row.append(el("strong", null, channelLabel(scrape.channelId))); row.append(
el("strong", null, `${scrapeName(scrape.channelId)} · ${scrape.id}`)
);
row.append( row.append(
el( el(
"span", "span",
@@ -2894,7 +2910,14 @@
list.replaceChildren(); list.replaceChildren();
for (const scrape of scrapes) { for (const scrape of scrapes) {
list.append(el("div", null, `${scrape.id} · ${scrape.state} · ${formatBytes(scrape.bytes)}`)); list.append(
el(
"div",
null,
`${scrapeName(scrape.channelId)} · ${scrape.id} · ${scrape.state} · ` +
formatBytes(scrape.bytes)
)
);
} }
const bytes = scrapes.reduce((sum, scrape) => sum + scrape.bytes, 0); const bytes = scrapes.reduce((sum, scrape) => sum + scrape.bytes, 0);
+152
View File
@@ -0,0 +1,152 @@
/*
* Scrape completeness check.
*
* Reads every scrape index and reports whether the walk plausibly reached the start of the
* source:
* - state is done, no lastError, and the message count is non-zero
* - the message count matches the Discord search total when the scrape recorded one
* - the oldest message is close to the channel's creation time (derived from its snowflake)
*
* The last check is a heuristic: a channel can legitimately sit empty for a while after it is
* created, and a ranged scrape (From date) is expected to stop early. Anything flagged deserves
* a look, not necessarily a re-run.
*
* Usage: pnpm verify:scrapes [--db <path>] [--grace <days>]
*/
import { existsSync } from "node:fs";
import {
isoFromSnowflake,
listScrapes,
resolveDbRoot,
ScrapeStore,
scrapeDir
} from "../../media-scrape/src/index.js";
const args = process.argv.slice(2);
const valueOf = (flag: string) => {
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : undefined;
};
const dbRoot = valueOf("--db") ?? resolveDbRoot();
const graceDays = Number(valueOf("--grace") ?? 60);
const graceMs = (Number.isFinite(graceDays) ? graceDays : 60) * 86_400_000;
interface Verdict {
id: string;
channelId: string;
state: string;
backend: string;
messages: number;
estimated: number | null;
oldest: string | null;
created: string | null;
flags: string[];
/** Another scrape of the same source that completed and walked further. */
supersededBy?: string;
}
const results: Verdict[] = [];
for (const meta of listScrapes(dbRoot)) {
if (meta.state === "deleted" || meta.state === "deleting") continue;
const flags: string[] = [];
let messages = 0;
let oldest: string | null = null;
const dir = scrapeDir(dbRoot, meta.id);
if (!existsSync(dir)) {
flags.push("directory missing");
} else {
try {
const store = new ScrapeStore(dir);
try {
const row = store.dbSummary();
messages = row.messages;
oldest = row.oldest;
} finally {
store.close();
}
} catch (error) {
flags.push(`unreadable index: ${String(error)}`);
}
}
if (meta.state !== "done") flags.push(`state ${meta.state}`);
if (meta.lastError) flags.push(`lastError: ${meta.lastError}`);
if (messages === 0) flags.push("no messages indexed");
if (meta.estimatedMessages != null && messages < meta.estimatedMessages * 0.98) {
flags.push(`indexed ${messages} of ~${meta.estimatedMessages} search results`);
}
let created: string | null = null;
try {
created = isoFromSnowflake(meta.channelId);
} catch {
// not a snowflake; skip the age heuristic
}
if (!meta.fromDate && created && oldest) {
const age = new Date(oldest).getTime() - new Date(created).getTime();
if (Number.isFinite(age) && age > graceMs) {
flags.push(`oldest ${oldest.slice(0, 10)} vs source created ${created.slice(0, 10)}`);
}
}
results.push({
id: meta.id,
channelId: meta.channelId,
state: meta.state,
backend: meta.backend,
messages,
estimated: meta.estimatedMessages ?? null,
oldest,
created,
flags
});
}
// A flagged scrape that a later full walk of the same source supersedes is stale, not a problem.
for (const result of results) {
if (!result.flags.length) continue;
const replacement = results.find(
candidate =>
candidate !== result &&
candidate.channelId === result.channelId &&
candidate.flags.length === 0 &&
candidate.messages > result.messages
);
if (replacement) result.supersededBy = replacement.id;
}
const complete = results.filter(result => result.flags.length === 0);
const superseded = results.filter(result => result.flags.length > 0 && result.supersededBy);
const incomplete = results.filter(result => result.flags.length > 0 && !result.supersededBy);
console.log(
`scrape completeness: ${complete.length} complete, ${superseded.length} superseded, ` +
`${incomplete.length} incomplete`
);
console.log(`database root: ${dbRoot}\n`);
for (const result of results) {
const mark = !result.flags.length ? "ok " : result.supersededBy ? "old " : "FLAG";
const oldest = result.oldest ? result.oldest.slice(0, 10) : "----------";
const created = result.created ? result.created.slice(0, 10) : "----------";
console.log(
`${mark} ${result.id.padEnd(44)} ${result.state.padEnd(7)} ` +
`msgs ${String(result.messages).padStart(6)}/${String(result.estimated ?? "?").padStart(6)} ` +
`oldest ${oldest} (source ${created})` +
(result.supersededBy
? ` <- superseded by ${result.supersededBy}`
: result.flags.length
? ` <- ${result.flags.join("; ")}`
: "")
);
}
process.exitCode = incomplete.length ? 1 : 0;