Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions src/automation/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getCredential } from "../credentials.js";
import { buildAddCustomFoodCode } from "../kernel/add-custom-food.js";
import { buildDiaryCode } from "../kernel/diary.js";
import { buildLogFoodCode } from "../kernel/log-food.js";
import { buildRecipesCode } from "../kernel/recipes.js";
import {
buildAutoLoginCode,
buildLoginCheckCode,
Expand All @@ -19,6 +20,7 @@ import type {
LogFoodEntry,
MacroEntry,
PlaywrightExecutionResponse,
RecipeData,
WeightData,
} from "./types.js";

Expand Down Expand Up @@ -97,6 +99,21 @@ export function createAutomationClient(
);
}
}),
getRecipes: (onStatus?: (msg: string) => void) =>
withLoggedInRuntime(createRuntime, onStatus, async (runtime) => {
onStatus?.("Reading custom recipes...");
const data = await executeAutomation<{
success: boolean;
recipes?: RecipeData[];
error?: string;
}>(runtime, buildRecipesCode(), 30);
if (!data.success) {
throw new Error(
`Recipes read failed: ${data.error ?? "Unknown error"}`
);
}
return data.recipes ?? [];
}),
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/automation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export interface LogFoodEntry {
servings?: number;
}

export interface RecipeData {
name: string;
}

export interface AutomationClient {
addQuickEntry(
entry: MacroEntry,
Expand All @@ -64,6 +68,7 @@ export interface AutomationClient {
onStatus?: (msg: string) => void
): Promise<void>;
logFood(entry: LogFoodEntry, onStatus?: (msg: string) => void): Promise<void>;
getRecipes(onStatus?: (msg: string) => void): Promise<RecipeData[]>;
}

export interface PlaywrightExecutionResponse<T = unknown> {
Expand Down
40 changes: 40 additions & 0 deletions src/commands/recipes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as p from "@clack/prompts";
import { getAutomationClient } from "../automation/client.js";
import { formatKernelError } from "../kernel/errors.js";

export interface RecipesOptions {
json?: boolean;
}

export async function recipes(options: RecipesOptions): Promise<void> {
if (!options.json) {
p.intro("Crono recipes");
}

const s = options.json ? null : p.spinner();
s?.start("Connecting...");

try {
const client = await getAutomationClient();
const list = await client.getRecipes((msg) => s?.message(msg));

s?.stop("Done.");

if (options.json) {
console.log(JSON.stringify(list, null, 2));
} else {
if (list.length === 0) {
p.outro("No custom recipes found.");
} else {
for (const recipe of list) {
p.log.info(recipe.name);
}
p.outro(`${list.length} recipe${list.length === 1 ? "" : "s"}`);
}
}
} catch (error) {
s?.stop("Failed.");
p.log.error(`Failed to read recipes: ${formatKernelError(error)}`);
process.exit(1);
}
}
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { log } from "./commands/log.js";
import { diary } from "./commands/diary.js";
import { weight } from "./commands/weight.js";
import { exportCmd } from "./commands/export.js";
import { recipes } from "./commands/recipes.js";

const require = createRequire(import.meta.url);
const { version } = require("../package.json");
Expand Down Expand Up @@ -111,4 +112,12 @@ program
await exportCmd(type, options);
});

program
.command("recipes")
.description("List your custom recipes from Cronometer")
.option("--json", "Output as JSON")
.action(async (options) => {
await recipes(options);
});

program.parse();
37 changes: 37 additions & 0 deletions src/kernel/recipes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Playwright code generator for Cronometer custom recipes scraping.
*
* Recipe names live in div.gwt-Label elements inside div[role="button"]
* rows within the scrollable list on the #custom-recipes page.
*
* Returns { success: true, recipes: [{ name }] }
*/
export function buildRecipesCode(): string {
return `
await page.goto('https://cronometer.com/#custom-recipes', { waitUntil: 'domcontentloaded', timeout: 15000 });

const url = page.url();
if (url.includes('/login') || url.includes('/signin')) {
return { success: false, error: 'Not logged in. Login may have failed.' };
}

// Wait for the GWT widget to render the recipe list.
// The search input appears before list items finish rendering, so we wait
// for an actual recipe row to be present before scraping.
await page.waitForSelector('input[placeholder*="recipes"]', { timeout: 10000 });
await page.waitForSelector('div[role="button"] .gwt-Label', { timeout: 10000 }).catch(() => {});

const recipes = await page.evaluate(() => {
// Recipe rows are div[role="button"] elements containing a div.gwt-Label child
const rows = Array.from(document.querySelectorAll('div[role="button"]'));
return rows
.map((row) => {
const label = row.querySelector('.gwt-Label');
return label ? { name: label.textContent?.trim() ?? '' } : null;
})
.filter((r) => r !== null && r.name.length > 0);
});

return { success: true, recipes };
`;
}
Loading