Add friendly scrape names and a completeness check
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user