Resume paused scrapes from their cursor

This commit is contained in:
Fisto
2026-09-17 00:14:57 +02:00
parent 089bca0d4f
commit 0664b54241
10 changed files with 110 additions and 0 deletions
+5
View File
@@ -40,6 +40,11 @@ mobile draft at 390px.
- [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`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C34 Resume from cursor. A paused scrape can continue where it stopped instead of
re-walking the top: `POST /api/media/scrape/resume`, MCP `resume_media_scrape`, and a Resume
button in History. Previews never needed the walk — backfill fetches them straight from the
stored URLs; the walk is only for discovery. Live: the paused `gernal_chut` scrape resumed at
cursor `365256427247239181`, fetched its final page, and entered the preview pass.
- [x] C33 Puppet history pagination. The core's `message.fetch` read the client store, which
keeps only the newest messages plus the channel's first — every page was 10 messages and the
walk stopped after one page (a 10-message, 4-media scrape of an ancient group with thousands
+1
View File
@@ -83,6 +83,7 @@ The daemon exposes MCP over Streamable HTTP. The default address is `http://127.
| `list_media_scrapes` | Media scrapes with state, pages, messages, and counts. |
| `get_media_scrape` | One scrape by ID. |
| `stop_media_scrape` | Stop a scrape; it pauses after the current page. |
| `resume_media_scrape` | Continue a paused scrape from its cursor. |
| `get_media_storage` | Usage against the limit, per-kind sizes, and the scrape list. |
| `query_media` | Media items across scrapes (`kind?`, `source?`, `scrapeId?`, `channelId?`, `limit?`, `offset?`). |
| `get_media_item` | One media item by `scrapeId` and `itemId`. |
+3
View File
@@ -39,6 +39,9 @@ Rules:
- One running scrape at a time. Further requests queue.
- `stop` marks the job and lets the current page finish.
- `resume` continues a paused scrape from its stored cursor, so an interrupted walk does not fetch
the pages it already has again. The History popup has a **Resume** button and the API a
`POST /api/media/scrape/resume` route.
- Every page is an outbound read. Sends from commands keep their own pacing.
## Pacing and backoff
+1
View File
@@ -272,6 +272,7 @@ replaceable.
| GET | `/api/media/errors` | none | Bot and scrape errors, newest first |
| POST | `/api/media/errors/clear` | none | Clear the error list |
| POST | `/api/media/scrape/stop` | `id` | Stop a media scrape |
| POST | `/api/media/scrape/resume` | `id` | Continue a paused scrape from its cursor |
| POST | `/api/media/clean` | `ids?` | Delete the given scrapes, or every scrape when `ids` is absent |
| GET | `/api/logs` | `limit` | The persistent action log |
| GET | `/api/events` | none | Server-sent events |
+16
View File
@@ -182,6 +182,22 @@ export class ScrapeEngine {
this.stopRequested.add(id);
}
/** Continue a paused scrape from its stored cursor instead of walking the top again. */
async resume(scrapeId: string, backend: ScrapeBackend): Promise<ScrapeMeta | null> {
const meta = this.active.get(scrapeId) ?? readScrapeMeta(join(this.options.rootDir, scrapeId));
if (!meta) return null;
if (meta.state === "running" || meta.state === "queued") return { ...meta };
this.stopRequested.delete(scrapeId);
meta.state = "queued";
this.active.set(scrapeId, meta);
writeScrapeMeta(join(this.options.rootDir, scrapeId), meta);
this.queue.push({ meta, dir: join(this.options.rootDir, scrapeId), backend });
void this.processQueue();
return { ...meta };
}
/** Cache previews for rows a finished or paused scrape never reached. Returns the count. */
async backfillPreviews(scrapeId: string): Promise<number> {
const meta = this.active.get(scrapeId) ?? readScrapeMeta(join(this.options.rootDir, scrapeId));
+27
View File
@@ -877,6 +877,33 @@ void (async () => {
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
});
await test("engine resumes a paused scrape from its cursor", async () => {
const first = new FakeBackend([
{ messages: [message("500"), message("499")] },
{ messages: [message("498")] }
]);
const engine = engineFor(root, first);
const started = await engine.start({ channelId: "140", guildId: null, mode: "links", backend: first });
engine.stop(started.id);
await engine.processQueue();
let meta = engine.get(started.id);
assert.equal(meta?.state, "paused");
assert.equal(meta?.cursor, "499");
assert.equal(meta?.messages, 2);
const second = new FakeBackend([{ messages: [message("498")] }]);
await engine.resume(started.id, second);
await engine.processQueue();
meta = engine.get(started.id);
assert.equal(meta?.state, "done");
assert.equal(meta?.messages, 3);
assert.equal(meta?.cursor, "498");
assert.equal(second.calls[0].before, "499");
});
await test("backfillPreviews fills rows left by a preview-less walk", async () => {
const backend = new FakeBackend([
{
+14
View File
@@ -1104,6 +1104,20 @@
`${formatBytes(bytesById.get(scrape.id) ?? 0)} · files ${scrape.downloaded ?? 0} · previews ${scrape.previewed ?? 0}`
)
);
if (["paused", "queued"].includes(scrape.state)) {
row.append(
button("Resume", async () => {
try {
await window.VK.api.post("/api/media/scrape/resume", { id: scrape.id });
} catch (error) {
setStatus(`resume failed: ${error.message}`);
}
void loadScrapes();
void openHistoryModal();
})
);
}
list.append(row);
}
+9
View File
@@ -836,6 +836,15 @@ function buildServer(): McpServer {
async ({ scrapeId }) => jsonText(media.getScrape(scrapeId))
);
server.registerTool(
"resume_media_scrape",
{
description: "Continue a paused media scrape from its cursor.",
inputSchema: { scrapeId: z.string().describe("Scrape ID") }
},
async ({ scrapeId }) => jsonText(await media.resumeScrape(scrapeId))
);
server.registerTool(
"stop_media_scrape",
{
+22
View File
@@ -384,6 +384,28 @@ export class MediaService {
this.engine.stop(id);
}
/** Continue a paused scrape from its cursor with the same backend family. */
async resumeScrape(scrapeId: string): Promise<ScrapeMeta | null> {
const meta = this.engine.get(scrapeId);
if (!meta) return null;
let backend: ScrapeBackend;
if (meta.backend === "slopbot" && meta.guildId) {
const available = await this.ensureSlopbot();
const inGuild = this.bot?.guilds.cache.has(meta.guildId) ?? false;
const fetcher = this.botFetcher;
if (!available || !inGuild || !fetcher) {
throw new Error("slopbot is not connected to that guild");
}
backend = new SlopbotBackend(fetcher);
} else {
backend = this.puppetBackend();
}
this.deps.log?.(`resuming scrape ${scrapeId} from cursor ${meta.cursor ?? "start"}`);
return this.engine.resume(scrapeId, backend);
}
listScrapes(): ScrapeMeta[] {
return this.engine.list();
}
+12
View File
@@ -980,6 +980,18 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
})
);
app.post(
"/api/media/scrape/resume",
wrap(async (req, res) => {
const id = String(req.body?.id ?? "");
if (!id) throw new HttpError(400, "id is required");
const scrape = await deps.media.resumeScrape(id);
deps.getPuppet()?.appendAction({ type: "media", action: "resume", scrapeId: id });
res.json({ ok: true, result: scrape });
})
);
app.post(
"/api/media/scrape/stop",
wrap(async (req, res) => {