Skip to content
2 changes: 1 addition & 1 deletion src/auth/commands/setup.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { Command } from "commander";
import { getRootOpts } from "../../cli/services/global-opts.js";
import {
CliUsageError,
handleCliError,
} from "../../platform/services/handle-cli-error.js";
import { validateAuthToken } from "../../scrape/services/auth-validation.js";
import { PLAYGROUND_URL } from "../constants.js";
import { getConfigPath, writeConfig } from "../services/config.js";
import { getRootOpts } from "../services/global-opts.js";

const TOKEN_PROMPT = `Paste your Web Scraping API basic auth token (${PLAYGROUND_URL}): `;

Expand Down
2 changes: 1 addition & 1 deletion src/auth/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { getRootOpts } from "../../cli/services/global-opts.js";
import { handleCliError } from "../../platform/services/handle-cli-error.js";
import { AuthRequiredError } from "../errors/auth-required-error.js";
import { getRootOpts } from "../services/global-opts.js";
import { mask } from "../services/mask.js";
import { resolveAuthToken } from "../services/resolve-token.js";

Expand Down
11 changes: 0 additions & 11 deletions src/auth/services/global-opts.ts

This file was deleted.

16 changes: 16 additions & 0 deletions src/cli/services/global-opts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Command } from "commander";

export interface RootOptions {
token?: string;
verbose?: boolean;
}

export function getRootOpts(command: Command): RootOptions {
let current: Command = command;

while (current.parent) {
current = current.parent;
}

return current.opts() as RootOptions;
}
7 changes: 7 additions & 0 deletions src/cli/services/verbose-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function verboseLog(enabled: boolean, message: string): void {
if (!enabled) {
return;
}

process.stderr.write(`[verbose] ${message}\n`);
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const program = new Command()
.name("decodo")
.description("Official CLI for Decodo APIs")
.version(readVersion(), "-V, --version", "output the version number")
.option("-v, --verbose", "Print debug logs to stderr")
.option(
"--token <token>",
"Basic auth token (overrides DECODO_AUTH_TOKEN and saved config)"
Expand Down
39 changes: 39 additions & 0 deletions src/scrape/services/format-scrape-request-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const SENSITIVE_QUERY_PARAM_KEYS = new Set([
"auth",
"authorization",
"apikey",
"api_key",
"key",
"password",
"secret",
"token",
]);

function sanitizeUrlForLog(value: string): string {
try {
const parsed = new URL(value);
for (const key of parsed.searchParams.keys()) {
if (SENSITIVE_QUERY_PARAM_KEYS.has(key.toLowerCase())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id suggest flipping this inside out to check if key.toLowerCase() has any of the SENSITIVE_QUERY_PARAM_KEYS. This would catch cases where key may be access_token.

thank u claude

parsed.searchParams.set(key, "<redacted>");
}
}
return parsed.toString();
} catch {
return value;
}
}

export function formatScrapeRequestLog(body: Record<string, unknown>): string {
const target = typeof body.target === "string" ? body.target : "unknown";
const url = typeof body.url === "string" ? sanitizeUrlForLog(body.url) : null;
if (url !== null) {
return `request target=${target} url=${url}`;
}

const query = typeof body.query === "string" ? body.query : null;
if (query !== null) {
return `request target=${target} query=${query}`;
}

return `request target=${target}`;
}
31 changes: 26 additions & 5 deletions src/scrape/services/run-target-scrape.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { DecodoSchema, ScrapeRequest } from "@decodo/sdk-ts";
import type { Command } from "commander";
import { getRootOpts } from "../../auth/services/global-opts.js";
import { requireAuthToken } from "../../auth/services/resolve-token.js";
import { AuthRequiredError } from "../../auth/errors/auth-required-error.js";
import { resolveAuthToken } from "../../auth/services/resolve-token.js";
import { getRootOpts } from "../../cli/services/global-opts.js";
import { verboseLog } from "../../cli/services/verbose-log.js";
import { writeScrapeResponse } from "../../output/services/write-scrape-response.js";
import type { OutputOptions } from "../../output/types/output-options.js";
import type { WriteScrapeResponseContext } from "../../output/types/write-scrape-response.js";
Expand All @@ -12,19 +14,23 @@ import type {
} from "../types/run-target-scrape.js";
import { createDecodoClient } from "./client.js";
import { buildScrapeBody, getTargetCommandConfig } from "./command-builder.js";
import { formatScrapeRequestLog } from "./format-scrape-request-log.js";

export async function executeScrape(
token: string,
schema: DecodoSchema,
body: Record<string, unknown>,
options: Record<string, unknown>,
outputContext?: Partial<WriteScrapeResponseContext>,
input?: string
input?: string,
verbose = false
): Promise<void> {
const client = createDecodoClient(token, schema);
const startedAt = Date.now();
const response = await client.webScrapingApi.scrape(
body as unknown as ScrapeRequest
);
verboseLog(verbose, `response latency_ms=${Date.now() - startedAt}`);

writeScrapeResponse(response, {
options: options as OutputOptions,
Expand All @@ -51,12 +57,27 @@ export function createTargetAction(
command: Command
): Promise<void> => {
const rootOpts = getRootOpts(command);
const verbose = rootOpts.verbose === true;

try {
const token = await requireAuthToken({ token: rootOpts.token });
const auth = await resolveAuthToken({ token: rootOpts.token });
verboseLog(verbose, `auth source=${auth.source}`);
if (!auth.token) {
throw new AuthRequiredError();
}

const body = resolveBody(input, options);
verboseLog(verbose, formatScrapeRequestLog(body));
const outputContext = getOutputContext?.(input, options);
await executeScrape(token, schema, body, options, outputContext, input);
await executeScrape(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider moving params to an object (not necessarily this PR)

auth.token,
schema,
body,
options,
outputContext,
input,
verbose
);
} catch (err) {
handleCliError(err, { fallbackMessage: "Scrape failed." });
}
Expand Down
10 changes: 10 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,14 @@ describe("cli", () => {

expect(output).toBe(packageJson.version);
});

it("shows verbose flag in root help", () => {
const output = execFileSync(
process.execPath,
[join(rootDir, "..", "build", "esm", "index.js"), "--help"],
{ encoding: "utf8" }
);

expect(output).toContain("-v, --verbose");
});
});
9 changes: 6 additions & 3 deletions tests/scrape/commands/scrape.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { BundledSchema, ValidationError } from "@decodo/sdk-ts";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { requireAuthToken } from "../../../src/auth/services/resolve-token.js";
import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js";
import { createScrapeCommand } from "../../../src/scrape/commands/scrape.js";
import { createDecodoClient } from "../../../src/scrape/services/client.js";

vi.mock("../../../src/auth/services/resolve-token.js", () => ({
requireAuthToken: vi.fn(),
resolveAuthToken: vi.fn(),
}));

vi.mock("../../../src/scrape/services/client.js", () => ({
Expand All @@ -26,7 +26,10 @@ describe("createScrapeCommand", () => {
configurable: true,
});

vi.mocked(requireAuthToken).mockResolvedValue("test-token");
vi.mocked(resolveAuthToken).mockResolvedValue({
token: "test-token",
source: "flag",
});
vi.spyOn(process, "exit").mockImplementation((code) => {
exitCode = code as number;
throw new Error(`process.exit:${code}`);
Expand Down
9 changes: 6 additions & 3 deletions tests/scrape/commands/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from "node:path";
import { BundledSchema, ValidationError } from "@decodo/sdk-ts";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { requireAuthToken } from "../../../src/auth/services/resolve-token.js";
import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js";
import { BINARY_TTY_ERROR } from "../../../src/platform/services/write-binary.js";
import { createScreenshotCommand } from "../../../src/scrape/commands/screenshot.js";
import { createDecodoClient } from "../../../src/scrape/services/client.js";
Expand All @@ -13,7 +13,7 @@ const MINIMAL_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAD0JEQVQImWP4GwADAwEAAv6C7p4AAAAASUVORK5CYII=";

vi.mock("../../../src/auth/services/resolve-token.js", () => ({
requireAuthToken: vi.fn(),
resolveAuthToken: vi.fn(),
}));

vi.mock("../../../src/scrape/services/client.js", () => ({
Expand All @@ -30,7 +30,10 @@ describe("createScreenshotCommand", () => {
stderr = [];
stdoutBytes = undefined;

vi.mocked(requireAuthToken).mockResolvedValue("test-token");
vi.mocked(resolveAuthToken).mockResolvedValue({
token: "test-token",
source: "flag",
});
vi.spyOn(process, "exit").mockImplementation((code) => {
exitCode = code as number;
throw new Error(`process.exit:${code}`);
Expand Down
9 changes: 6 additions & 3 deletions tests/scrape/commands/search.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { BundledSchema, ValidationError } from "@decodo/sdk-ts";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { requireAuthToken } from "../../../src/auth/services/resolve-token.js";
import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js";
import { createSearchCommand } from "../../../src/scrape/commands/search.js";
import { createDecodoClient } from "../../../src/scrape/services/client.js";

vi.mock("../../../src/auth/services/resolve-token.js", () => ({
requireAuthToken: vi.fn(),
resolveAuthToken: vi.fn(),
}));

vi.mock("../../../src/scrape/services/client.js", () => ({
Expand All @@ -26,7 +26,10 @@ describe("createSearchCommand", () => {
configurable: true,
});

vi.mocked(requireAuthToken).mockResolvedValue("test-token");
vi.mocked(resolveAuthToken).mockResolvedValue({
token: "test-token",
source: "flag",
});
vi.spyOn(process, "exit").mockImplementation((code) => {
exitCode = code as number;
throw new Error(`process.exit:${code}`);
Expand Down
27 changes: 27 additions & 0 deletions tests/scrape/services/format-scrape-request-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { formatScrapeRequestLog } from "../../../src/scrape/services/format-scrape-request-log.js";

describe("formatScrapeRequestLog", () => {
it("formats query-based requests", () => {
expect(
formatScrapeRequestLog({ target: "google_search", query: "coffee" })
).toBe("request target=google_search query=coffee");
});

it("redacts sensitive URL query params", () => {
expect(
formatScrapeRequestLog({
target: "universal",
url: "https://example.com?token=secret&page=1",
})
).toBe(
"request target=universal url=https://example.com/?token=%3Credacted%3E&page=1"
);
});

it("falls back to target only when body has no url or query", () => {
expect(formatScrapeRequestLog({ target: "amazon_product" })).toBe(
"request target=amazon_product"
);
});
});
Loading
Loading