-
Notifications
You must be signed in to change notification settings - Fork 0
Add decodo screenshot command #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import type { Command } from "commander"; | ||
|
|
||
| export const ROOT_COMMAND_ORDER = [ | ||
| "scrape", | ||
| "search", | ||
| "screenshot", | ||
| "setup", | ||
| "reset", | ||
| "whoami", | ||
| "targets", | ||
| ] as const; | ||
|
|
||
| export function sortCommandsByOrder( | ||
| commands: Command[], | ||
| order: readonly string[] = ROOT_COMMAND_ORDER | ||
| ): Command[] { | ||
| const orderIndex = new Map(order.map((name, index) => [name, index])); | ||
| const fallbackIndex = order.length; | ||
|
|
||
| return [...commands].sort((a, b) => { | ||
| const aIndex = orderIndex.get(a.name()) ?? fallbackIndex; | ||
| const bIndex = orderIndex.get(b.name()) ?? fallbackIndex; | ||
|
|
||
| if (aIndex !== bIndex) { | ||
| return aIndex - bIndex; | ||
| } | ||
|
|
||
| return a.name().localeCompare(b.name()); | ||
|
Comment on lines
+17
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm having a hard time understanding reasoning here, but as I've understand the idea is to have ROOT_COMMAND_ORDER as first commands, then everything else?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, you go it right. Not sure how to better write this, since there is no option for sort order in commander. Maybe better would be to have an export SORT_ORDER = ... for each command but that still requires a registry to map all the commands so it's kind of the same as it is right now |
||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { statSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| export function resolveOutputFilePath( | ||
| outputPath: string, | ||
| defaultFileName: string | ||
| ): string { | ||
| try { | ||
| if (statSync(outputPath).isDirectory()) { | ||
| return join(outputPath, defaultFileName); | ||
| } | ||
| } catch { | ||
| // Path missing or inaccessible — treat as a file path. | ||
| } | ||
|
|
||
| return outputPath; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { writeFileSync } from "node:fs"; | ||
| import { resolve } from "node:path"; | ||
| import { EXIT } from "../constants.js"; | ||
| import { resolveOutputFilePath } from "./resolve-output-file.js"; | ||
|
|
||
| export const BINARY_TTY_ERROR = | ||
| "Refusing to write binary data to a TTY. Use -o <file> or pipe to a file."; | ||
|
|
||
| export function writeBinaryOutput( | ||
| bytes: Buffer, | ||
| options: { output?: string; defaultFileName?: string } | ||
| ): void { | ||
| if (options.output !== undefined) { | ||
| const filePath = | ||
| options.defaultFileName === undefined | ||
| ? options.output | ||
| : resolveOutputFilePath(options.output, options.defaultFileName); | ||
| writeFileSync(filePath, bytes); | ||
| console.error(`Wrote ${resolve(filePath)}`); | ||
| return; | ||
| } | ||
|
|
||
| if (process.stdout.isTTY) { | ||
| console.error(`Error: ${BINARY_TTY_ERROR}`); | ||
| process.exit(EXIT.USAGE); | ||
| } | ||
|
|
||
| process.stdout.write(bytes); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { type DecodoSchema, Target } from "@decodo/sdk-ts"; | ||
| import { Command } from "commander"; | ||
| import { writeBinaryOutput } from "../../platform/services/write-binary.js"; | ||
| import { extractPngFromResponse } from "../services/extract-png.js"; | ||
| import { resolveTarget } from "../services/resolve-target.js"; | ||
| import { createTargetAction } from "../services/run-target-scrape.js"; | ||
| import { defaultScreenshotFilename } from "../services/screenshot-output-filename.js"; | ||
| import type { ScreenshotOptions } from "../types/screenshot-command.js"; | ||
|
|
||
| export function createScreenshotCommand(schema: DecodoSchema): Command { | ||
| return new Command("screenshot") | ||
| .description( | ||
| "Capture a PNG screenshot (universal, headless). Use decodo universal --headless png for full options." | ||
| ) | ||
| .argument("<url>", "URL to screenshot") | ||
| .option("--country <code>", "Geo / country code (maps to geo)") | ||
| .option( | ||
| "-o, --output <path>", | ||
| "Write PNG to file or directory (default name: <host>.png)" | ||
| ) | ||
| .option("--target <name>", "Scrape target override (default: universal)") | ||
| .action( | ||
| createTargetAction( | ||
| Target.Universal, | ||
| schema, | ||
| (url, options) => { | ||
| if (url === undefined) { | ||
| throw new Error("Missing required URL."); | ||
| } | ||
|
|
||
| const opts = options as ScreenshotOptions; | ||
| const body: Record<string, unknown> = { | ||
| target: resolveTarget(opts.target, schema, Target.Universal), | ||
| url, | ||
| headless: "png", | ||
| }; | ||
|
|
||
| if (opts.country !== undefined) { | ||
| body.geo = opts.country; | ||
| } | ||
|
|
||
| return body; | ||
| }, | ||
| (response, options, url) => { | ||
| const opts = options as ScreenshotOptions; | ||
| writeBinaryOutput(extractPngFromResponse(response), { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about filename? Maybe we should have option for this too.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. -o, --output already accepts a full file path (see line 16). If filename only variant is needed it can be added but I think the full path covers that use case There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what about if path is a directory?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good point, with previous changes it just throws which is bad ux, if folder is specified then by default it will save to a predefined filepath, updated pr description on how it works with new changes |
||
| output: opts.output, | ||
| defaultFileName: defaultScreenshotFilename(url), | ||
| }); | ||
| } | ||
| ) | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { type SyncResponse, ValidationError } from "@decodo/sdk-ts"; | ||
|
|
||
| const PNG_SIGNATURE = Buffer.from([ | ||
| 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, | ||
| ]); | ||
|
|
||
| function isPngBuffer(buffer: Buffer): boolean { | ||
| return ( | ||
| buffer.length >= PNG_SIGNATURE.length && | ||
| buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) | ||
| ); | ||
| } | ||
|
|
||
| export function extractPngFromResponse(response: SyncResponse): Buffer { | ||
| const entry = response.results[0]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if result will be empty.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is handled in this check: |
||
| if (entry === undefined) { | ||
| throw new ValidationError("Screenshot response has no results."); | ||
| } | ||
|
|
||
| const { content } = entry; | ||
| if (typeof content !== "string" || content.length === 0) { | ||
| throw new ValidationError("Screenshot response has no PNG content."); | ||
| } | ||
|
|
||
| const bytes = Buffer.from(content, "base64"); | ||
| if (bytes.length === 0 || !isPngBuffer(bytes)) { | ||
| throw new ValidationError("Screenshot response content is not valid PNG."); | ||
| } | ||
|
|
||
| return bytes; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| const DEFAULT_SCREENSHOT_FILENAME = "screenshot.png"; | ||
|
|
||
| export function defaultScreenshotFilename(url: string | undefined): string { | ||
| if (url === undefined || url.length === 0) { | ||
| return DEFAULT_SCREENSHOT_FILENAME; | ||
| } | ||
|
|
||
| try { | ||
| const hostname = new URL(url).hostname; | ||
| const safe = hostname.replace(/[^a-zA-Z0-9.-]+/g, "-"); | ||
| if (safe.length > 0) { | ||
| return `${safe}.png`; | ||
| } | ||
| } catch { | ||
| // Fall through to default. | ||
| } | ||
|
|
||
| return DEFAULT_SCREENSHOT_FILENAME; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import type { SyncResponse } from "@decodo/sdk-ts"; | ||
|
|
||
| export type ScrapeBodyBuilder = ( | ||
| input: string | undefined, | ||
| options: Record<string, unknown> | ||
| ) => Record<string, unknown>; | ||
|
|
||
| export type ScrapeResponseHandler = ( | ||
| response: SyncResponse, | ||
| options: Record<string, unknown>, | ||
| input?: string | ||
| ) => void | Promise<void>; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| export interface ScreenshotOptions { | ||
| country?: string; | ||
| output?: string; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you sure all three are optional?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this mirrors command options: output is only needed when stdout is a TTY |
||
| target?: string; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { Command } from "commander"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { sortCommandsByOrder } from "../../../src/cli/services/sort-commands-by-order.js"; | ||
|
|
||
| describe("sortCommandsByOrder", () => { | ||
| it("sorts known commands by explicit order", () => { | ||
| const commands = [ | ||
| new Command("whoami"), | ||
| new Command("scrape"), | ||
| new Command("universal"), | ||
| new Command("search"), | ||
| new Command("setup"), | ||
| ]; | ||
|
|
||
| expect(sortCommandsByOrder(commands).map((c) => c.name())).toEqual([ | ||
| "scrape", | ||
| "search", | ||
| "setup", | ||
| "whoami", | ||
| "universal", | ||
| ]); | ||
| }); | ||
|
|
||
| it("sorts unlisted commands after known ones, alphabetically", () => { | ||
| const commands = [ | ||
| new Command("zebra-target"), | ||
| new Command("scrape"), | ||
| new Command("alpha-target"), | ||
| ]; | ||
|
|
||
| expect(sortCommandsByOrder(commands).map((c) => c.name())).toEqual([ | ||
| "scrape", | ||
| "alpha-target", | ||
| "zebra-target", | ||
| ]); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if possible, but maybe we can create groups, like:
User:
Scrape:
and when you will use --help it shows as grouped commands.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code is more about sorting commands which favor top level over target specific ones. Grouping is possible and can be done but I think it's product desicion, separate task