Files
kegel/store/asc-fill.js
marcuspaico 2d74b7d152
Some checks failed
Build & Release APK / build (push) Has been cancelled
chore(store): App Store Connect API client and listing push script
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WontRuPqKcod9XK4ZN9MXv
2026-09-07 16:56:15 +10:00

153 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// Push the App Store listing from store/listing.md into App Store Connect.
// Idempotent: re-running overwrites text fields and replaces the screenshot set.
//
// node store/asc-fill.js
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { api, APP_ID } = require("./asc");
const NAME = "Him: Kegel Exercises for Men";
const SUBTITLE = "Pelvic Floor Trainer & Timer";
const PRIVACY_URL = "https://rehbock.xyz/kegel/privacy.html";
const SUPPORT_URL = "https://rehbock.xyz/kegel/privacy.html";
const KEYWORDS = "kegels,reverse,workout,stamina,control,bladder,prostate,strength,daily,free,squeeze,hold,guided";
const PROMO = "Free, no account, no ads, works offline. Guided kegel sessions that get harder as you get stronger.";
const DESCRIPTION = `A free pelvic floor trainer for men. No account, no ads, no subscription, no data collection. Everything runs on your phone.
Each session is a guided, four-block routine driven by a step timer with haptic cues so you can train with the screen off or your eyes closed:
• Holds — 8 slow squeeze-and-hold reps. The core of the workout. Holds start at 5 seconds and automatically grow by 1 second every 3 completed sessions, up to 30 seconds.
• Pushes — 5 reverse kegels (gently bear down) to balance tension and relaxation.
• Quick squeezes — 10 one-second burst contractions.
• Quick pushes — 10 one-second burst releases.
Progressive overload is built in. Finish sessions and the holds get longer on their own. Want to go faster or slower? Adjust hold time manually in Settings.
Stay consistent with a day streak, your best streak, and a two-week activity strip on the home screen.
Why kegels?
Pelvic floor exercises strengthen the muscles that support the bladder and bowel and are widely recommended for bladder control, post-prostate-surgery recovery, and general pelvic health. Like any muscle, they respond to regular, progressively harder training.
What this app is not
It is a timer and progression tracker, not a medical device, and gives no medical advice. If you have a medical condition or recent surgery, talk to a clinician first.
Privacy
Zero network access. Your session count, difficulty and history are stored only on your device. Delete the app and the data is gone.`;
const SHOTS_DIR = path.join(__dirname, "screenshots", "out", "ios");
const DISPLAY_TYPE = "APP_IPHONE_67"; // 6.9" / 6.7" iPhones (1320×2868 accepted)
async function first(p) {
const r = await api("GET", p);
return r.data[0];
}
async function main() {
const appInfo = await first(`/v1/apps/${APP_ID}/appInfos`);
const infoLoc = await first(`/v1/appInfos/${appInfo.id}/appInfoLocalizations`);
const version = await first(`/v1/apps/${APP_ID}/appStoreVersions?filter[platform]=IOS`);
const verLoc = await first(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations`);
console.log(`appInfo ${appInfo.id} / version ${version.attributes.versionString} (${version.attributes.appStoreState})`);
// 1. Name, subtitle, privacy policy
await api("PATCH", `/v1/appInfoLocalizations/${infoLoc.id}`, {
data: { type: "appInfoLocalizations", id: infoLoc.id, attributes: { name: NAME, subtitle: SUBTITLE, privacyPolicyUrl: PRIVACY_URL } },
});
console.log("✔ name / subtitle / privacy URL");
// 2. Description, keywords, promo, support URL
await api("PATCH", `/v1/appStoreVersionLocalizations/${verLoc.id}`, {
data: {
type: "appStoreVersionLocalizations",
id: verLoc.id,
attributes: { description: DESCRIPTION, keywords: KEYWORDS, promotionalText: PROMO, supportUrl: SUPPORT_URL },
},
});
console.log("✔ description / keywords / promo / support URL");
// 3. Categories
await api("PATCH", `/v1/appInfos/${appInfo.id}`, {
data: {
type: "appInfos",
id: appInfo.id,
relationships: {
primaryCategory: { data: { type: "appCategories", id: "HEALTH_AND_FITNESS" } },
secondaryCategory: { data: { type: "appCategories", id: "MEDICAL" } },
},
},
});
console.log("✔ categories");
// 4. Age rating: fitness timer, health topic yes, mild medical info, nothing else
const none = "NONE";
await api("PATCH", `/v1/ageRatingDeclarations/${appInfo.id}`, {
data: {
type: "ageRatingDeclarations",
id: appInfo.id,
attributes: {
alcoholTobaccoOrDrugUseOrReferences: none,
contests: none,
gamblingSimulated: none,
gunsOrOtherWeapons: none,
horrorOrFearThemes: none,
matureOrSuggestiveThemes: none,
medicalOrTreatmentInformation: "INFREQUENT_OR_MILD",
profanityOrCrudeHumor: none,
sexualContentGraphicAndNudity: none,
sexualContentOrNudity: none,
violenceCartoonOrFantasy: none,
violenceRealistic: none,
violenceRealisticProlongedGraphicOrSadistic: none,
advertising: false,
gambling: false,
healthOrWellnessTopics: true,
lootBox: false,
messagingAndChat: false,
parentalControls: false,
socialMedia: false,
unrestrictedWebAccess: false,
userGeneratedContent: false,
ageAssurance: false,
},
},
});
console.log("✔ age rating declaration");
// 5. Screenshots: replace the iPhone 6.9" set
const sets = await api("GET", `/v1/appStoreVersionLocalizations/${verLoc.id}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}`);
for (const s of sets.data) await api("DELETE", `/v1/appScreenshotSets/${s.id}`);
const set = await api("POST", "/v1/appScreenshotSets", {
data: {
type: "appScreenshotSets",
attributes: { screenshotDisplayType: DISPLAY_TYPE },
relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: verLoc.id } } },
},
});
const files = fs.readdirSync(SHOTS_DIR).filter((f) => /^\d+\.png$/.test(f)).sort((a, b) => parseInt(a) - parseInt(b));
for (const f of files) {
const buf = fs.readFileSync(path.join(SHOTS_DIR, f));
const shot = await api("POST", "/v1/appScreenshots", {
data: {
type: "appScreenshots",
attributes: { fileName: f, fileSize: buf.length },
relationships: { appScreenshotSet: { data: { type: "appScreenshotSets", id: set.data.id } } },
},
});
for (const op of shot.data.attributes.uploadOperations) {
const headers = Object.fromEntries(op.requestHeaders.map((h) => [h.name, h.value]));
const res = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) });
if (!res.ok) throw new Error(`upload chunk failed ${res.status} for ${f}`);
}
await api("PATCH", `/v1/appScreenshots/${shot.data.id}`, {
data: { type: "appScreenshots", id: shot.data.id, attributes: { uploaded: true, sourceFileChecksum: crypto.createHash("md5").update(buf).digest("hex") } },
});
console.log(`✔ screenshot ${f}`);
}
console.log("done");
}
main().catch((e) => { console.error(e.message); process.exit(1); });