{"id":"8d2738ec-97e3-4caf-ab86-dfd1b449d58e","entityType":"agent","slug":"clawhub-chrischall-artsonia-api","name":"artsonia-api","canonicalUrl":"https://www.xpersona.co/agent/clawhub-chrischall-artsonia-api","canonicalPath":"/agent/clawhub-chrischall-artsonia-api","generatedAt":"2026-10-10T10:43:33.219Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T08:15:15.075Z","emptyReason":null},"description":"Access Artsonia (artsonia.com) student-art portfolios, comments, fans, teacher feedback, and downloads from a shell with curl instead of running the artsonia-mcp server — log in with a username/password form POST to get a session cookie, then curl the server-rendered member pages. Use when you want Artsonia data without the MCP, in a script, or on a machine where the MCP isn't installed.","descriptionLabel":"Source description","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.6K downloads reported by the source. Last updated 10/10/2026.","installCommand":"clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:artsonia-api","sourceUrl":"https://clawhub.ai/chrischall/artsonia-api","homepage":"https://clawhub.ai/chrischall/skills/artsonia-api","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/chrischall/artsonia-api","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/chrischall/skills/artsonia-api","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":64,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"artsonia-api technical dossier on Xpersona with agent coverage, OPENCLEW support, and live trust metadata."},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-10T08:15:15.075Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T08:15:15.075Z","emptyReason":null},"stars":null,"forks":null,"downloads":1564,"packageName":null,"latestVersion":"1.3.0","tractionLabel":"1.6K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T08:15:15.075Z","emptyReason":null},"lastUpdatedAt":"2026-10-10T08:15:15.075Z","lastCrawledAt":"2026-10-10T08:15:15.075Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-11T08:15:15.075Z","lastVerifiedAt":null,"highlights":[{"version":"1.3.0","createdAt":"2026-10-09T23:23:47.153Z","changelog":"- Removed the file: skill-card.md. - No changes to user-facing documentation or functionality.","fileCount":4,"zipByteSize":8833},{"version":"1.2.5","createdAt":"2026-10-07T13:36:59.905Z","changelog":"- Removed the file skill-card.md. - No changes to existing functionality or documentation content.","fileCount":4,"zipByteSize":8783},{"version":"1.2.4","createdAt":"2026-10-05T02:48:11.698Z","changelog":"- Removed the sample file skill-card.md. - No user-facing changes to features or documentation. - Maintenance update; no changes required to usage or API.","fileCount":4,"zipByteSize":8762},{"version":"1.2.3","createdAt":"2026-10-03T01:39:50.095Z","changelog":"- Removed the file skill-card.md. - No user-facing features or functionality were changed in this version.","fileCount":4,"zipByteSize":8836},{"version":"1.2.2","createdAt":"2026-09-28T13:55:57.140Z","changelog":"- Updated documentation to refer to \"ContextMint Bridge extension\" instead of \"Transporter extension\". - Removed the skill-card.md file. - No functional or usage changes; documentation only.","fileCount":4,"zipByteSize":8801},{"version":"1.2.1","createdAt":"2026-09-25T15:55:07.250Z","changelog":"- Removed the file skill-card.md. - No functional changes to the skill's API or usage. - Documentation and codebase remain otherwise unchanged.","fileCount":4,"zipByteSize":8837},{"version":"1.2.0","createdAt":"2026-09-24T15:10:30.963Z","changelog":"- removed 1 file(s). - Updated SKILL.md and bundle contents.","fileCount":4,"zipByteSize":9004},{"version":"1.1.4","createdAt":"2026-09-23T21:39:34.849Z","changelog":"artsonia-api 1.1.4 - Removed the skill-card.md file. - No changes to implementation or documented usage.","fileCount":4,"zipByteSize":8956}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:artsonia-api","setupComplexity":"low","setupSteps":["Install using `clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:artsonia-api` in an isolated environment before connecting it to live workloads.","No published capability contract is available yet, so validate auth and request/response behavior manually.","Review the upstream CLAWHUB listing at https://clawhub.ai/chrischall/artsonia-api before using production credentials."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-10T10:43:33.216Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-artsonia-api/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T08:15:15.075Z","emptyReason":null},"readme":"Skill: artsonia-api\n\nOwner: chrischall\n\nSummary: Access Artsonia (artsonia.com) student-art portfolios, comments, fans, teacher feedback, and downloads from a shell with curl instead of running the artsonia-mcp server — log in with a username/password form POST to get a session cookie, then curl the server-rendered member pages. Use when you want Artsonia data without the MCP, in a script, or on a machine where the MCP isn't installed.\n\nTags: latest:1.3.0\n\nVersion history:\n\nv1.3.0 | 2026-10-09T23:23:47.153Z | auto\n\n- Removed the file: skill-card.md.\n- No changes to user-facing documentation or functionality.\n\nv1.2.5 | 2026-10-07T13:36:59.905Z | auto\n\n- Removed the file skill-card.md.\n- No changes to existing functionality or documentation content.\n\nv1.2.4 | 2026-10-05T02:48:11.698Z | auto\n\n- Removed the sample file skill-card.md.\n- No user-facing changes to features or documentation.\n- Maintenance update; no changes required to usage or API.\n\nv1.2.3 | 2026-10-03T01:39:50.095Z | auto\n\n- Removed the file skill-card.md.\n- No user-facing features or functionality were changed in this version.\n\nv1.2.2 | 2026-09-28T13:55:57.140Z | auto\n\n- Updated documentation to refer to \"ContextMint Bridge extension\" instead of \"Transporter extension\".\n- Removed the skill-card.md file.\n- No functional or usage changes; documentation only.\n\nv1.2.1 | 2026-09-25T15:55:07.250Z | auto\n\n- Removed the file skill-card.md.\n- No functional changes to the skill's API or usage.\n- Documentation and codebase remain otherwise unchanged.\n\nv1.2.0 | 2026-09-24T15:10:30.963Z | auto\n\n- removed 1 file(s).\n- Updated SKILL.md and bundle contents.\n\nv1.1.4 | 2026-09-23T21:39:34.849Z | auto\n\nartsonia-api 1.1.4\n\n- Removed the skill-card.md file.\n- No changes to implementation or documented usage.\n\nv1.1.3 | 2026-09-23T15:41:29.136Z | auto\n\n- Removed the sample file skill-card.md from the repository.\n- No functional or documentation changes to the skill itself.\n\nv1.1.2 | 2026-09-21T05:03:31.630Z | auto\n\n- Removed the sample file skill-card.md.\n- No functional changes to the skill's code or documentation.\n\nv1.1.1 | 2026-09-21T04:12:56.322Z | auto\n\n- Removed the sample file skill-card.md.\n- No changes to functionality or features.\n- Documentation and usage remain unchanged.\n\nv1.1.0 | 2026-09-20T02:48:44.426Z | auto\n\n- Removed the unnecessary file skill-card.md.\n- No user-facing changes to functionality or documentation.\n- Internal cleanup only; usage and features remain unchanged.\n\nv1.0.0 | 2026-09-19T01:20:46.691Z | auto\n\n- Removed the sample skill-card.md file.\n- No changes to core functionality or documentation.\n\nv0.12.3 | 2026-09-15T19:26:42.150Z | auto\n\n- Removed the sample file skill-card.md.\n- No changes to core functionality or documentation.\n\nv0.12.2 | 2026-09-14T14:08:53.612Z | auto\n\n- Removed the file skill-card.md from the project.\n- No code or functionality changes; this is a documentation-only update.\n\nv0.12.1 | 2026-09-10T17:50:01.235Z | auto\n\n- Removed the sample file skill-card.md.\n- No changes to functionality or documentation in SKILL.md.\n\nv0.12.0 | 2026-09-04T22:28:20.434Z | auto\n\n- Removed the sample file skill-card.md.\n- No functional or documentation changes to the core skill.\n\nv0.11.0 | 2026-08-29T13:53:47.618Z | auto\n\n- Removed the sample file skill-card.md.\n- No functional or API changes; documentation and usage remain the same.\n\nv0.10.1 | 2026-08-28T21:06:46.964Z | auto\n\n- Removed the file skill-card.md.\n- No changes to functionality or usage.\n\nv0.10.0 | 2026-08-28T11:34:26.295Z | auto\n\n- Removed the sample file skill-card.md.\n- No changes to functionality or documentation.\n\nv0.9.0 | 2026-08-09T21:03:13.423Z | auto\n\n- Removed the sample file skill-card.md.\n- No changes to core functionality or documentation beyond file cleanup.\n\nv0.8.4 | 2026-08-06T00:42:46.818Z | auto\n\n- Removed the sample file skill-card.md.\n- No changes to functionality, usage, or documentation in SKILL.md.\n- Minor cleanup to repository files only.\n\nv0.8.3 | 2026-07-30T12:53:10.844Z | auto\n\n- Adds documentation for accessing Artsonia student-art portfolios and actions using curl, eliminating the need to run the MCP server.\n- Details secure login process with a username/password POST and session cookie handling.\n- Describes how to retrieve and parse student, artwork, and comment data directly from server-rendered HTML pages.\n- Explains session expiry behavior and outlines critical write caveats observed from MCP usage.\n- Documents public access to artwork images via the CDN, with available resolutions.\n- Includes setup instructions, extraction examples, and pointers to further endpoint and selector references.\n\nArchive index:\n\nArchive v1.3.0: 4 files, 8833 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2115b), SKILL.md (6422b), _meta.json (131b)\n\nFile v1.3.0:SKILL.md\n\n---\nname: artsonia-api\ndescription: >-\n  Access Artsonia (artsonia.com) student-art portfolios, comments, fans,\n  teacher feedback, and downloads from a shell with curl instead of running\n  the artsonia-mcp server — log in with a username/password form POST to get\n  a session cookie, then curl the server-rendered member pages. Use when you\n  want Artsonia data without the MCP, in a script, or on a machine where the\n  MCP isn't installed.\n---\n\n# Artsonia via curl (no MCP)\n\nArtsonia is a classic server-rendered `.asp` site — no public API, no JSON\nstore, no bot wall. All the data (students, portfolios, comments, fans,\nfeedback, awards, profile) lives in server-rendered HTML, and auth is a real\n**username + password form POST** that hands back an HttpOnly session\ncookie. No browser, no ContextMint Bridge extension, no `fpx` bridge is needed —\n`curl` with a cookie jar reaches everything a signed-in parent account can.\n(The MCP keeps `@fetchproxy/server` as an optional fallback transport for\nsome future walled endpoint, but nothing documented here needs it.)\n\nThis is the same data the `artsonia_*` MCP tools return, reached with `curl`\ninstead of a running server.\n\n## One-time setup\n\n```sh\nexport ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar\n```\n\n## Log in (get the session cookie)\n\nRead the password from stdin (`Password@-`) instead of putting it on the\ncommand line — `--data-urlencode name=value` puts `value` in curl's argv,\nwhich any local user can read via `ps`/`/proc` while the process runs. This\nmirrors how the MCP itself sends the password: only in the POST body, never\non a command line.\n\n```sh\nprintf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp\n```\n\nA successful login redirects away from `login.asp` (final URL ends in\n`.../members/`) and leaves cookies in `$JAR`. If `url_effective` still ends\nin `login.asp`, or `/tmp/artsonia-login.html` matches `You need to log\nin|Parent \\(or Fan\\) Login`, the credentials are wrong — Artsonia has no\nCSRF/`__VIEWSTATE` token, so a bad login is the only reason this fails.\nMagic-link-only accounts can't use this (there's no password to POST).\n\nRe-run this exact command any time a later request looks logged-out (see\n\"Session expiry\" below) — `curl -c` overwrites the jar with a fresh session.\n\n## Core call pattern\n\nEvery read is a `GET` through the jar; every write is a form-urlencoded\n`POST` through the jar. Fetch to a file, then extract:\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\n```\n\nFor the numeric ids every other endpoint needs (`artist_id`, `artwork_id`),\na plain regex on the `href` is enough and needs no extra tooling:\n\n```sh\ngrep -oP '(?<=portfolio\\.asp\\?id=)\\d+' /tmp/dash.html | sort -u\n```\n\nFor the full structured fields (name, school, stats, notifications, awards,\n…) don't re-derive selectors by hand — this repo already ships a\nlive-verified `node-html-parser` scraper (`src/parse.ts` → built\n`dist/parse.js`) with one function per page. Build it once, then import it\nin a one-liner and pipe the JSON to `jq`:\n\n```sh\ncd ~/git/artsonia-mcp && npm install && npm run build   # once, if dist/ is missing\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$(pwd)/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html', 'utf8'))));\n\" | jq '.'\n```\n\n`references/endpoints.md` has the ready-to-run curl command, `dist/parse.js`\nimport, and `jq` projection for every read and write tool, keyed to the same\npaths and selectors the MCP uses.\n\n## The one rule: resolve artist_id (and artwork_id) first\n\nEvery per-student/per-artwork endpoint needs a numeric id you can only get\nfrom a parent call — never guess one:\n\n1. `GET /members/` → `.artist-card` → `artist_id` (from each card's\n   `portfolio.asp?id=` link).\n2. `GET /artists/portfolio.asp?id=<artist_id>` → `.grid-item` that contains\n   an `art.asp` link → `artwork_id`.\n\n## Session expiry\n\nArtsonia expires a session by **redirecting/rendering back to the login\npage**, not a 401. After any GET/POST, check the response body:\n\n```sh\ngrep -qE 'login\\.asp|You need to log in|Parent \\(or Fan\\) Login' /tmp/out.html \\\n  && echo \"EXPIRED — re-run the login step\"\n```\n\n## Writes — real caveats from the MCP build\n\n- **A 3xx is not proof a write persisted.** Artsonia's form handlers 302\n  even on payloads they silently drop. Re-`GET` the page you just changed\n  and confirm the field actually flipped before trusting a write.\n- **Checkboxes submit `value=Y`, never `on`.** Sending `Field=on` is\n  silently ignored by the server (still 302s, saves nothing).\n- **The profile form is read-modify-write.** `/members/profile/`\n  (`#TheForm`) bundles name/email/password/opt-ins in one form — re-POST\n  every current field verbatim (blank the password fields, keep\n  `DidChangePassword=N`), flipping only the one you're changing.\n- **Never POST a password.** Leave `OldPassword`/`NewPassword`/\n  `NewPassword2` blank/omitted.\n\nSee `references/endpoints.md` for the exact body of every write, and how to\nre-read and verify each one.\n\n## Artwork images (no auth needed)\n\nFull-resolution images live on a public CDN — no cookie required, even for\nprivate pieces:\n\n```sh\ncurl -s -o \"$artwork_id.jpg\" \"https://images.artsonia.com/art/full/$artwork_id.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$artwork_id.jpg\" | grep -i last-modified   # per-artwork upload date\n```\n\nResolutions (small→large): `small` `medium` `large` `xlarge` `full`.\n\n## Notes\n\n- Reads/writes both go through the member site (`www.artsonia.com`);\n  nothing here needs the `fetchproxy`/browser-bridge path the MCP keeps as\n  an optional fallback.\n- Comment-item markup on `/museum/art.asp` is unverified upstream (both\n  captured accounts had 0 comments) — `references/endpoints.md` flags it.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.3.0:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"artsonia-api\",\n  \"version\": \"1.3.0\",\n  \"publishedAt\": 1791588227153\n}\n\nFile v1.3.0:references/endpoints.md\n\n# Artsonia endpoints — curl + parser recipes\n\nAll paths are relative to `https://www.artsonia.com`. `$JAR` is the cookie\njar from `SKILL.md`'s login step. All parsing functions below are the\nMCP's own live-verified `src/parse.ts` (built to `dist/parse.js`) — build\nonce with `npm install && npm run build` in the repo, then reuse the\n`node --input-type=module` one-liner shown per endpoint. Swap in\n`grep -oP '(?<=KEY=)\\d+'` on the raw HTML instead if you only need an id and\ndon't want to build the repo.\n\nShorthand used below: `$REPO` = the absolute path to this clone of\n`artsonia-mcp` (e.g. `~/git/artsonia-mcp`); `$ID` = a resolved `artist_id`;\n`$ART` = a resolved `artwork_id`.\n\n```sh\n# generic shape of every parse call — swap the function name + input path\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents as fn } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(fn(readFileSync('/tmp/page.html', 'utf8'))));\n\" | jq '.'\n```\n\n## Reads\n\n### list_students — GET `/members/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artist_id)\\t\\(.name)\\t\\(.school)\\tgrade \\(.grade)\\t\\(.artwork_count) artworks\"'\n```\nFields: `artist_id`, `name`, `school`, `grade`, `artwork_count`,\n`fan_count`, `comment_count`, `feedback_count`, `award_count`,\n`portfolio_path`. Selector: `.artist-card`.\n\n### get_activity — GET `/members/` (notifications)\n\nSame fetch as above, different parser:\n\n```sh\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseNotifications } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseNotifications(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq '.'\n```\nShape: `{ count, items: [{ title, body, href }] }`. Selectors:\n`.textSubhead` (text `Notifications (N)`) + `div.notice`.\n\n### get_portfolio — GET `/artists/portfolio.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/portfolio.asp?id=$ID\" -o /tmp/portfolio.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parsePortfolio } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parsePortfolio(readFileSync('/tmp/portfolio.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artwork_id)\\t\\(.is_private)\\t\\(.thumbnail)\"'\n```\nFields: `artwork_id`, `is_private`, `thumbnail` (derived\n`images.artsonia.com/art/small/<id>.jpg`). Selector: `.grid-item` that\ncontains an `a[href*=\"art.asp\"]` (the first `.grid-item` is a non-artwork\nsection header — no art link — and is dropped by that filter).\n\n### get_artwork / list_comments — GET `/museum/art.asp?id=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/museum/art.asp?id=$ART\" -o /tmp/artwork.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseArtwork } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseArtwork(readFileSync('/tmp/artwork.html','utf8'))));\n\" | jq '{title, artist_screen_name, views, grade, project, comments}'\n```\nFields: `title`, `artist_screen_name`, `views`, `grade`, `project`,\n`comment_entry: {artist_id, artwork_id}|null`, `comments: [{author, text}]`.\n`list_comments` is just the `.comments` array from the same fetch.\n**Comment-item markup (`.comment`/`.comment-author`/`.comment-text`) is\nUNVERIFIED upstream** — both captured accounts had 0 comments, so it\ndegrades to `[]` without throwing but hasn't been confirmed against a real\ncomment. Verify against a live artwork with comments before trusting it.\n\n### get_fans — GET `/members/fanclub/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/fanclub/?artist=$ID\" -o /tmp/fans.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFans } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFans(readFileSync('/tmp/fans.html','utf8'))));\n\" | jq '.'\n```\nFields: `name`, `relationship`. Selector: `.fan-card` (name from the first\n`a.hiddenlink`; relationship from the card's own-text div, e.g. \"Father\" —\nthe card also carries the fan's email, which the MCP deliberately doesn't\nsurface).\n\n### get_feedback — GET `/members/feedback/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/feedback/?artist=$ID\" -o /tmp/feedback.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFeedback } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFeedback(readFileSync('/tmp/feedback.html','utf8'))));\n\" | jq '[.[] | select(.is_read==false)]'   # unread only\n```\nFields: `artwork_id`, `message`, `posted_by`, `is_read` (false while the row\nsays \"has not been marked as read\"), `thumbnail`. Selector: `.comment-row`\n(message `.comment`, attribution `.commenter`, read state\n`.comment-options`).\n\n### get_awards — GET `/artists/awards.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/awards.asp?id=$ID\" -o /tmp/awards.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseAwards } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseAwards(readFileSync('/tmp/awards.html','utf8'))));\n\" | jq '[.[] | select(.earned)]'   # earned only\n```\nFields: `name`, `earned`, `description`, `progress`,\n`period: \"current\"|\"past\"`. Selectors: `.award-card` (current year —\nname/\"Earned\"|\"Not earned\"/criteria/progress) + `.award-card-past`\n(prior-year badge icons, name derived from `artist_<name>[_ghosted].gif`).\n\n### get_profile — GET `/members/profile/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile.html','utf8'))));\n\" | jq '.'\n```\nFields: `first_name`, `last_name`, `email`, `mobile`,\n`opt_ins: {news, artist_activity, promos}`. Selector: `#TheForm`\n(`input[name=...]`/`select[name=...]`).\n\n## Downloads (public CDN, no auth)\n\n```sh\ncurl -s -o \"$ART.jpg\" \"https://images.artsonia.com/art/full/$ART.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$ART.jpg\" | grep -i last-modified\n```\nNo teacher-entered \"created\" date exists anywhere in the HTML; the CDN\nimage's `Last-Modified` header is the real per-artwork date (verified\nspanning 2022→2026 on one portfolio). Resolutions: `small` (~11 KB)\n`medium` (~34 KB) `large` (~58 KB) `xlarge` (~102 KB) `full`/`original`\n(~665 KB). To bulk-download a portfolio: loop `get_portfolio`'s\n`artwork_id`s through the CDN curl above. There's no starred/favorited\ncontrol on the artwork page for the parent role — don't look for one.\n\n## Writes (POST, form-urlencoded, session-cookie auth, no CSRF)\n\nEvery write below: **a 3xx from curl is not proof it persisted** — Artsonia\n302s even on a silently-dropped payload. Re-GET the affected page (recipes\nabove) and diff before trusting it.\n\n### post_comment — POST `/museum/enter.asp?artist=$ID&art=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/comment-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"Comment=Nice work!\" \\\n  \"https://www.artsonia.com/museum/enter.asp?artist=$ID&art=$ART\"\n```\nBody: `Comment=<text>` only. **Not verifiable from the parent side** — the\nMCP's live test got a 302 to a content page (not a form bounce) but the\ncomment never appeared on the public artwork page or the parent comments\nview; most likely held for teacher moderation. Treat a 302 here as\n\"submitted/pending,\" never \"posted.\"\n\n### invite_fan — POST `/members/fanclub/add.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/invite-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"MemberType=fan\" \\\n  --data-urlencode \"RelationshipID=<relationship-id-from-the-live-Add-Fans-form>\" \\\n  --data-urlencode \"FirstName=Test\" \\\n  --data-urlencode \"LastName=Fan\" \\\n  --data-urlencode \"EmailAddress=test@example.com\" \\\n  --data-urlencode \"ArtistID=$ID\" \\\n  \"https://www.artsonia.com/members/fanclub/add.asp?artist=$ID\"\n```\nOnly test with a real address you're authorized to invite (`@example.com`\nfor dry testing — it sends a real invite email). `MemberType=fan` is\nassumed correct (live-verified by the MCP build); `RelationshipID` is the\n`<select>` value from `/members/fanclub/add.asp?artist=$ID`'s live form —\nread that page first if you don't already know the code. Add\n`--data-urlencode \"IsParent=on\"` if the fan is also a parent/guardian.\nVerify by re-`GET`ting the fan club (`get_fans` above) — a successful\ninvite shows up as a pending fan.\n\n### mark_feedback_read — POST `/members/feedback/default.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/mark-read-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"ConfirmAsRead=Mark as Read\" \\\n  \"https://www.artsonia.com/members/feedback/default.asp?artist=$ID\"\n```\nBody: `ConfirmAsRead=Mark as Read` only. Marks **all** of the student's\nfeedback (no per-item control). Verify by re-running `get_feedback` above\nand confirming no row has `is_read: false`.\n\n### set_notifications — read `/members/profile/`, POST `/members/profile/default.asp`\n\nThis is a read-modify-write of the whole profile form — re-send every\ncurrent field, flip only the opt-in(s), never send a password:\n\n```sh\n# 1. Read current form values (also needed for every other field below)\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\n\n# 2. Inspect it to see the *current* value of every input/select in #TheForm\n#    (FirstName, LastName, EmailAddress, MobileNumber, MobileCountryCode,\n#    Action, EmailAddressPrev, DidChangePassword, OptInNews,\n#    OptInArtistActivity, OptInPromos, ...). Checkboxes submit their real\n#    `value` attribute (Artsonia uses \"Y\", never \"on\") when checked, and are\n#    OMITTED entirely when unchecked — exactly like a browser form.\ngrep -oP '<input[^>]*name=\"[^\"]+\"[^>]*>' /tmp/profile.html\n\n# 3. Re-POST every field verbatim EXCEPT: blank OldPassword/NewPassword/\n#    NewPassword2, keep DidChangePassword=N, and flip only the opt-in(s)\n#    you're changing (include `Field=Y` to turn ON, omit `Field` entirely\n#    to turn OFF).\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/notif-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"FirstName=<current>\" \\\n  --data-urlencode \"LastName=<current>\" \\\n  --data-urlencode \"EmailAddress=<current>\" \\\n  --data-urlencode \"EmailAddressPrev=<current>\" \\\n  --data-urlencode \"MobileCountryCode=<current>\" \\\n  --data-urlencode \"MobileNumber=<current>\" \\\n  --data-urlencode \"Action=<current>\" \\\n  --data-urlencode \"DidChangePassword=N\" \\\n  --data-urlencode \"OldPassword=\" \\\n  --data-urlencode \"NewPassword=\" \\\n  --data-urlencode \"NewPassword2=\" \\\n  --data-urlencode \"OptInArtistActivity=Y\" \\\n  \"https://www.artsonia.com/members/profile/default.asp\"\n  # (omit OptInNews / OptInPromos entirely here to turn them off)\n\n# 4. Verify: re-fetch the profile and confirm the checkbox state changed\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile-after.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile-after.html','utf8')).opt_ins));\n\"\n```\nField names: `OptInNews`, `OptInArtistActivity` (default checked),\n`OptInPromos`. This form also carries password-change fields\n(`OldPassword`/`NewPassword`/`NewPassword2`) — always send them blank.\n\nFile v1.3.0:skill-card.md\n\n## Description:\n\nGuides agents in accessing Artsonia student portfolios, comments, fans, feedback, and artwork through authenticated curl requests without running the Artsonia MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and authorized Artsonia account holders use this skill to retrieve student artwork and account information or manage comments, fan invitations, feedback, and notification settings with curl.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Private student artwork may be accessible through direct image links and bulk downloads.\n\nMitigation: Access only accounts and artwork you are authorized to use; avoid bulk or private downloads without explicit permission.\n\nRisk: Form submissions can change live account state or send invitation emails.\n\nMitigation: Review each POST before sending it, use only authorized recipients, and verify changes against the affected page.\n\nRisk: Login credentials, session cookies, and downloaded pages can expose account or student information.\n\nMitigation: Use a trusted machine, protect the cookie jar and temporary files, and clear them when finished.\n\n## Reference(s):\n\n- [Artsonia endpoint recipes](references/endpoints.md)\n- [ClawHub skill listing](https://clawhub.ai/chrischall/skills/artsonia-api)\n- [Artsonia](https://www.artsonia.com)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with curl and shell command examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Authenticated reads and live form submissions; artwork download commands can create image files.]\n\n## Skill Version(s):\n\n1.3.0 (source: ClawHub release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.5: 4 files, 8783 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2013b), SKILL.md (6422b), _meta.json (131b)\n\nFile v1.2.5:SKILL.md\n\n---\nname: artsonia-api\ndescription: >-\n  Access Artsonia (artsonia.com) student-art portfolios, comments, fans,\n  teacher feedback, and downloads from a shell with curl instead of running\n  the artsonia-mcp server — log in with a username/password form POST to get\n  a session cookie, then curl the server-rendered member pages. Use when you\n  want Artsonia data without the MCP, in a script, or on a machine where the\n  MCP isn't installed.\n---\n\n# Artsonia via curl (no MCP)\n\nArtsonia is a classic server-rendered `.asp` site — no public API, no JSON\nstore, no bot wall. All the data (students, portfolios, comments, fans,\nfeedback, awards, profile) lives in server-rendered HTML, and auth is a real\n**username + password form POST** that hands back an HttpOnly session\ncookie. No browser, no ContextMint Bridge extension, no `fpx` bridge is needed —\n`curl` with a cookie jar reaches everything a signed-in parent account can.\n(The MCP keeps `@fetchproxy/server` as an optional fallback transport for\nsome future walled endpoint, but nothing documented here needs it.)\n\nThis is the same data the `artsonia_*` MCP tools return, reached with `curl`\ninstead of a running server.\n\n## One-time setup\n\n```sh\nexport ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar\n```\n\n## Log in (get the session cookie)\n\nRead the password from stdin (`Password@-`) instead of putting it on the\ncommand line — `--data-urlencode name=value` puts `value` in curl's argv,\nwhich any local user can read via `ps`/`/proc` while the process runs. This\nmirrors how the MCP itself sends the password: only in the POST body, never\non a command line.\n\n```sh\nprintf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp\n```\n\nA successful login redirects away from `login.asp` (final URL ends in\n`.../members/`) and leaves cookies in `$JAR`. If `url_effective` still ends\nin `login.asp`, or `/tmp/artsonia-login.html` matches `You need to log\nin|Parent \\(or Fan\\) Login`, the credentials are wrong — Artsonia has no\nCSRF/`__VIEWSTATE` token, so a bad login is the only reason this fails.\nMagic-link-only accounts can't use this (there's no password to POST).\n\nRe-run this exact command any time a later request looks logged-out (see\n\"Session expiry\" below) — `curl -c` overwrites the jar with a fresh session.\n\n## Core call pattern\n\nEvery read is a `GET` through the jar; every write is a form-urlencoded\n`POST` through the jar. Fetch to a file, then extract:\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\n```\n\nFor the numeric ids every other endpoint needs (`artist_id`, `artwork_id`),\na plain regex on the `href` is enough and needs no extra tooling:\n\n```sh\ngrep -oP '(?<=portfolio\\.asp\\?id=)\\d+' /tmp/dash.html | sort -u\n```\n\nFor the full structured fields (name, school, stats, notifications, awards,\n…) don't re-derive selectors by hand — this repo already ships a\nlive-verified `node-html-parser` scraper (`src/parse.ts` → built\n`dist/parse.js`) with one function per page. Build it once, then import it\nin a one-liner and pipe the JSON to `jq`:\n\n```sh\ncd ~/git/artsonia-mcp && npm install && npm run build   # once, if dist/ is missing\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$(pwd)/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html', 'utf8'))));\n\" | jq '.'\n```\n\n`references/endpoints.md` has the ready-to-run curl command, `dist/parse.js`\nimport, and `jq` projection for every read and write tool, keyed to the same\npaths and selectors the MCP uses.\n\n## The one rule: resolve artist_id (and artwork_id) first\n\nEvery per-student/per-artwork endpoint needs a numeric id you can only get\nfrom a parent call — never guess one:\n\n1. `GET /members/` → `.artist-card` → `artist_id` (from each card's\n   `portfolio.asp?id=` link).\n2. `GET /artists/portfolio.asp?id=<artist_id>` → `.grid-item` that contains\n   an `art.asp` link → `artwork_id`.\n\n## Session expiry\n\nArtsonia expires a session by **redirecting/rendering back to the login\npage**, not a 401. After any GET/POST, check the response body:\n\n```sh\ngrep -qE 'login\\.asp|You need to log in|Parent \\(or Fan\\) Login' /tmp/out.html \\\n  && echo \"EXPIRED — re-run the login step\"\n```\n\n## Writes — real caveats from the MCP build\n\n- **A 3xx is not proof a write persisted.** Artsonia's form handlers 302\n  even on payloads they silently drop. Re-`GET` the page you just changed\n  and confirm the field actually flipped before trusting a write.\n- **Checkboxes submit `value=Y`, never `on`.** Sending `Field=on` is\n  silently ignored by the server (still 302s, saves nothing).\n- **The profile form is read-modify-write.** `/members/profile/`\n  (`#TheForm`) bundles name/email/password/opt-ins in one form — re-POST\n  every current field verbatim (blank the password fields, keep\n  `DidChangePassword=N`), flipping only the one you're changing.\n- **Never POST a password.** Leave `OldPassword`/`NewPassword`/\n  `NewPassword2` blank/omitted.\n\nSee `references/endpoints.md` for the exact body of every write, and how to\nre-read and verify each one.\n\n## Artwork images (no auth needed)\n\nFull-resolution images live on a public CDN — no cookie required, even for\nprivate pieces:\n\n```sh\ncurl -s -o \"$artwork_id.jpg\" \"https://images.artsonia.com/art/full/$artwork_id.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$artwork_id.jpg\" | grep -i last-modified   # per-artwork upload date\n```\n\nResolutions (small→large): `small` `medium` `large` `xlarge` `full`.\n\n## Notes\n\n- Reads/writes both go through the member site (`www.artsonia.com`);\n  nothing here needs the `fetchproxy`/browser-bridge path the MCP keeps as\n  an optional fallback.\n- Comment-item markup on `/museum/art.asp` is unverified upstream (both\n  captured accounts had 0 comments) — `references/endpoints.md` flags it.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.5:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"artsonia-api\",\n  \"version\": \"1.2.5\",\n  \"publishedAt\": 1791380219905\n}\n\nFile v1.2.5:references/endpoints.md\n\n# Artsonia endpoints — curl + parser recipes\n\nAll paths are relative to `https://www.artsonia.com`. `$JAR` is the cookie\njar from `SKILL.md`'s login step. All parsing functions below are the\nMCP's own live-verified `src/parse.ts` (built to `dist/parse.js`) — build\nonce with `npm install && npm run build` in the repo, then reuse the\n`node --input-type=module` one-liner shown per endpoint. Swap in\n`grep -oP '(?<=KEY=)\\d+'` on the raw HTML instead if you only need an id and\ndon't want to build the repo.\n\nShorthand used below: `$REPO` = the absolute path to this clone of\n`artsonia-mcp` (e.g. `~/git/artsonia-mcp`); `$ID` = a resolved `artist_id`;\n`$ART` = a resolved `artwork_id`.\n\n```sh\n# generic shape of every parse call — swap the function name + input path\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents as fn } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(fn(readFileSync('/tmp/page.html', 'utf8'))));\n\" | jq '.'\n```\n\n## Reads\n\n### list_students — GET `/members/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artist_id)\\t\\(.name)\\t\\(.school)\\tgrade \\(.grade)\\t\\(.artwork_count) artworks\"'\n```\nFields: `artist_id`, `name`, `school`, `grade`, `artwork_count`,\n`fan_count`, `comment_count`, `feedback_count`, `award_count`,\n`portfolio_path`. Selector: `.artist-card`.\n\n### get_activity — GET `/members/` (notifications)\n\nSame fetch as above, different parser:\n\n```sh\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseNotifications } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseNotifications(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq '.'\n```\nShape: `{ count, items: [{ title, body, href }] }`. Selectors:\n`.textSubhead` (text `Notifications (N)`) + `div.notice`.\n\n### get_portfolio — GET `/artists/portfolio.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/portfolio.asp?id=$ID\" -o /tmp/portfolio.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parsePortfolio } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parsePortfolio(readFileSync('/tmp/portfolio.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artwork_id)\\t\\(.is_private)\\t\\(.thumbnail)\"'\n```\nFields: `artwork_id`, `is_private`, `thumbnail` (derived\n`images.artsonia.com/art/small/<id>.jpg`). Selector: `.grid-item` that\ncontains an `a[href*=\"art.asp\"]` (the first `.grid-item` is a non-artwork\nsection header — no art link — and is dropped by that filter).\n\n### get_artwork / list_comments — GET `/museum/art.asp?id=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/museum/art.asp?id=$ART\" -o /tmp/artwork.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseArtwork } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseArtwork(readFileSync('/tmp/artwork.html','utf8'))));\n\" | jq '{title, artist_screen_name, views, grade, project, comments}'\n```\nFields: `title`, `artist_screen_name`, `views`, `grade`, `project`,\n`comment_entry: {artist_id, artwork_id}|null`, `comments: [{author, text}]`.\n`list_comments` is just the `.comments` array from the same fetch.\n**Comment-item markup (`.comment`/`.comment-author`/`.comment-text`) is\nUNVERIFIED upstream** — both captured accounts had 0 comments, so it\ndegrades to `[]` without throwing but hasn't been confirmed against a real\ncomment. Verify against a live artwork with comments before trusting it.\n\n### get_fans — GET `/members/fanclub/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/fanclub/?artist=$ID\" -o /tmp/fans.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFans } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFans(readFileSync('/tmp/fans.html','utf8'))));\n\" | jq '.'\n```\nFields: `name`, `relationship`. Selector: `.fan-card` (name from the first\n`a.hiddenlink`; relationship from the card's own-text div, e.g. \"Father\" —\nthe card also carries the fan's email, which the MCP deliberately doesn't\nsurface).\n\n### get_feedback — GET `/members/feedback/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/feedback/?artist=$ID\" -o /tmp/feedback.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFeedback } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFeedback(readFileSync('/tmp/feedback.html','utf8'))));\n\" | jq '[.[] | select(.is_read==false)]'   # unread only\n```\nFields: `artwork_id`, `message`, `posted_by`, `is_read` (false while the row\nsays \"has not been marked as read\"), `thumbnail`. Selector: `.comment-row`\n(message `.comment`, attribution `.commenter`, read state\n`.comment-options`).\n\n### get_awards — GET `/artists/awards.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/awards.asp?id=$ID\" -o /tmp/awards.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseAwards } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseAwards(readFileSync('/tmp/awards.html','utf8'))));\n\" | jq '[.[] | select(.earned)]'   # earned only\n```\nFields: `name`, `earned`, `description`, `progress`,\n`period: \"current\"|\"past\"`. Selectors: `.award-card` (current year —\nname/\"Earned\"|\"Not earned\"/criteria/progress) + `.award-card-past`\n(prior-year badge icons, name derived from `artist_<name>[_ghosted].gif`).\n\n### get_profile — GET `/members/profile/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile.html','utf8'))));\n\" | jq '.'\n```\nFields: `first_name`, `last_name`, `email`, `mobile`,\n`opt_ins: {news, artist_activity, promos}`. Selector: `#TheForm`\n(`input[name=...]`/`select[name=...]`).\n\n## Downloads (public CDN, no auth)\n\n```sh\ncurl -s -o \"$ART.jpg\" \"https://images.artsonia.com/art/full/$ART.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$ART.jpg\" | grep -i last-modified\n```\nNo teacher-entered \"created\" date exists anywhere in the HTML; the CDN\nimage's `Last-Modified` header is the real per-artwork date (verified\nspanning 2022→2026 on one portfolio). Resolutions: `small` (~11 KB)\n`medium` (~34 KB) `large` (~58 KB) `xlarge` (~102 KB) `full`/`original`\n(~665 KB). To bulk-download a portfolio: loop `get_portfolio`'s\n`artwork_id`s through the CDN curl above. There's no starred/favorited\ncontrol on the artwork page for the parent role — don't look for one.\n\n## Writes (POST, form-urlencoded, session-cookie auth, no CSRF)\n\nEvery write below: **a 3xx from curl is not proof it persisted** — Artsonia\n302s even on a silently-dropped payload. Re-GET the affected page (recipes\nabove) and diff before trusting it.\n\n### post_comment — POST `/museum/enter.asp?artist=$ID&art=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/comment-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"Comment=Nice work!\" \\\n  \"https://www.artsonia.com/museum/enter.asp?artist=$ID&art=$ART\"\n```\nBody: `Comment=<text>` only. **Not verifiable from the parent side** — the\nMCP's live test got a 302 to a content page (not a form bounce) but the\ncomment never appeared on the public artwork page or the parent comments\nview; most likely held for teacher moderation. Treat a 302 here as\n\"submitted/pending,\" never \"posted.\"\n\n### invite_fan — POST `/members/fanclub/add.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/invite-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"MemberType=fan\" \\\n  --data-urlencode \"RelationshipID=<relationship-id-from-the-live-Add-Fans-form>\" \\\n  --data-urlencode \"FirstName=Test\" \\\n  --data-urlencode \"LastName=Fan\" \\\n  --data-urlencode \"EmailAddress=test@example.com\" \\\n  --data-urlencode \"ArtistID=$ID\" \\\n  \"https://www.artsonia.com/members/fanclub/add.asp?artist=$ID\"\n```\nOnly test with a real address you're authorized to invite (`@example.com`\nfor dry testing — it sends a real invite email). `MemberType=fan` is\nassumed correct (live-verified by the MCP build); `RelationshipID` is the\n`<select>` value from `/members/fanclub/add.asp?artist=$ID`'s live form —\nread that page first if you don't already know the code. Add\n`--data-urlencode \"IsParent=on\"` if the fan is also a parent/guardian.\nVerify by re-`GET`ting the fan club (`get_fans` above) — a successful\ninvite shows up as a pending fan.\n\n### mark_feedback_read — POST `/members/feedback/default.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/mark-read-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"ConfirmAsRead=Mark as Read\" \\\n  \"https://www.artsonia.com/members/feedback/default.asp?artist=$ID\"\n```\nBody: `ConfirmAsRead=Mark as Read` only. Marks **all** of the student's\nfeedback (no per-item control). Verify by re-running `get_feedback` above\nand confirming no row has `is_read: false`.\n\n### set_notifications — read `/members/profile/`, POST `/members/profile/default.asp`\n\nThis is a read-modify-write of the whole profile form — re-send every\ncurrent field, flip only the opt-in(s), never send a password:\n\n```sh\n# 1. Read current form values (also needed for every other field below)\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\n\n# 2. Inspect it to see the *current* value of every input/select in #TheForm\n#    (FirstName, LastName, EmailAddress, MobileNumber, MobileCountryCode,\n#    Action, EmailAddressPrev, DidChangePassword, OptInNews,\n#    OptInArtistActivity, OptInPromos, ...). Checkboxes submit their real\n#    `value` attribute (Artsonia uses \"Y\", never \"on\") when checked, and are\n#    OMITTED entirely when unchecked — exactly like a browser form.\ngrep -oP '<input[^>]*name=\"[^\"]+\"[^>]*>' /tmp/profile.html\n\n# 3. Re-POST every field verbatim EXCEPT: blank OldPassword/NewPassword/\n#    NewPassword2, keep DidChangePassword=N, and flip only the opt-in(s)\n#    you're changing (include `Field=Y` to turn ON, omit `Field` entirely\n#    to turn OFF).\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/notif-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"FirstName=<current>\" \\\n  --data-urlencode \"LastName=<current>\" \\\n  --data-urlencode \"EmailAddress=<current>\" \\\n  --data-urlencode \"EmailAddressPrev=<current>\" \\\n  --data-urlencode \"MobileCountryCode=<current>\" \\\n  --data-urlencode \"MobileNumber=<current>\" \\\n  --data-urlencode \"Action=<current>\" \\\n  --data-urlencode \"DidChangePassword=N\" \\\n  --data-urlencode \"OldPassword=\" \\\n  --data-urlencode \"NewPassword=\" \\\n  --data-urlencode \"NewPassword2=\" \\\n  --data-urlencode \"OptInArtistActivity=Y\" \\\n  \"https://www.artsonia.com/members/profile/default.asp\"\n  # (omit OptInNews / OptInPromos entirely here to turn them off)\n\n# 4. Verify: re-fetch the profile and confirm the checkbox state changed\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile-after.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile-after.html','utf8')).opt_ins));\n\"\n```\nField names: `OptInNews`, `OptInArtistActivity` (default checked),\n`OptInPromos`. This form also carries password-change fields\n(`OldPassword`/`NewPassword`/`NewPassword2`) — always send them blank.\n\nFile v1.2.5:skill-card.md\n\n## Description:\n\nGuides shell-based access to Artsonia student portfolios, feedback, comments, and artwork using curl without an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nAuthorized parents, guardians, and developers use this skill to retrieve student artwork and account information or manage Artsonia interactions from shell scripts without running the MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Persistent session cookies and downloaded pages can expose account credentials or student information.\n\nMitigation: Use only authorized accounts, keep cookie jars and downloaded data out of shared or synced locations, and restrict or delete them after use.\n\nRisk: Private artwork images may be downloadable without an authenticated session.\n\nMitigation: Access or share private artwork only with explicit permission from the account holder.\n\nRisk: Write actions can change account state or send real invitation emails.\n\nMitigation: Obtain explicit permission before writes, confirm recipients, and recheck the affected page to verify changes.\n\n## Reference(s):\n\n- [Artsonia endpoint recipes](references/endpoints.md)\n- [ClawHub skill release](https://clawhub.ai/chrischall/skills/artsonia-api)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with shell command examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Commands can retrieve student data and perform account changes when run with authorized credentials.]\n\n## Skill Version(s):\n\n1.2.5 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.4: 4 files, 8762 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (1961b), SKILL.md (6422b), _meta.json (131b)\n\nFile v1.2.4:SKILL.md\n\n---\nname: artsonia-api\ndescription: >-\n  Access Artsonia (artsonia.com) student-art portfolios, comments, fans,\n  teacher feedback, and downloads from a shell with curl instead of running\n  the artsonia-mcp server — log in with a username/password form POST to get\n  a session cookie, then curl the server-rendered member pages. Use when you\n  want Artsonia data without the MCP, in a script, or on a machine where the\n  MCP isn't installed.\n---\n\n# Artsonia via curl (no MCP)\n\nArtsonia is a classic server-rendered `.asp` site — no public API, no JSON\nstore, no bot wall. All the data (students, portfolios, comments, fans,\nfeedback, awards, profile) lives in server-rendered HTML, and auth is a real\n**username + password form POST** that hands back an HttpOnly session\ncookie. No browser, no ContextMint Bridge extension, no `fpx` bridge is needed —\n`curl` with a cookie jar reaches everything a signed-in parent account can.\n(The MCP keeps `@fetchproxy/server` as an optional fallback transport for\nsome future walled endpoint, but nothing documented here needs it.)\n\nThis is the same data the `artsonia_*` MCP tools return, reached with `curl`\ninstead of a running server.\n\n## One-time setup\n\n```sh\nexport ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar\n```\n\n## Log in (get the session cookie)\n\nRead the password from stdin (`Password@-`) instead of putting it on the\ncommand line — `--data-urlencode name=value` puts `value` in curl's argv,\nwhich any local user can read via `ps`/`/proc` while the process runs. This\nmirrors how the MCP itself sends the password: only in the POST body, never\non a command line.\n\n```sh\nprintf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp\n```\n\nA successful login redirects away from `login.asp` (final URL ends in\n`.../members/`) and leaves cookies in `$JAR`. If `url_effective` still ends\nin `login.asp`, or `/tmp/artsonia-login.html` matches `You need to log\nin|Parent \\(or Fan\\) Login`, the credentials are wrong — Artsonia has no\nCSRF/`__VIEWSTATE` token, so a bad login is the only reason this fails.\nMagic-link-only accounts can't use this (there's no password to POST).\n\nRe-run this exact command any time a later request looks logged-out (see\n\"Session expiry\" below) — `curl -c` overwrites the jar with a fresh session.\n\n## Core call pattern\n\nEvery read is a `GET` through the jar; every write is a form-urlencoded\n`POST` through the jar. Fetch to a file, then extract:\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\n```\n\nFor the numeric ids every other endpoint needs (`artist_id`, `artwork_id`),\na plain regex on the `href` is enough and needs no extra tooling:\n\n```sh\ngrep -oP '(?<=portfolio\\.asp\\?id=)\\d+' /tmp/dash.html | sort -u\n```\n\nFor the full structured fields (name, school, stats, notifications, awards,\n…) don't re-derive selectors by hand — this repo already ships a\nlive-verified `node-html-parser` scraper (`src/parse.ts` → built\n`dist/parse.js`) with one function per page. Build it once, then import it\nin a one-liner and pipe the JSON to `jq`:\n\n```sh\ncd ~/git/artsonia-mcp && npm install && npm run build   # once, if dist/ is missing\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$(pwd)/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html', 'utf8'))));\n\" | jq '.'\n```\n\n`references/endpoints.md` has the ready-to-run curl command, `dist/parse.js`\nimport, and `jq` projection for every read and write tool, keyed to the same\npaths and selectors the MCP uses.\n\n## The one rule: resolve artist_id (and artwork_id) first\n\nEvery per-student/per-artwork endpoint needs a numeric id you can only get\nfrom a parent call — never guess one:\n\n1. `GET /members/` → `.artist-card` → `artist_id` (from each card's\n   `portfolio.asp?id=` link).\n2. `GET /artists/portfolio.asp?id=<artist_id>` → `.grid-item` that contains\n   an `art.asp` link → `artwork_id`.\n\n## Session expiry\n\nArtsonia expires a session by **redirecting/rendering back to the login\npage**, not a 401. After any GET/POST, check the response body:\n\n```sh\ngrep -qE 'login\\.asp|You need to log in|Parent \\(or Fan\\) Login' /tmp/out.html \\\n  && echo \"EXPIRED — re-run the login step\"\n```\n\n## Writes — real caveats from the MCP build\n\n- **A 3xx is not proof a write persisted.** Artsonia's form handlers 302\n  even on payloads they silently drop. Re-`GET` the page you just changed\n  and confirm the field actually flipped before trusting a write.\n- **Checkboxes submit `value=Y`, never `on`.** Sending `Field=on` is\n  silently ignored by the server (still 302s, saves nothing).\n- **The profile form is read-modify-write.** `/members/profile/`\n  (`#TheForm`) bundles name/email/password/opt-ins in one form — re-POST\n  every current field verbatim (blank the password fields, keep\n  `DidChangePassword=N`), flipping only the one you're changing.\n- **Never POST a password.** Leave `OldPassword`/`NewPassword`/\n  `NewPassword2` blank/omitted.\n\nSee `references/endpoints.md` for the exact body of every write, and how to\nre-read and verify each one.\n\n## Artwork images (no auth needed)\n\nFull-resolution images live on a public CDN — no cookie required, even for\nprivate pieces:\n\n```sh\ncurl -s -o \"$artwork_id.jpg\" \"https://images.artsonia.com/art/full/$artwork_id.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$artwork_id.jpg\" | grep -i last-modified   # per-artwork upload date\n```\n\nResolutions (small→large): `small` `medium` `large` `xlarge` `full`.\n\n## Notes\n\n- Reads/writes both go through the member site (`www.artsonia.com`);\n  nothing here needs the `fetchproxy`/browser-bridge path the MCP keeps as\n  an optional fallback.\n- Comment-item markup on `/museum/art.asp` is unverified upstream (both\n  captured accounts had 0 comments) — `references/endpoints.md` flags it.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.4:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"artsonia-api\",\n  \"version\": \"1.2.4\",\n  \"publishedAt\": 1791168491698\n}\n\nFile v1.2.4:references/endpoints.md\n\n# Artsonia endpoints — curl + parser recipes\n\nAll paths are relative to `https://www.artsonia.com`. `$JAR` is the cookie\njar from `SKILL.md`'s login step. All parsing functions below are the\nMCP's own live-verified `src/parse.ts` (built to `dist/parse.js`) — build\nonce with `npm install && npm run build` in the repo, then reuse the\n`node --input-type=module` one-liner shown per endpoint. Swap in\n`grep -oP '(?<=KEY=)\\d+'` on the raw HTML instead if you only need an id and\ndon't want to build the repo.\n\nShorthand used below: `$REPO` = the absolute path to this clone of\n`artsonia-mcp` (e.g. `~/git/artsonia-mcp`); `$ID` = a resolved `artist_id`;\n`$ART` = a resolved `artwork_id`.\n\n```sh\n# generic shape of every parse call — swap the function name + input path\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents as fn } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(fn(readFileSync('/tmp/page.html', 'utf8'))));\n\" | jq '.'\n```\n\n## Reads\n\n### list_students — GET `/members/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artist_id)\\t\\(.name)\\t\\(.school)\\tgrade \\(.grade)\\t\\(.artwork_count) artworks\"'\n```\nFields: `artist_id`, `name`, `school`, `grade`, `artwork_count`,\n`fan_count`, `comment_count`, `feedback_count`, `award_count`,\n`portfolio_path`. Selector: `.artist-card`.\n\n### get_activity — GET `/members/` (notifications)\n\nSame fetch as above, different parser:\n\n```sh\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseNotifications } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseNotifications(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq '.'\n```\nShape: `{ count, items: [{ title, body, href }] }`. Selectors:\n`.textSubhead` (text `Notifications (N)`) + `div.notice`.\n\n### get_portfolio — GET `/artists/portfolio.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/portfolio.asp?id=$ID\" -o /tmp/portfolio.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parsePortfolio } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parsePortfolio(readFileSync('/tmp/portfolio.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artwork_id)\\t\\(.is_private)\\t\\(.thumbnail)\"'\n```\nFields: `artwork_id`, `is_private`, `thumbnail` (derived\n`images.artsonia.com/art/small/<id>.jpg`). Selector: `.grid-item` that\ncontains an `a[href*=\"art.asp\"]` (the first `.grid-item` is a non-artwork\nsection header — no art link — and is dropped by that filter).\n\n### get_artwork / list_comments — GET `/museum/art.asp?id=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/museum/art.asp?id=$ART\" -o /tmp/artwork.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseArtwork } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseArtwork(readFileSync('/tmp/artwork.html','utf8'))));\n\" | jq '{title, artist_screen_name, views, grade, project, comments}'\n```\nFields: `title`, `artist_screen_name`, `views`, `grade`, `project`,\n`comment_entry: {artist_id, artwork_id}|null`, `comments: [{author, text}]`.\n`list_comments` is just the `.comments` array from the same fetch.\n**Comment-item markup (`.comment`/`.comment-author`/`.comment-text`) is\nUNVERIFIED upstream** — both captured accounts had 0 comments, so it\ndegrades to `[]` without throwing but hasn't been confirmed against a real\ncomment. Verify against a live artwork with comments before trusting it.\n\n### get_fans — GET `/members/fanclub/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/fanclub/?artist=$ID\" -o /tmp/fans.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFans } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFans(readFileSync('/tmp/fans.html','utf8'))));\n\" | jq '.'\n```\nFields: `name`, `relationship`. Selector: `.fan-card` (name from the first\n`a.hiddenlink`; relationship from the card's own-text div, e.g. \"Father\" —\nthe card also carries the fan's email, which the MCP deliberately doesn't\nsurface).\n\n### get_feedback — GET `/members/feedback/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/feedback/?artist=$ID\" -o /tmp/feedback.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFeedback } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFeedback(readFileSync('/tmp/feedback.html','utf8'))));\n\" | jq '[.[] | select(.is_read==false)]'   # unread only\n```\nFields: `artwork_id`, `message`, `posted_by`, `is_read` (false while the row\nsays \"has not been marked as read\"), `thumbnail`. Selector: `.comment-row`\n(message `.comment`, attribution `.commenter`, read state\n`.comment-options`).\n\n### get_awards — GET `/artists/awards.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/awards.asp?id=$ID\" -o /tmp/awards.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseAwards } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseAwards(readFileSync('/tmp/awards.html','utf8'))));\n\" | jq '[.[] | select(.earned)]'   # earned only\n```\nFields: `name`, `earned`, `description`, `progress`,\n`period: \"current\"|\"past\"`. Selectors: `.award-card` (current year —\nname/\"Earned\"|\"Not earned\"/criteria/progress) + `.award-card-past`\n(prior-year badge icons, name derived from `artist_<name>[_ghosted].gif`).\n\n### get_profile — GET `/members/profile/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile.html','utf8'))));\n\" | jq '.'\n```\nFields: `first_name`, `last_name`, `email`, `mobile`,\n`opt_ins: {news, artist_activity, promos}`. Selector: `#TheForm`\n(`input[name=...]`/`select[name=...]`).\n\n## Downloads (public CDN, no auth)\n\n```sh\ncurl -s -o \"$ART.jpg\" \"https://images.artsonia.com/art/full/$ART.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$ART.jpg\" | grep -i last-modified\n```\nNo teacher-entered \"created\" date exists anywhere in the HTML; the CDN\nimage's `Last-Modified` header is the real per-artwork date (verified\nspanning 2022→2026 on one portfolio). Resolutions: `small` (~11 KB)\n`medium` (~34 KB) `large` (~58 KB) `xlarge` (~102 KB) `full`/`original`\n(~665 KB). To bulk-download a portfolio: loop `get_portfolio`'s\n`artwork_id`s through the CDN curl above. There's no starred/favorited\ncontrol on the artwork page for the parent role — don't look for one.\n\n## Writes (POST, form-urlencoded, session-cookie auth, no CSRF)\n\nEvery write below: **a 3xx from curl is not proof it persisted** — Artsonia\n302s even on a silently-dropped payload. Re-GET the affected page (recipes\nabove) and diff before trusting it.\n\n### post_comment — POST `/museum/enter.asp?artist=$ID&art=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/comment-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"Comment=Nice work!\" \\\n  \"https://www.artsonia.com/museum/enter.asp?artist=$ID&art=$ART\"\n```\nBody: `Comment=<text>` only. **Not verifiable from the parent side** — the\nMCP's live test got a 302 to a content page (not a form bounce) but the\ncomment never appeared on the public artwork page or the parent comments\nview; most likely held for teacher moderation. Treat a 302 here as\n\"submitted/pending,\" never \"posted.\"\n\n### invite_fan — POST `/members/fanclub/add.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/invite-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"MemberType=fan\" \\\n  --data-urlencode \"RelationshipID=<relationship-id-from-the-live-Add-Fans-form>\" \\\n  --data-urlencode \"FirstName=Test\" \\\n  --data-urlencode \"LastName=Fan\" \\\n  --data-urlencode \"EmailAddress=test@example.com\" \\\n  --data-urlencode \"ArtistID=$ID\" \\\n  \"https://www.artsonia.com/members/fanclub/add.asp?artist=$ID\"\n```\nOnly test with a real address you're authorized to invite (`@example.com`\nfor dry testing — it sends a real invite email). `MemberType=fan` is\nassumed correct (live-verified by the MCP build); `RelationshipID` is the\n`<select>` value from `/members/fanclub/add.asp?artist=$ID`'s live form —\nread that page first if you don't already know the code. Add\n`--data-urlencode \"IsParent=on\"` if the fan is also a parent/guardian.\nVerify by re-`GET`ting the fan club (`get_fans` above) — a successful\ninvite shows up as a pending fan.\n\n### mark_feedback_read — POST `/members/feedback/default.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/mark-read-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"ConfirmAsRead=Mark as Read\" \\\n  \"https://www.artsonia.com/members/feedback/default.asp?artist=$ID\"\n```\nBody: `ConfirmAsRead=Mark as Read` only. Marks **all** of the student's\nfeedback (no per-item control). Verify by re-running `get_feedback` above\nand confirming no row has `is_read: false`.\n\n### set_notifications — read `/members/profile/`, POST `/members/profile/default.asp`\n\nThis is a read-modify-write of the whole profile form — re-send every\ncurrent field, flip only the opt-in(s), never send a password:\n\n```sh\n# 1. Read current form values (also needed for every other field below)\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\n\n# 2. Inspect it to see the *current* value of every input/select in #TheForm\n#    (FirstName, LastName, EmailAddress, MobileNumber, MobileCountryCode,\n#    Action, EmailAddressPrev, DidChangePassword, OptInNews,\n#    OptInArtistActivity, OptInPromos, ...). Checkboxes submit their real\n#    `value` attribute (Artsonia uses \"Y\", never \"on\") when checked, and are\n#    OMITTED entirely when unchecked — exactly like a browser form.\ngrep -oP '<input[^>]*name=\"[^\"]+\"[^>]*>' /tmp/profile.html\n\n# 3. Re-POST every field verbatim EXCEPT: blank OldPassword/NewPassword/\n#    NewPassword2, keep DidChangePassword=N, and flip only the opt-in(s)\n#    you're changing (include `Field=Y` to turn ON, omit `Field` entirely\n#    to turn OFF).\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/notif-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"FirstName=<current>\" \\\n  --data-urlencode \"LastName=<current>\" \\\n  --data-urlencode \"EmailAddress=<current>\" \\\n  --data-urlencode \"EmailAddressPrev=<current>\" \\\n  --data-urlencode \"MobileCountryCode=<current>\" \\\n  --data-urlencode \"MobileNumber=<current>\" \\\n  --data-urlencode \"Action=<current>\" \\\n  --data-urlencode \"DidChangePassword=N\" \\\n  --data-urlencode \"OldPassword=\" \\\n  --data-urlencode \"NewPassword=\" \\\n  --data-urlencode \"NewPassword2=\" \\\n  --data-urlencode \"OptInArtistActivity=Y\" \\\n  \"https://www.artsonia.com/members/profile/default.asp\"\n  # (omit OptInNews / OptInPromos entirely here to turn them off)\n\n# 4. Verify: re-fetch the profile and confirm the checkbox state changed\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile-after.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile-after.html','utf8')).opt_ins));\n\"\n```\nField names: `OptInNews`, `OptInArtistActivity` (default checked),\n`OptInPromos`. This form also carries password-change fields\n(`OldPassword`/`NewPassword`/`NewPassword2`) — always send them blank.\n\nFile v1.2.4:skill-card.md\n\n## Description:\n\nGuides agents in accessing Artsonia student portfolios, comments, fans, feedback, and artwork through authenticated shell requests without an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nAuthorized parents and developers use this skill to retrieve student artwork and account information or perform approved account actions using shell commands instead of an MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Authenticated access exposes private student and family content, credentials, session cookies, and downloaded artwork.\n\nMitigation: Use only accounts and content you are authorized to access; protect credentials, session cookies, downloaded pages, profile data, and artwork as sensitive.\n\nRisk: Write commands can send real fan invitations or change live account and feedback state.\n\nMitigation: Require explicit approval of the exact action, recipient, artist or artwork ID, and expected effect before running any write command; re-read affected pages to check results.\n\n## Reference(s):\n\n- [Artsonia API skill release](https://clawhub.ai/chrischall/skills/artsonia-api)\n- [Artsonia endpoint recipes](references/endpoints.md)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with shell commands]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Commands may retrieve account pages, student artwork, and profile data or submit account changes.]\n\n## Skill Version(s):\n\n1.2.4 (source: server-resolved release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.3: 4 files, 8836 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2217b), SKILL.md (6422b), _meta.json (131b)\n\nFile v1.2.3:SKILL.md\n\n---\nname: artsonia-api\ndescription: >-\n  Access Artsonia (artsonia.com) student-art portfolios, comments, fans,\n  teacher feedback, and downloads from a shell with curl instead of running\n  the artsonia-mcp server — log in with a username/password form POST to get\n  a session cookie, then curl the server-rendered member pages. Use when you\n  want Artsonia data without the MCP, in a script, or on a machine where the\n  MCP isn't installed.\n---\n\n# Artsonia via curl (no MCP)\n\nArtsonia is a classic server-rendered `.asp` site — no public API, no JSON\nstore, no bot wall. All the data (students, portfolios, comments, fans,\nfeedback, awards, profile) lives in server-rendered HTML, and auth is a real\n**username + password form POST** that hands back an HttpOnly session\ncookie. No browser, no ContextMint Bridge extension, no `fpx` bridge is needed —\n`curl` with a cookie jar reaches everything a signed-in parent account can.\n(The MCP keeps `@fetchproxy/server` as an optional fallback transport for\nsome future walled endpoint, but nothing documented here needs it.)\n\nThis is the same data the `artsonia_*` MCP tools return, reached with `curl`\ninstead of a running server.\n\n## One-time setup\n\n```sh\nexport ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar\n```\n\n## Log in (get the session cookie)\n\nRead the password from stdin (`Password@-`) instead of putting it on the\ncommand line — `--data-urlencode name=value` puts `value` in curl's argv,\nwhich any local user can read via `ps`/`/proc` while the process runs. This\nmirrors how the MCP itself sends the password: only in the POST body, never\non a command line.\n\n```sh\nprintf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp\n```\n\nA successful login redirects away from `login.asp` (final URL ends in\n`.../members/`) and leaves cookies in `$JAR`. If `url_effective` still ends\nin `login.asp`, or `/tmp/artsonia-login.html` matches `You need to log\nin|Parent \\(or Fan\\) Login`, the credentials are wrong — Artsonia has no\nCSRF/`__VIEWSTATE` token, so a bad login is the only reason this fails.\nMagic-link-only accounts can't use this (there's no password to POST).\n\nRe-run this exact command any time a later request looks logged-out (see\n\"Session expiry\" below) — `curl -c` overwrites the jar with a fresh session.\n\n## Core call pattern\n\nEvery read is a `GET` through the jar; every write is a form-urlencoded\n`POST` through the jar. Fetch to a file, then extract:\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\n```\n\nFor the numeric ids every other endpoint needs (`artist_id`, `artwork_id`),\na plain regex on the `href` is enough and needs no extra tooling:\n\n```sh\ngrep -oP '(?<=portfolio\\.asp\\?id=)\\d+' /tmp/dash.html | sort -u\n```\n\nFor the full structured fields (name, school, stats, notifications, awards,\n…) don't re-derive selectors by hand — this repo already ships a\nlive-verified `node-html-parser` scraper (`src/parse.ts` → built\n`dist/parse.js`) with one function per page. Build it once, then import it\nin a one-liner and pipe the JSON to `jq`:\n\n```sh\ncd ~/git/artsonia-mcp && npm install && npm run build   # once, if dist/ is missing\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$(pwd)/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html', 'utf8'))));\n\" | jq '.'\n```\n\n`references/endpoints.md` has the ready-to-run curl command, `dist/parse.js`\nimport, and `jq` projection for every read and write tool, keyed to the same\npaths and selectors the MCP uses.\n\n## The one rule: resolve artist_id (and artwork_id) first\n\nEvery per-student/per-artwork endpoint needs a numeric id you can only get\nfrom a parent call — never guess one:\n\n1. `GET /members/` → `.artist-card` → `artist_id` (from each card's\n   `portfolio.asp?id=` link).\n2. `GET /artists/portfolio.asp?id=<artist_id>` → `.grid-item` that contains\n   an `art.asp` link → `artwork_id`.\n\n## Session expiry\n\nArtsonia expires a session by **redirecting/rendering back to the login\npage**, not a 401. After any GET/POST, check the response body:\n\n```sh\ngrep -qE 'login\\.asp|You need to log in|Parent \\(or Fan\\) Login' /tmp/out.html \\\n  && echo \"EXPIRED — re-run the login step\"\n```\n\n## Writes — real caveats from the MCP build\n\n- **A 3xx is not proof a write persisted.** Artsonia's form handlers 302\n  even on payloads they silently drop. Re-`GET` the page you just changed\n  and confirm the field actually flipped before trusting a write.\n- **Checkboxes submit `value=Y`, never `on`.** Sending `Field=on` is\n  silently ignored by the server (still 302s, saves nothing).\n- **The profile form is read-modify-write.** `/members/profile/`\n  (`#TheForm`) bundles name/email/password/opt-ins in one form — re-POST\n  every current field verbatim (blank the password fields, keep\n  `DidChangePassword=N`), flipping only the one you're changing.\n- **Never POST a password.** Leave `OldPassword`/`NewPassword`/\n  `NewPassword2` blank/omitted.\n\nSee `references/endpoints.md` for the exact body of every write, and how to\nre-read and verify each one.\n\n## Artwork images (no auth needed)\n\nFull-resolution images live on a public CDN — no cookie required, even for\nprivate pieces:\n\n```sh\ncurl -s -o \"$artwork_id.jpg\" \"https://images.artsonia.com/art/full/$artwork_id.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$artwork_id.jpg\" | grep -i last-modified   # per-artwork upload date\n```\n\nResolutions (small→large): `small` `medium` `large` `xlarge` `full`.\n\n## Notes\n\n- Reads/writes both go through the member site (`www.artsonia.com`);\n  nothing here needs the `fetchproxy`/browser-bridge path the MCP keeps as\n  an optional fallback.\n- Comment-item markup on `/museum/art.asp` is unverified upstream (both\n  captured accounts had 0 comments) — `references/endpoints.md` flags it.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.3:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"artsonia-api\",\n  \"version\": \"1.2.3\",\n  \"publishedAt\": 1790991590095\n}\n\nFile v1.2.3:references/endpoints.md\n\n# Artsonia endpoints — curl + parser recipes\n\nAll paths are relative to `https://www.artsonia.com`. `$JAR` is the cookie\njar from `SKILL.md`'s login step. All parsing functions below are the\nMCP's own live-verified `src/parse.ts` (built to `dist/parse.js`) — build\nonce with `npm install && npm run build` in the repo, then reuse the\n`node --input-type=module` one-liner shown per endpoint. Swap in\n`grep -oP '(?<=KEY=)\\d+'` on the raw HTML instead if you only need an id and\ndon't want to build the repo.\n\nShorthand used below: `$REPO` = the absolute path to this clone of\n`artsonia-mcp` (e.g. `~/git/artsonia-mcp`); `$ID` = a resolved `artist_id`;\n`$ART` = a resolved `artwork_id`.\n\n```sh\n# generic shape of every parse call — swap the function name + input path\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents as fn } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(fn(readFileSync('/tmp/page.html', 'utf8'))));\n\" | jq '.'\n```\n\n## Reads\n\n### list_students — GET `/members/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artist_id)\\t\\(.name)\\t\\(.school)\\tgrade \\(.grade)\\t\\(.artwork_count) artworks\"'\n```\nFields: `artist_id`, `name`, `school`, `grade`, `artwork_count`,\n`fan_count`, `comment_count`, `feedback_count`, `award_count`,\n`portfolio_path`. Selector: `.artist-card`.\n\n### get_activity — GET `/members/` (notifications)\n\nSame fetch as above, different parser:\n\n```sh\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseNotifications } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseNotifications(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq '.'\n```\nShape: `{ count, items: [{ title, body, href }] }`. Selectors:\n`.textSubhead` (text `Notifications (N)`) + `div.notice`.\n\n### get_portfolio — GET `/artists/portfolio.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/portfolio.asp?id=$ID\" -o /tmp/portfolio.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parsePortfolio } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parsePortfolio(readFileSync('/tmp/portfolio.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artwork_id)\\t\\(.is_private)\\t\\(.thumbnail)\"'\n```\nFields: `artwork_id`, `is_private`, `thumbnail` (derived\n`images.artsonia.com/art/small/<id>.jpg`). Selector: `.grid-item` that\ncontains an `a[href*=\"art.asp\"]` (the first `.grid-item` is a non-artwork\nsection header — no art link — and is dropped by that filter).\n\n### get_artwork / list_comments — GET `/museum/art.asp?id=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/museum/art.asp?id=$ART\" -o /tmp/artwork.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseArtwork } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseArtwork(readFileSync('/tmp/artwork.html','utf8'))));\n\" | jq '{title, artist_screen_name, views, grade, project, comments}'\n```\nFields: `title`, `artist_screen_name`, `views`, `grade`, `project`,\n`comment_entry: {artist_id, artwork_id}|null`, `comments: [{author, text}]`.\n`list_comments` is just the `.comments` array from the same fetch.\n**Comment-item markup (`.comment`/`.comment-author`/`.comment-text`) is\nUNVERIFIED upstream** — both captured accounts had 0 comments, so it\ndegrades to `[]` without throwing but hasn't been confirmed against a real\ncomment. Verify against a live artwork with comments before trusting it.\n\n### get_fans — GET `/members/fanclub/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/fanclub/?artist=$ID\" -o /tmp/fans.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFans } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFans(readFileSync('/tmp/fans.html','utf8'))));\n\" | jq '.'\n```\nFields: `name`, `relationship`. Selector: `.fan-card` (name from the first\n`a.hiddenlink`; relationship from the card's own-text div, e.g. \"Father\" —\nthe card also carries the fan's email, which the MCP deliberately doesn't\nsurface).\n\n### get_feedback — GET `/members/feedback/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/feedback/?artist=$ID\" -o /tmp/feedback.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFeedback } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFeedback(readFileSync('/tmp/feedback.html','utf8'))));\n\" | jq '[.[] | select(.is_read==false)]'   # unread only\n```\nFields: `artwork_id`, `message`, `posted_by`, `is_read` (false while the row\nsays \"has not been marked as read\"), `thumbnail`. Selector: `.comment-row`\n(message `.comment`, attribution `.commenter`, read state\n`.comment-options`).\n\n### get_awards — GET `/artists/awards.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/awards.asp?id=$ID\" -o /tmp/awards.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseAwards } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseAwards(readFileSync('/tmp/awards.html','utf8'))));\n\" | jq '[.[] | select(.earned)]'   # earned only\n```\nFields: `name`, `earned`, `description`, `progress`,\n`period: \"current\"|\"past\"`. Selectors: `.award-card` (current year —\nname/\"Earned\"|\"Not earned\"/criteria/progress) + `.award-card-past`\n(prior-year badge icons, name derived from `artist_<name>[_ghosted].gif`).\n\n### get_profile — GET `/members/profile/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile.html','utf8'))));\n\" | jq '.'\n```\nFields: `first_name`, `last_name`, `email`, `mobile`,\n`opt_ins: {news, artist_activity, promos}`. Selector: `#TheForm`\n(`input[name=...]`/`select[name=...]`).\n\n## Downloads (public CDN, no auth)\n\n```sh\ncurl -s -o \"$ART.jpg\" \"https://images.artsonia.com/art/full/$ART.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$ART.jpg\" | grep -i last-modified\n```\nNo teacher-entered \"created\" date exists anywhere in the HTML; the CDN\nimage's `Last-Modified` header is the real per-artwork date (verified\nspanning 2022→2026 on one portfolio). Resolutions: `small` (~11 KB)\n`medium` (~34 KB) `large` (~58 KB) `xlarge` (~102 KB) `full`/`original`\n(~665 KB). To bulk-download a portfolio: loop `get_portfolio`'s\n`artwork_id`s through the CDN curl above. There's no starred/favorited\ncontrol on the artwork page for the parent role — don't look for one.\n\n## Writes (POST, form-urlencoded, session-cookie auth, no CSRF)\n\nEvery write below: **a 3xx from curl is not proof it persisted** — Artsonia\n302s even on a silently-dropped payload. Re-GET the affected page (recipes\nabove) and diff before trusting it.\n\n### post_comment — POST `/museum/enter.asp?artist=$ID&art=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/comment-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"Comment=Nice work!\" \\\n  \"https://www.artsonia.com/museum/enter.asp?artist=$ID&art=$ART\"\n```\nBody: `Comment=<text>` only. **Not verifiable from the parent side** — the\nMCP's live test got a 302 to a content page (not a form bounce) but the\ncomment never appeared on the public artwork page or the parent comments\nview; most likely held for teacher moderation. Treat a 302 here as\n\"submitted/pending,\" never \"posted.\"\n\n### invite_fan — POST `/members/fanclub/add.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/invite-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"MemberType=fan\" \\\n  --data-urlencode \"RelationshipID=<relationship-id-from-the-live-Add-Fans-form>\" \\\n  --data-urlencode \"FirstName=Test\" \\\n  --data-urlencode \"LastName=Fan\" \\\n  --data-urlencode \"EmailAddress=test@example.com\" \\\n  --data-urlencode \"ArtistID=$ID\" \\\n  \"https://www.artsonia.com/members/fanclub/add.asp?artist=$ID\"\n```\nOnly test with a real address you're authorized to invite (`@example.com`\nfor dry testing — it sends a real invite email). `MemberType=fan` is\nassumed correct (live-verified by the MCP build); `RelationshipID` is the\n`<select>` value from `/members/fanclub/add.asp?artist=$ID`'s live form —\nread that page first if you don't already know the code. Add\n`--data-urlencode \"IsParent=on\"` if the fan is also a parent/guardian.\nVerify by re-`GET`ting the fan club (`get_fans` above) — a successful\ninvite shows up as a pending fan.\n\n### mark_feedback_read — POST `/members/feedback/default.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/mark-read-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"ConfirmAsRead=Mark as Read\" \\\n  \"https://www.artsonia.com/members/feedback/default.asp?artist=$ID\"\n```\nBody: `ConfirmAsRead=Mark as Read` only. Marks **all** of the student's\nfeedback (no per-item control). Verify by re-running `get_feedback` above\nand confirming no row has `is_read: false`.\n\n### set_notifications — read `/members/profile/`, POST `/members/profile/default.asp`\n\nThis is a read-modify-write of the whole profile form — re-send every\ncurrent field, flip only the opt-in(s), never send a password:\n\n```sh\n# 1. Read current form values (also needed for every other field below)\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\n\n# 2. Inspect it to see the *current* value of every input/select in #TheForm\n#    (FirstName, LastName, EmailAddress, MobileNumber, MobileCountryCode,\n#    Action, EmailAddressPrev, DidChangePassword, OptInNews,\n#    OptInArtistActivity, OptInPromos, ...). Checkboxes submit their real\n#    `value` attribute (Artsonia uses \"Y\", never \"on\") when checked, and are\n#    OMITTED entirely when unchecked — exactly like a browser form.\ngrep -oP '<input[^>]*name=\"[^\"]+\"[^>]*>' /tmp/profile.html\n\n# 3. Re-POST every field verbatim EXCEPT: blank OldPassword/NewPassword/\n#    NewPassword2, keep DidChangePassword=N, and flip only the opt-in(s)\n#    you're changing (include `Field=Y` to turn ON, omit `Field` entirely\n#    to turn OFF).\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/notif-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"FirstName=<current>\" \\\n  --data-urlencode \"LastName=<current>\" \\\n  --data-urlencode \"EmailAddress=<current>\" \\\n  --data-urlencode \"EmailAddressPrev=<current>\" \\\n  --data-urlencode \"MobileCountryCode=<current>\" \\\n  --data-urlencode \"MobileNumber=<current>\" \\\n  --data-urlencode \"Action=<current>\" \\\n  --data-urlencode \"DidChangePassword=N\" \\\n  --data-urlencode \"OldPassword=\" \\\n  --data-urlencode \"NewPassword=\" \\\n  --data-urlencode \"NewPassword2=\" \\\n  --data-urlencode \"OptInArtistActivity=Y\" \\\n  \"https://www.artsonia.com/members/profile/default.asp\"\n  # (omit OptInNews / OptInPromos entirely here to turn them off)\n\n# 4. Verify: re-fetch the profile and confirm the checkbox state changed\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile-after.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile-after.html','utf8')).opt_ins));\n\"\n```\nField names: `OptInNews`, `OptInArtistActivity` (default checked),\n`OptInPromos`. This form also carries password-change fields\n(`OldPassword`/`NewPassword`/`NewPassword2`) — always send them blank.\n\nFile v1.2.3:skill-card.md\n\n## Description:\n\nGuides agents in using shell commands to access Artsonia student-art portfolios, comments, fans, teacher feedback, and downloads through an authorized parent account without running an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nParents and authorized agents use this skill to retrieve student artwork and account information from Artsonia using shell commands, and to manage permitted account interactions without an MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Direct access to a parent account exposes login credentials, session cookies, and student information.\n\nMitigation: Use only an account and student data you are authorized to access; protect credentials and local session cookies, and review access before installing.\n\nRisk: Unauthenticated artwork links and bulk downloads can expose artwork described as private.\n\nMitigation: Avoid private-artwork and bulk-download recipes without clear permission.\n\nRisk: Comments, fan invitations, feedback reads, and profile or notification changes may affect other people or account settings.\n\nMitigation: Require explicit confirmation before these actions and verify any submitted change by reading it back.\n\n## Reference(s):\n\n- [Artsonia API endpoint recipes](references/endpoints.md)\n- [Artsonia API ClawHub release](https://clawhub.ai/chrischall/skills/artsonia-api)\n- [Artsonia](https://www.artsonia.com)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance, Configuration instructions]\n\n**Output Format:** [Markdown with shell commands]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Commands can retrieve page data or submit account changes; responses may include student information.]\n\n## Skill Version(s):\n\n1.2.3 (source: ClawHub release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.2: 4 files, 8801 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2067b), SKILL.md (6422b), _meta.json (131b)\n\nFile v1.2.2:SKILL.md\n\n---\nname: artsonia-api\ndescription: >-\n  Access Artsonia (artsonia.com) student-art portfolios, comments, fans,\n  teacher feedback, and downloads from a shell with curl instead of running\n  the artsonia-mcp server — log in with a username/password form POST to get\n  a session cookie, then curl the server-rendered member pages. Use when you\n  want Artsonia data without the MCP, in a script, or on a machine where the\n  MCP isn't installed.\n---\n\n# Artsonia via curl (no MCP)\n\nArtsonia is a classic server-rendered `.asp` site — no public API, no JSON\nstore, no bot wall. All the data (students, portfolios, comments, fans,\nfeedback, awards, profile) lives in server-rendered HTML, and auth is a real\n**username + password form POST** that hands back an HttpOnly session\ncookie. No browser, no ContextMint Bridge extension, no `fpx` bridge is needed —\n`curl` with a cookie jar reaches everything a signed-in parent account can.\n(The MCP keeps `@fetchproxy/server` as an optional fallback transport for\nsome future walled endpoint, but nothing documented here needs it.)\n\nThis is the same data the `artsonia_*` MCP tools return, reached with `curl`\ninstead of a running server.\n\n## One-time setup\n\n```sh\nexport ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar\n```\n\n## Log in (get the session cookie)\n\nRead the password from stdin (`Password@-`) instead of putting it on the\ncommand line — `--data-urlencode name=value` puts `value` in curl's argv,\nwhich any local user can read via `ps`/`/proc` while the process runs. This\nmirrors how the MCP itself sends the password: only in the POST body, never\non a command line.\n\n```sh\nprintf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp\n```\n\nA successful login redirects away from `login.asp` (final URL ends in\n`.../members/`) and leaves cookies in `$JAR`. If `url_effective` still ends\nin `login.asp`, or `/tmp/artsonia-login.html` matches `You need to log\nin|Parent \\(or Fan\\) Login`, the credentials are wrong — Artsonia has no\nCSRF/`__VIEWSTATE` token, so a bad login is the only reason this fails.\nMagic-link-only accounts can't use this (there's no password to POST).\n\nRe-run this exact command any time a later request looks logged-out (see\n\"Session expiry\" below) — `curl -c` overwrites the jar with a fresh session.\n\n## Core call pattern\n\nEvery read is a `GET` through the jar; every write is a form-urlencoded\n`POST` through the jar. Fetch to a file, then extract:\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\n```\n\nFor the numeric ids every other endpoint needs (`artist_id`, `artwork_id`),\na plain regex on the `href` is enough and needs no extra tooling:\n\n```sh\ngrep -oP '(?<=portfolio\\.asp\\?id=)\\d+' /tmp/dash.html | sort -u\n```\n\nFor the full structured fields (name, school, stats, notifications, awards,\n…) don't re-derive selectors by hand — this repo already ships a\nlive-verified `node-html-parser` scraper (`src/parse.ts` → built\n`dist/parse.js`) with one function per page. Build it once, then import it\nin a one-liner and pipe the JSON to `jq`:\n\n```sh\ncd ~/git/artsonia-mcp && npm install && npm run build   # once, if dist/ is missing\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$(pwd)/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html', 'utf8'))));\n\" | jq '.'\n```\n\n`references/endpoints.md` has the ready-to-run curl command, `dist/parse.js`\nimport, and `jq` projection for every read and write tool, keyed to the same\npaths and selectors the MCP uses.\n\n## The one rule: resolve artist_id (and artwork_id) first\n\nEvery per-student/per-artwork endpoint needs a numeric id you can only get\nfrom a parent call — never guess one:\n\n1. `GET /members/` → `.artist-card` → `artist_id` (from each card's\n   `portfolio.asp?id=` link).\n2. `GET /artists/portfolio.asp?id=<artist_id>` → `.grid-item` that contains\n   an `art.asp` link → `artwork_id`.\n\n## Session expiry\n\nArtsonia expires a session by **redirecting/rendering back to the login\npage**, not a 401. After any GET/POST, check the response body:\n\n```sh\ngrep -qE 'login\\.asp|You need to log in|Parent \\(or Fan\\) Login' /tmp/out.html \\\n  && echo \"EXPIRED — re-run the login step\"\n```\n\n## Writes — real caveats from the MCP build\n\n- **A 3xx is not proof a write persisted.** Artsonia's form handlers 302\n  even on payloads they silently drop. Re-`GET` the page you just changed\n  and confirm the field actually flipped before trusting a write.\n- **Checkboxes submit `value=Y`, never `on`.** Sending `Field=on` is\n  silently ignored by the server (still 302s, saves nothing).\n- **The profile form is read-modify-write.** `/members/profile/`\n  (`#TheForm`) bundles name/email/password/opt-ins in one form — re-POST\n  every current field verbatim (blank the password fields, keep\n  `DidChangePassword=N`), flipping only the one you're changing.\n- **Never POST a password.** Leave `OldPassword`/`NewPassword`/\n  `NewPassword2` blank/omitted.\n\nSee `references/endpoints.md` for the exact body of every write, and how to\nre-read and verify each one.\n\n## Artwork images (no auth needed)\n\nFull-resolution images live on a public CDN — no cookie required, even for\nprivate pieces:\n\n```sh\ncurl -s -o \"$artwork_id.jpg\" \"https://images.artsonia.com/art/full/$artwork_id.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$artwork_id.jpg\" | grep -i last-modified   # per-artwork upload date\n```\n\nResolutions (small→large): `small` `medium` `large` `xlarge` `full`.\n\n## Notes\n\n- Reads/writes both go through the member site (`www.artsonia.com`);\n  nothing here needs the `fetchproxy`/browser-bridge path the MCP keeps as\n  an optional fallback.\n- Comment-item markup on `/museum/art.asp` is unverified upstream (both\n  captured accounts had 0 comments) — `references/endpoints.md` flags it.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.2:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"artsonia-api\",\n  \"version\": \"1.2.2\",\n  \"publishedAt\": 1790603757140\n}\n\nFile v1.2.2:references/endpoints.md\n\n# Artsonia endpoints — curl + parser recipes\n\nAll paths are relative to `https://www.artsonia.com`. `$JAR` is the cookie\njar from `SKILL.md`'s login step. All parsing functions below are the\nMCP's own live-verified `src/parse.ts` (built to `dist/parse.js`) — build\nonce with `npm install && npm run build` in the repo, then reuse the\n`node --input-type=module` one-liner shown per endpoint. Swap in\n`grep -oP '(?<=KEY=)\\d+'` on the raw HTML instead if you only need an id and\ndon't want to build the repo.\n\nShorthand used below: `$REPO` = the absolute path to this clone of\n`artsonia-mcp` (e.g. `~/git/artsonia-mcp`); `$ID` = a resolved `artist_id`;\n`$ART` = a resolved `artwork_id`.\n\n```sh\n# generic shape of every parse call — swap the function name + input path\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents as fn } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(fn(readFileSync('/tmp/page.html', 'utf8'))));\n\" | jq '.'\n```\n\n## Reads\n\n### list_students — GET `/members/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artist_id)\\t\\(.name)\\t\\(.school)\\tgrade \\(.grade)\\t\\(.artwork_count) artworks\"'\n```\nFields: `artist_id`, `name`, `school`, `grade`, `artwork_count`,\n`fan_count`, `comment_count`, `feedback_count`, `award_count`,\n`portfolio_path`. Selector: `.artist-card`.\n\n### get_activity — GET `/members/` (notifications)\n\nSame fetch as above, different parser:\n\n```sh\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseNotifications } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseNotifications(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq '.'\n```\nShape: `{ count, items: [{ title, body, href }] }`. Selectors:\n`.textSubhead` (text `Notifications (N)`) + `div.notice`.\n\n### get_portfolio — GET `/artists/portfolio.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/portfolio.asp?id=$ID\" -o /tmp/portfolio.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parsePortfolio } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parsePortfolio(readFileSync('/tmp/portfolio.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artwork_id)\\t\\(.is_private)\\t\\(.thumbnail)\"'\n```\nFields: `artwork_id`, `is_private`, `thumbnail` (derived\n`images.artsonia.com/art/small/<id>.jpg`). Selector: `.grid-item` that\ncontains an `a[href*=\"art.asp\"]` (the first `.grid-item` is a non-artwork\nsection header — no art link — and is dropped by that filter).\n\n### get_artwork / list_comments — GET `/museum/art.asp?id=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/museum/art.asp?id=$ART\" -o /tmp/artwork.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseArtwork } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseArtwork(readFileSync('/tmp/artwork.html','utf8'))));\n\" | jq '{title, artist_screen_name, views, grade, project, comments}'\n```\nFields: `title`, `artist_screen_name`, `views`, `grade`, `project`,\n`comment_entry: {artist_id, artwork_id}|null`, `comments: [{author, text}]`.\n`list_comments` is just the `.comments` array from the same fetch.\n**Comment-item markup (`.comment`/`.comment-author`/`.comment-text`) is\nUNVERIFIED upstream** — both captured accounts had 0 comments, so it\ndegrades to `[]` without throwing but hasn't been confirmed against a real\ncomment. Verify against a live artwork with comments before trusting it.\n\n### get_fans — GET `/members/fanclub/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/fanclub/?artist=$ID\" -o /tmp/fans.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFans } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFans(readFileSync('/tmp/fans.html','utf8'))));\n\" | jq '.'\n```\nFields: `name`, `relationship`. Selector: `.fan-card` (name from the first\n`a.hiddenlink`; relationship from the card's own-text div, e.g. \"Father\" —\nthe card also carries the fan's email, which the MCP deliberately doesn't\nsurface).\n\n### get_feedback — GET `/members/feedback/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/feedback/?artist=$ID\" -o /tmp/feedback.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFeedback } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFeedback(readFileSync('/tmp/feedback.html','utf8'))));\n\" | jq '[.[] | select(.is_read==false)]'   # unread only\n```\nFields: `artwork_id`, `message`, `posted_by`, `is_read` (false while the row\nsays \"has not been marked as read\"), `thumbnail`. Selector: `.comment-row`\n(message `.comment`, attribution `.commenter`, read state\n`.comment-options`).\n\n### get_awards — GET `/artists/awards.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/awards.asp?id=$ID\" -o /tmp/awards.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseAwards } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseAwards(readFileSync('/tmp/awards.html','utf8'))));\n\" | jq '[.[] | select(.earned)]'   # earned only\n```\nFields: `name`, `earned`, `description`, `progress`,\n`period: \"current\"|\"past\"`. Selectors: `.award-card` (current year —\nname/\"Earned\"|\"Not earned\"/criteria/progress) + `.award-card-past`\n(prior-year badge icons, name derived from `artist_<name>[_ghosted].gif`).\n\n### get_profile — GET `/members/profile/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile.html','utf8'))));\n\" | jq '.'\n```\nFields: `first_name`, `last_name`, `email`, `mobile`,\n`opt_ins: {news, artist_activity, promos}`. Selector: `#TheForm`\n(`input[name=...]`/`select[name=...]`).\n\n## Downloads (public CDN, no auth)\n\n```sh\ncurl -s -o \"$ART.jpg\" \"https://images.artsonia.com/art/full/$ART.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$ART.jpg\" | grep -i last-modified\n```\nNo teacher-entered \"created\" date exists anywhere in the HTML; the CDN\nimage's `Last-Modified` header is the real per-artwork date (verified\nspanning 2022→2026 on one portfolio). Resolutions: `small` (~11 KB)\n`medium` (~34 KB) `large` (~58 KB) `xlarge` (~102 KB) `full`/`original`\n(~665 KB). To bulk-download a portfolio: loop `get_portfolio`'s\n`artwork_id`s through the CDN curl above. There's no starred/favorited\ncontrol on the artwork page for the parent role — don't look for one.\n\n## Writes (POST, form-urlencoded, session-cookie auth, no CSRF)\n\nEvery write below: **a 3xx from curl is not proof it persisted** — Artsonia\n302s even on a silently-dropped payload. Re-GET the affected page (recipes\nabove) and diff before trusting it.\n\n### post_comment — POST `/museum/enter.asp?artist=$ID&art=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/comment-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"Comment=Nice work!\" \\\n  \"https://www.artsonia.com/museum/enter.asp?artist=$ID&art=$ART\"\n```\nBody: `Comment=<text>` only. **Not verifiable from the parent side** — the\nMCP's live test got a 302 to a content page (not a form bounce) but the\ncomment never appeared on the public artwork page or the parent comments\nview; most likely held for teacher moderation. Treat a 302 here as\n\"submitted/pending,\" never \"posted.\"\n\n### invite_fan — POST `/members/fanclub/add.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/invite-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"MemberType=fan\" \\\n  --data-urlencode \"RelationshipID=<relationship-id-from-the-live-Add-Fans-form>\" \\\n  --data-urlencode \"FirstName=Test\" \\\n  --data-urlencode \"LastName=Fan\" \\\n  --data-urlencode \"EmailAddress=test@example.com\" \\\n  --data-urlencode \"ArtistID=$ID\" \\\n  \"https://www.artsonia.com/members/fanclub/add.asp?artist=$ID\"\n```\nOnly test with a real address you're authorized to invite (`@example.com`\nfor dry testing — it sends a real invite email). `MemberType=fan` is\nassumed correct (live-verified by the MCP build); `RelationshipID` is the\n`<select>` value from `/members/fanclub/add.asp?artist=$ID`'s live form —\nread that page first if you don't already know the code. Add\n`--data-urlencode \"IsParent=on\"` if the fan is also a parent/guardian.\nVerify by re-`GET`ting the fan club (`get_fans` above) — a successful\ninvite shows up as a pending fan.\n\n### mark_feedback_read — POST `/members/feedback/default.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/mark-read-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"ConfirmAsRead=Mark as Read\" \\\n  \"https://www.artsonia.com/members/feedback/default.asp?artist=$ID\"\n```\nBody: `ConfirmAsRead=Mark as Read` only. Marks **all** of the student's\nfeedback (no per-item control). Verify by re-running `get_feedback` above\nand confirming no row has `is_read: false`.\n\n### set_notifications — read `/members/profile/`, POST `/members/profile/default.asp`\n\nThis is a read-modify-write of the whole profile form — re-send every\ncurrent field, flip only the opt-in(s), never send a password:\n\n```sh\n# 1. Read current form values (also needed for every other field below)\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\n\n# 2. Inspect it to see the *current* value of every input/select in #TheForm\n#    (FirstName, LastName, EmailAddress, MobileNumber, MobileCountryCode,\n#    Action, EmailAddressPrev, DidChangePassword, OptInNews,\n#    OptInArtistActivity, OptInPromos, ...). Checkboxes submit their real\n#    `value` attribute (Artsonia uses \"Y\", never \"on\") when checked, and are\n#    OMITTED entirely when unchecked — exactly like a browser form.\ngrep -oP '<input[^>]*name=\"[^\"]+\"[^>]*>' /tmp/profile.html\n\n# 3. Re-POST every field verbatim EXCEPT: blank OldPassword/NewPassword/\n#    NewPassword2, keep DidChangePassword=N, and flip only the opt-in(s)\n#    you're changing (include `Field=Y` to turn ON, omit `Field` entirely\n#    to turn OFF).\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/notif-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"FirstName=<current>\" \\\n  --data-urlencode \"LastName=<current>\" \\\n  --data-urlencode \"EmailAddress=<current>\" \\\n  --data-urlencode \"EmailAddressPrev=<current>\" \\\n  --data-urlencode \"MobileCountryCode=<current>\" \\\n  --data-urlencode \"MobileNumber=<current>\" \\\n  --data-urlencode \"Action=<current>\" \\\n  --data-urlencode \"DidChangePassword=N\" \\\n  --data-urlencode \"OldPassword=\" \\\n  --data-urlencode \"NewPassword=\" \\\n  --data-urlencode \"NewPassword2=\" \\\n  --data-urlencode \"OptInArtistActivity=Y\" \\\n  \"https://www.artsonia.com/members/profile/default.asp\"\n  # (omit OptInNews / OptInPromos entirely here to turn them off)\n\n# 4. Verify: re-fetch the profile and confirm the checkbox state changed\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile-after.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile-after.html','utf8')).opt_ins));\n\"\n```\nField names: `OptInNews`, `OptInArtistActivity` (default checked),\n`OptInPromos`. This form also carries password-change fields\n(`OldPassword`/`NewPassword`/`NewPassword2`) — always send them blank.\n\nFile v1.2.2:skill-card.md\n\n## Description:\n\nGuides agents in accessing Artsonia student-art account pages through authenticated curl requests and extracting portfolio, activity, comment, and feedback data.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and authorized account holders use this skill to retrieve Artsonia student artwork and account information through shell commands without running the MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Student artwork and account pages can contain sensitive information.\n\nMitigation: Access only accounts and student data you are authorized to use, and limit sharing of retrieved content.\n\nRisk: A documented image URL may retrieve private artwork without authentication.\n\nMitigation: Avoid the unauthenticated private-image download recipe.\n\nRisk: Fan invitations and account-changing requests can alter account state.\n\nMitigation: Get explicit confirmation before sending these requests and verify any intended changes afterward.\n\nRisk: Saved session cookies and downloaded HTML can expose account data.\n\nMitigation: Restrict file permissions and delete local copies when finished.\n\n## Reference(s):\n\n- [Artsonia endpoint recipes](references/endpoints.md)\n- [Artsonia](https://www.artsonia.com)\n- [Artsonia API skill release](https://clawhub.ai/chrischall/skills/artsonia-api)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with shell examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Commands may save authenticated HTML and structured account data locally.]\n\n## Skill Version(s):\n\n1.2.2 (source: ClawHub release)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.1: 4 files, 8837 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2122b), SKILL.md (6415b), _meta.json (131b)\n\nFile v1.2.1:SKILL.md\n\n---\nname: artsonia-api\ndescription: >-\n  Access Artsonia (artsonia.com) student-art portfolios, comments, fans,\n  teacher feedback, and downloads from a shell with curl instead of running\n  the artsonia-mcp server — log in with a username/password form POST to get\n  a session cookie, then curl the server-rendered member pages. Use when you\n  want Artsonia data without the MCP, in a script, or on a machine where the\n  MCP isn't installed.\n---\n\n# Artsonia via curl (no MCP)\n\nArtsonia is a classic server-rendered `.asp` site — no public API, no JSON\nstore, no bot wall. All the data (students, portfolios, comments, fans,\nfeedback, awards, profile) lives in server-rendered HTML, and auth is a real\n**username + password form POST** that hands back an HttpOnly session\ncookie. No browser, no Transporter extension, no `fpx` bridge is needed —\n`curl` with a cookie jar reaches everything a signed-in parent account can.\n(The MCP keeps `@fetchproxy/server` as an optional fallback transport for\nsome future walled endpoint, but nothing documented here needs it.)\n\nThis is the same data the `artsonia_*` MCP tools return, reached with `curl`\ninstead of a running server.\n\n## One-time setup\n\n```sh\nexport ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar\n```\n\n## Log in (get the session cookie)\n\nRead the password from stdin (`Password@-`) instead of putting it on the\ncommand line — `--data-urlencode name=value` puts `value` in curl's argv,\nwhich any local user can read via `ps`/`/proc` while the process runs. This\nmirrors how the MCP itself sends the password: only in the POST body, never\non a command line.\n\n```sh\nprintf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp\n```\n\nA successful login redirects away from `login.asp` (final URL ends in\n`.../members/`) and leaves cookies in `$JAR`. If `url_effective` still ends\nin `login.asp`, or `/tmp/artsonia-login.html` matches `You need to log\nin|Parent \\(or Fan\\) Login`, the credentials are wrong — Artsonia has no\nCSRF/`__VIEWSTATE` token, so a bad login is the only reason this fails.\nMagic-link-only accounts can't use this (there's no password to POST).\n\nRe-run this exact command any time a later request looks logged-out (see\n\"Session expiry\" below) — `curl -c` overwrites the jar with a fresh session.\n\n## Core call pattern\n\nEvery read is a `GET` through the jar; every write is a form-urlencoded\n`POST` through the jar. Fetch to a file, then extract:\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\n```\n\nFor the numeric ids every other endpoint needs (`artist_id`, `artwork_id`),\na plain regex on the `href` is enough and needs no extra tooling:\n\n```sh\ngrep -oP '(?<=portfolio\\.asp\\?id=)\\d+' /tmp/dash.html | sort -u\n```\n\nFor the full structured fields (name, school, stats, notifications, awards,\n…) don't re-derive selectors by hand — this repo already ships a\nlive-verified `node-html-parser` scraper (`src/parse.ts` → built\n`dist/parse.js`) with one function per page. Build it once, then import it\nin a one-liner and pipe the JSON to `jq`:\n\n```sh\ncd ~/git/artsonia-mcp && npm install && npm run build   # once, if dist/ is missing\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$(pwd)/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html', 'utf8'))));\n\" | jq '.'\n```\n\n`references/endpoints.md` has the ready-to-run curl command, `dist/parse.js`\nimport, and `jq` projection for every read and write tool, keyed to the same\npaths and selectors the MCP uses.\n\n## The one rule: resolve artist_id (and artwork_id) first\n\nEvery per-student/per-artwork endpoint needs a numeric id you can only get\nfrom a parent call — never guess one:\n\n1. `GET /members/` → `.artist-card` → `artist_id` (from each card's\n   `portfolio.asp?id=` link).\n2. `GET /artists/portfolio.asp?id=<artist_id>` → `.grid-item` that contains\n   an `art.asp` link → `artwork_id`.\n\n## Session expiry\n\nArtsonia expires a session by **redirecting/rendering back to the login\npage**, not a 401. After any GET/POST, check the response body:\n\n```sh\ngrep -qE 'login\\.asp|You need to log in|Parent \\(or Fan\\) Login' /tmp/out.html \\\n  && echo \"EXPIRED — re-run the login step\"\n```\n\n## Writes — real caveats from the MCP build\n\n- **A 3xx is not proof a write persisted.** Artsonia's form handlers 302\n  even on payloads they silently drop. Re-`GET` the page you just changed\n  and confirm the field actually flipped before trusting a write.\n- **Checkboxes submit `value=Y`, never `on`.** Sending `Field=on` is\n  silently ignored by the server (still 302s, saves nothing).\n- **The profile form is read-modify-write.** `/members/profile/`\n  (`#TheForm`) bundles name/email/password/opt-ins in one form — re-POST\n  every current field verbatim (blank the password fields, keep\n  `DidChangePassword=N`), flipping only the one you're changing.\n- **Never POST a password.** Leave `OldPassword`/`NewPassword`/\n  `NewPassword2` blank/omitted.\n\nSee `references/endpoints.md` for the exact body of every write, and how to\nre-read and verify each one.\n\n## Artwork images (no auth needed)\n\nFull-resolution images live on a public CDN — no cookie required, even for\nprivate pieces:\n\n```sh\ncurl -s -o \"$artwork_id.jpg\" \"https://images.artsonia.com/art/full/$artwork_id.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$artwork_id.jpg\" | grep -i last-modified   # per-artwork upload date\n```\n\nResolutions (small→large): `small` `medium` `large` `xlarge` `full`.\n\n## Notes\n\n- Reads/writes both go through the member site (`www.artsonia.com`);\n  nothing here needs the `fetchproxy`/browser-bridge path the MCP keeps as\n  an optional fallback.\n- Comment-item markup on `/museum/art.asp` is unverified upstream (both\n  captured accounts had 0 comments) — `references/endpoints.md` flags it.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.1:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"artsonia-api\",\n  \"version\": \"1.2.1\",\n  \"publishedAt\": 1790351707250\n}\n\nFile v1.2.1:references/endpoints.md\n\n# Artsonia endpoints — curl + parser recipes\n\nAll paths are relative to `https://www.artsonia.com`. `$JAR` is the cookie\njar from `SKILL.md`'s login step. All parsing functions below are the\nMCP's own live-verified `src/parse.ts` (built to `dist/parse.js`) — build\nonce with `npm install && npm run build` in the repo, then reuse the\n`node --input-type=module` one-liner shown per endpoint. Swap in\n`grep -oP '(?<=KEY=)\\d+'` on the raw HTML instead if you only need an id and\ndon't want to build the repo.\n\nShorthand used below: `$REPO` = the absolute path to this clone of\n`artsonia-mcp` (e.g. `~/git/artsonia-mcp`); `$ID` = a resolved `artist_id`;\n`$ART` = a resolved `artwork_id`.\n\n```sh\n# generic shape of every parse call — swap the function name + input path\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents as fn } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(fn(readFileSync('/tmp/page.html', 'utf8'))));\n\" | jq '.'\n```\n\n## Reads\n\n### list_students — GET `/members/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artist_id)\\t\\(.name)\\t\\(.school)\\tgrade \\(.grade)\\t\\(.artwork_count) artworks\"'\n```\nFields: `artist_id`, `name`, `school`, `grade`, `artwork_count`,\n`fan_count`, `comment_count`, `feedback_count`, `award_count`,\n`portfolio_path`. Selector: `.artist-card`.\n\n### get_activity — GET `/members/` (notifications)\n\nSame fetch as above, different parser:\n\n```sh\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseNotifications } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseNotifications(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq '.'\n```\nShape: `{ count, items: [{ title, body, href }] }`. Selectors:\n`.textSubhead` (text `Notifications (N)`) + `div.notice`.\n\n### get_portfolio — GET `/artists/portfolio.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/portfolio.asp?id=$ID\" -o /tmp/portfolio.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parsePortfolio } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parsePortfolio(readFileSync('/tmp/portfolio.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artwork_id)\\t\\(.is_private)\\t\\(.thumbnail)\"'\n```\nFields: `artwork_id`, `is_private`, `thumbnail` (derived\n`images.artsonia.com/art/small/<id>.jpg`). Selector: `.grid-item` that\ncontains an `a[href*=\"art.asp\"]` (the first `.grid-item` is a non-artwork\nsection header — no art link — and is dropped by that filter).\n\n### get_artwork / list_comments — GET `/museum/art.asp?id=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/museum/art.asp?id=$ART\" -o /tmp/artwork.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseArtwork } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseArtwork(readFileSync('/tmp/artwork.html','utf8'))));\n\" | jq '{title, artist_screen_name, views, grade, project, comments}'\n```\nFields: `title`, `artist_screen_name`, `views`, `grade`, `project`,\n`comment_entry: {artist_id, artwork_id}|null`, `comments: [{author, text}]`.\n`list_comments` is just the `.comments` array from the same fetch.\n**Comment-item markup (`.comment`/`.comment-author`/`.comment-text`) is\nUNVERIFIED upstream** — both captured accounts had 0 comments, so it\ndegrades to `[]` without throwing but hasn't been confirmed against a real\ncomment. Verify against a live artwork with comments before trusting it.\n\n### get_fans — GET `/members/fanclub/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/fanclub/?artist=$ID\" -o /tmp/fans.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFans } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFans(readFileSync('/tmp/fans.html','utf8'))));\n\" | jq '.'\n```\nFields: `name`, `relationship`. Selector: `.fan-card` (name from the first\n`a.hiddenlink`; relationship from the card's own-text div, e.g. \"Father\" —\nthe card also carries the fan's email, which the MCP deliberately doesn't\nsurface).\n\n### get_feedback — GET `/members/feedback/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/feedback/?artist=$ID\" -o /tmp/feedback.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFeedback } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFeedback(readFileSync('/tmp/feedback.html','utf8'))));\n\" | jq '[.[] | select(.is_read==false)]'   # unread only\n```\nFields: `artwork_id`, `message`, `posted_by`, `is_read` (false while the row\nsays \"has not been marked as read\"), `thumbnail`. Selector: `.comment-row`\n(message `.comment`, attribution `.commenter`, read state\n`.comment-options`).\n\n### get_awards — GET `/artists/awards.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/awards.asp?id=$ID\" -o /tmp/awards.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseAwards } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseAwards(readFileSync('/tmp/awards.html','utf8'))));\n\" | jq '[.[] | select(.earned)]'   # earned only\n```\nFields: `name`, `earned`, `description`, `progress`,\n`period: \"current\"|\"past\"`. Selectors: `.award-card` (current year —\nname/\"Earned\"|\"Not earned\"/criteria/progress) + `.award-card-past`\n(prior-year badge icons, name derived from `artist_<name>[_ghosted].gif`).\n\n### get_profile — GET `/members/profile/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile.html','utf8'))));\n\" | jq '.'\n```\nFields: `first_name`, `last_name`, `email`, `mobile`,\n`opt_ins: {news, artist_activity, promos}`. Selector: `#TheForm`\n(`input[name=...]`/`select[name=...]`).\n\n## Downloads (public CDN, no auth)\n\n```sh\ncurl -s -o \"$ART.jpg\" \"https://images.artsonia.com/art/full/$ART.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$ART.jpg\" | grep -i last-modified\n```\nNo teacher-entered \"created\" date exists anywhere in the HTML; the CDN\nimage's `Last-Modified` header is the real per-artwork date (verified\nspanning 2022→2026 on one portfolio). Resolutions: `small` (~11 KB)\n`medium` (~34 KB) `large` (~58 KB) `xlarge` (~102 KB) `full`/`original`\n(~665 KB). To bulk-download a portfolio: loop `get_portfolio`'s\n`artwork_id`s through the CDN curl above. There's no starred/favorited\ncontrol on the artwork page for the parent role — don't look for one.\n\n## Writes (POST, form-urlencoded, session-cookie auth, no CSRF)\n\nEvery write below: **a 3xx from curl is not proof it persisted** — Artsonia\n302s even on a silently-dropped payload. Re-GET the affected page (recipes\nabove) and diff before trusting it.\n\n### post_comment — POST `/museum/enter.asp?artist=$ID&art=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/comment-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"Comment=Nice work!\" \\\n  \"https://www.artsonia.com/museum/enter.asp?artist=$ID&art=$ART\"\n```\nBody: `Comment=<text>` only. **Not verifiable from the parent side** — the\nMCP's live test got a 302 to a content page (not a form bounce) but the\ncomment never appeared on the public artwork page or the parent comments\nview; most likely held for teacher moderation. Treat a 302 here as\n\"submitted/pending,\" never \"posted.\"\n\n### invite_fan — POST `/members/fanclub/add.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/invite-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"MemberType=fan\" \\\n  --data-urlencode \"RelationshipID=<relationship-id-from-the-live-Add-Fans-form>\" \\\n  --data-urlencode \"FirstName=Test\" \\\n  --data-urlencode \"LastName=Fan\" \\\n  --data-urlencode \"EmailAddress=test@example.com\" \\\n  --data-urlencode \"ArtistID=$ID\" \\\n  \"https://www.artsonia.com/members/fanclub/add.asp?artist=$ID\"\n```\nOnly test with a real address you're authorized to invite (`@example.com`\nfor dry testing — it sends a real invite email). `MemberType=fan` is\nassumed correct (live-verified by the MCP build); `RelationshipID` is the\n`<select>` value from `/members/fanclub/add.asp?artist=$ID`'s live form —\nread that page first if you don't already know the code. Add\n`--data-urlencode \"IsParent=on\"` if the fan is also a parent/guardian.\nVerify by re-`GET`ting the fan club (`get_fans` above) — a successful\ninvite shows up as a pending fan.\n\n### mark_feedback_read — POST `/members/feedback/default.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/mark-read-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"ConfirmAsRead=Mark as Read\" \\\n  \"https://www.artsonia.com/members/feedback/default.asp?artist=$ID\"\n```\nBody: `ConfirmAsRead=Mark as Read` only. Marks **all** of the student's\nfeedback (no per-item control). Verify by re-running `get_feedback` above\nand confirming no row has `is_read: false`.\n\n### set_notifications — read `/members/profile/`, POST `/members/profile/default.asp`\n\nThis is a read-modify-write of the whole profile form — re-send every\ncurrent field, flip only the opt-in(s), never send a password:\n\n```sh\n# 1. Read current form values (also needed for every other field below)\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\n\n# 2. Inspect it to see the *current* value of every input/select in #TheForm\n#    (FirstName, LastName, EmailAddress, MobileNumber, MobileCountryCode,\n#    Action, EmailAddressPrev, DidChangePassword, OptInNews,\n#    OptInArtistActivity, OptInPromos, ...). Checkboxes submit their real\n#    `value` attribute (Artsonia uses \"Y\", never \"on\") when checked, and are\n#    OMITTED entirely when unchecked — exactly like a browser form.\ngrep -oP '<input[^>]*name=\"[^\"]+\"[^>]*>' /tmp/profile.html\n\n# 3. Re-POST every field verbatim EXCEPT: blank OldPassword/NewPassword/\n#    NewPassword2, keep DidChangePassword=N, and flip only the opt-in(s)\n#    you're changing (include `Field=Y` to turn ON, omit `Field` entirely\n#    to turn OFF).\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/notif-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"FirstName=<current>\" \\\n  --data-urlencode \"LastName=<current>\" \\\n  --data-urlencode \"EmailAddress=<current>\" \\\n  --data-urlencode \"EmailAddressPrev=<current>\" \\\n  --data-urlencode \"MobileCountryCode=<current>\" \\\n  --data-urlencode \"MobileNumber=<current>\" \\\n  --data-urlencode \"Action=<current>\" \\\n  --data-urlencode \"DidChangePassword=N\" \\\n  --data-urlencode \"OldPassword=\" \\\n  --data-urlencode \"NewPassword=\" \\\n  --data-urlencode \"NewPassword2=\" \\\n  --data-urlencode \"OptInArtistActivity=Y\" \\\n  \"https://www.artsonia.com/members/profile/default.asp\"\n  # (omit OptInNews / OptInPromos entirely here to turn them off)\n\n# 4. Verify: re-fetch the profile and confirm the checkbox state changed\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile-after.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile-after.html','utf8')).opt_ins));\n\"\n```\nField names: `OptInNews`, `OptInArtistActivity` (default checked),\n`OptInPromos`. This form also carries password-change fields\n(`OldPassword`/`NewPassword`/`NewPassword2`) — always send them blank.\n\nFile v1.2.1:skill-card.md\n\n## Description:\n\nGuides agents in accessing Artsonia student artwork, comments, fans, feedback, and downloads through authenticated shell requests without running an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nParents or authorized account holders and developers use the skill to inspect student artwork and related account information or perform requested account actions through shell commands.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Accessing or redistributing private student artwork and account details without authorization.\n\nMitigation: Use only accounts and student content you are authorized to access; avoid bulk downloads and redistribution of private material.\n\nRisk: Write commands can post comments, send real invitation emails, mark all feedback read, or alter profile notification settings.\n\nMitigation: Require explicit approval for each intended change and recheck the affected page afterward; a redirect alone does not confirm success.\n\nRisk: A stored session cookie can expose account access to other local users.\n\nMitigation: Keep the cookie jar under restrictive file permissions and delete it after use on shared systems.\n\n## Reference(s):\n\n- [Artsonia endpoint recipes](references/endpoints.md)\n- [ClawHub skill listing](https://clawhub.ai/chrischall/skills/artsonia-api)\n- [Artsonia](https://www.artsonia.com)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with shell examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [May guide authorized reads, artwork downloads, and explicit account changes.]\n\n## Skill Version(s):\n\n1.2.1 (source: server-resolved release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.0: 4 files, 9004 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2566b), SKILL.md (6415b), _meta.json (131b)\n\nFile v1.2.0:SKILL.md\n\n---\nname: artsonia-api\ndescription: >-\n  Access Artsonia (artsonia.com) student-art portfolios, comments, fans,\n  teacher feedback, and downloads from a shell with curl instead of running\n  the artsonia-mcp server — log in with a username/password form POST to get\n  a session cookie, then curl the server-rendered member pages. Use when you\n  want Artsonia data without the MCP, in a script, or on a machine where the\n  MCP isn't installed.\n---\n\n# Artsonia via curl (no MCP)\n\nArtsonia is a classic server-rendered `.asp` site — no public API, no JSON\nstore, no bot wall. All the data (students, portfolios, comments, fans,\nfeedback, awards, profile) lives in server-rendered HTML, and auth is a real\n**username + password form POST** that hands back an HttpOnly session\ncookie. No browser, no Transporter extension, no `fpx` bridge is needed —\n`curl` with a cookie jar reaches everything a signed-in parent account can.\n(The MCP keeps `@fetchproxy/server` as an optional fallback transport for\nsome future walled endpoint, but nothing documented here needs it.)\n\nThis is the same data the `artsonia_*` MCP tools return, reached with `curl`\ninstead of a running server.\n\n## One-time setup\n\n```sh\nexport ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar\n```\n\n## Log in (get the session cookie)\n\nRead the password from stdin (`Password@-`) instead of putting it on the\ncommand line — `--data-urlencode name=value` puts `value` in curl's argv,\nwhich any local user can read via `ps`/`/proc` while the process runs. This\nmirrors how the MCP itself sends the password: only in the POST body, never\non a command line.\n\n```sh\nprintf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp\n```\n\nA successful login redirects away from `login.asp` (final URL ends in\n`.../members/`) and leaves cookies in `$JAR`. If `url_effective` still ends\nin `login.asp`, or `/tmp/artsonia-login.html` matches `You need to log\nin|Parent \\(or Fan\\) Login`, the credentials are wrong — Artsonia has no\nCSRF/`__VIEWSTATE` token, so a bad login is the only reason this fails.\nMagic-link-only accounts can't use this (there's no password to POST).\n\nRe-run this exact command any time a later request looks logged-out (see\n\"Session expiry\" below) — `curl -c` overwrites the jar with a fresh session.\n\n## Core call pattern\n\nEvery read is a `GET` through the jar; every write is a form-urlencoded\n`POST` through the jar. Fetch to a file, then extract:\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\n```\n\nFor the numeric ids every other endpoint needs (`artist_id`, `artwork_id`),\na plain regex on the `href` is enough and needs no extra tooling:\n\n```sh\ngrep -oP '(?<=portfolio\\.asp\\?id=)\\d+' /tmp/dash.html | sort -u\n```\n\nFor the full structured fields (name, school, stats, notifications, awards,\n…) don't re-derive selectors by hand — this repo already ships a\nlive-verified `node-html-parser` scraper (`src/parse.ts` → built\n`dist/parse.js`) with one function per page. Build it once, then import it\nin a one-liner and pipe the JSON to `jq`:\n\n```sh\ncd ~/git/artsonia-mcp && npm install && npm run build   # once, if dist/ is missing\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$(pwd)/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html', 'utf8'))));\n\" | jq '.'\n```\n\n`references/endpoints.md` has the ready-to-run curl command, `dist/parse.js`\nimport, and `jq` projection for every read and write tool, keyed to the same\npaths and selectors the MCP uses.\n\n## The one rule: resolve artist_id (and artwork_id) first\n\nEvery per-student/per-artwork endpoint needs a numeric id you can only get\nfrom a parent call — never guess one:\n\n1. `GET /members/` → `.artist-card` → `artist_id` (from each card's\n   `portfolio.asp?id=` link).\n2. `GET /artists/portfolio.asp?id=<artist_id>` → `.grid-item` that contains\n   an `art.asp` link → `artwork_id`.\n\n## Session expiry\n\nArtsonia expires a session by **redirecting/rendering back to the login\npage**, not a 401. After any GET/POST, check the response body:\n\n```sh\ngrep -qE 'login\\.asp|You need to log in|Parent \\(or Fan\\) Login' /tmp/out.html \\\n  && echo \"EXPIRED — re-run the login step\"\n```\n\n## Writes — real caveats from the MCP build\n\n- **A 3xx is not proof a write persisted.** Artsonia's form handlers 302\n  even on payloads they silently drop. Re-`GET` the page you just changed\n  and confirm the field actually flipped before trusting a write.\n- **Checkboxes submit `value=Y`, never `on`.** Sending `Field=on` is\n  silently ignored by the server (still 302s, saves nothing).\n- **The profile form is read-modify-write.** `/members/profile/`\n  (`#TheForm`) bundles name/email/password/opt-ins in one form — re-POST\n  every current field verbatim (blank the password fields, keep\n  `DidChangePassword=N`), flipping only the one you're changing.\n- **Never POST a password.** Leave `OldPassword`/`NewPassword`/\n  `NewPassword2` blank/omitted.\n\nSee `references/endpoints.md` for the exact body of every write, and how to\nre-read and verify each one.\n\n## Artwork images (no auth needed)\n\nFull-resolution images live on a public CDN — no cookie required, even for\nprivate pieces:\n\n```sh\ncurl -s -o \"$artwork_id.jpg\" \"https://images.artsonia.com/art/full/$artwork_id.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$artwork_id.jpg\" | grep -i last-modified   # per-artwork upload date\n```\n\nResolutions (small→large): `small` `medium` `large` `xlarge` `full`.\n\n## Notes\n\n- Reads/writes both go through the member site (`www.artsonia.com`);\n  nothing here needs the `fetchproxy`/browser-bridge path the MCP keeps as\n  an optional fallback.\n- Comment-item markup on `/museum/art.asp` is unverified upstream (both\n  captured accounts had 0 comments) — `references/endpoints.md` flags it.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.0:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"artsonia-api\",\n  \"version\": \"1.2.0\",\n  \"publishedAt\": 1790262630963\n}\n\nFile v1.2.0:references/endpoints.md\n\n# Artsonia endpoints — curl + parser recipes\n\nAll paths are relative to `https://www.artsonia.com`. `$JAR` is the cookie\njar from `SKILL.md`'s login step. All parsing functions below are the\nMCP's own live-verified `src/parse.ts` (built to `dist/parse.js`) — build\nonce with `npm install && npm run build` in the repo, then reuse the\n`node --input-type=module` one-liner shown per endpoint. Swap in\n`grep -oP '(?<=KEY=)\\d+'` on the raw HTML instead if you only need an id and\ndon't want to build the repo.\n\nShorthand used below: `$REPO` = the absolute path to this clone of\n`artsonia-mcp` (e.g. `~/git/artsonia-mcp`); `$ID` = a resolved `artist_id`;\n`$ART` = a resolved `artwork_id`.\n\n```sh\n# generic shape of every parse call — swap the function name + input path\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents as fn } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(fn(readFileSync('/tmp/page.html', 'utf8'))));\n\" | jq '.'\n```\n\n## Reads\n\n### list_students — GET `/members/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artist_id)\\t\\(.name)\\t\\(.school)\\tgrade \\(.grade)\\t\\(.artwork_count) artworks\"'\n```\nFields: `artist_id`, `name`, `school`, `grade`, `artwork_count`,\n`fan_count`, `comment_count`, `feedback_count`, `award_count`,\n`portfolio_path`. Selector: `.artist-card`.\n\n### get_activity — GET `/members/` (notifications)\n\nSame fetch as above, different parser:\n\n```sh\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseNotifications } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseNotifications(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq '.'\n```\nShape: `{ count, items: [{ title, body, href }] }`. Selectors:\n`.textSubhead` (text `Notifications (N)`) + `div.notice`.\n\n### get_portfolio — GET `/artists/portfolio.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/portfolio.asp?id=$ID\" -o /tmp/portfolio.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parsePortfolio } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parsePortfolio(readFileSync('/tmp/portfolio.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artwork_id)\\t\\(.is_private)\\t\\(.thumbnail)\"'\n```\nFields: `artwork_id`, `is_private`, `thumbnail` (derived\n`images.artsonia.com/art/small/<id>.jpg`). Selector: `.grid-item` that\ncontains an `a[href*=\"art.asp\"]` (the first `.grid-item` is a non-artwork\nsection header — no art link — and is dropped by that filter).\n\n### get_artwork / list_comments — GET `/museum/art.asp?id=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/museum/art.asp?id=$ART\" -o /tmp/artwork.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseArtwork } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseArtwork(readFileSync('/tmp/artwork.html','utf8'))));\n\" | jq '{title, artist_screen_name, views, grade, project, comments}'\n```\nFields: `title`, `artist_screen_name`, `views`, `grade`, `project`,\n`comment_entry: {artist_id, artwork_id}|null`, `comments: [{author, text}]`.\n`list_comments` is just the `.comments` array from the same fetch.\n**Comment-item markup (`.comment`/`.comment-author`/`.comment-text`) is\nUNVERIFIED upstream** — both captured accounts had 0 comments, so it\ndegrades to `[]` without throwing but hasn't been confirmed against a real\ncomment. Verify against a live artwork with comments before trusting it.\n\n### get_fans — GET `/members/fanclub/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/fanclub/?artist=$ID\" -o /tmp/fans.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFans } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFans(readFileSync('/tmp/fans.html','utf8'))));\n\" | jq '.'\n```\nFields: `name`, `relationship`. Selector: `.fan-card` (name from the first\n`a.hiddenlink`; relationship from the card's own-text div, e.g. \"Father\" —\nthe card also carries the fan's email, which the MCP deliberately doesn't\nsurface).\n\n### get_feedback — GET `/members/feedback/?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/members/feedback/?artist=$ID\" -o /tmp/feedback.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseFeedback } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseFeedback(readFileSync('/tmp/feedback.html','utf8'))));\n\" | jq '[.[] | select(.is_read==false)]'   # unread only\n```\nFields: `artwork_id`, `message`, `posted_by`, `is_read` (false while the row\nsays \"has not been marked as read\"), `thumbnail`. Selector: `.comment-row`\n(message `.comment`, attribution `.commenter`, read state\n`.comment-options`).\n\n### get_awards — GET `/artists/awards.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/awards.asp?id=$ID\" -o /tmp/awards.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseAwards } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseAwards(readFileSync('/tmp/awards.html','utf8'))));\n\" | jq '[.[] | select(.earned)]'   # earned only\n```\nFields: `name`, `earned`, `description`, `progress`,\n`period: \"current\"|\"past\"`. Selectors: `.award-card` (current year —\nname/\"Earned\"|\"Not earned\"/criteria/progress) + `.award-card-past`\n(prior-year badge icons, name derived from `artist_<name>[_ghosted].gif`).\n\n### get_profile — GET `/members/profile/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile.html','utf8'))));\n\" | jq '.'\n```\nFields: `first_name`, `last_name`, `email`, `mobile`,\n`opt_ins: {news, artist_activity, promos}`. Selector: `#TheForm`\n(`input[name=...]`/`select[name=...]`).\n\n## Downloads (public CDN, no auth)\n\n```sh\ncurl -s -o \"$ART.jpg\" \"https://images.artsonia.com/art/full/$ART.jpg\"\ncurl -sI \"https://images.artsonia.com/art/full/$ART.jpg\" | grep -i last-modified\n```\nNo teacher-entered \"created\" date exists anywhere in the HTML; the CDN\nimage's `Last-Modified` header is the real per-artwork date (verified\nspanning 2022→2026 on one portfolio). Resolutions: `small` (~11 KB)\n`medium` (~34 KB) `large` (~58 KB) `xlarge` (~102 KB) `full`/`original`\n(~665 KB). To bulk-download a portfolio: loop `get_portfolio`'s\n`artwork_id`s through the CDN curl above. There's no starred/favorited\ncontrol on the artwork page for the parent role — don't look for one.\n\n## Writes (POST, form-urlencoded, session-cookie auth, no CSRF)\n\nEvery write below: **a 3xx from curl is not proof it persisted** — Artsonia\n302s even on a silently-dropped payload. Re-GET the affected page (recipes\nabove) and diff before trusting it.\n\n### post_comment — POST `/museum/enter.asp?artist=$ID&art=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/comment-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"Comment=Nice work!\" \\\n  \"https://www.artsonia.com/museum/enter.asp?artist=$ID&art=$ART\"\n```\nBody: `Comment=<text>` only. **Not verifiable from the parent side** — the\nMCP's live test got a 302 to a content page (not a form bounce) but the\ncomment never appeared on the public artwork page or the parent comments\nview; most likely held for teacher moderation. Treat a 302 here as\n\"submitted/pending,\" never \"posted.\"\n\n### invite_fan — POST `/members/fanclub/add.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/invite-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"MemberType=fan\" \\\n  --data-urlencode \"RelationshipID=<relationship-id-from-the-live-Add-Fans-form>\" \\\n  --data-urlencode \"FirstName=Test\" \\\n  --data-urlencode \"LastName=Fan\" \\\n  --data-urlencode \"EmailAddress=test@example.com\" \\\n  --data-urlencode \"ArtistID=$ID\" \\\n  \"https://www.artsonia.com/members/fanclub/add.asp?artist=$ID\"\n```\nOnly test with a real address you're authorized to invite (`@example.com`\nfor dry testing — it sends a real invite email). `MemberType=fan` is\nassumed correct (live-verified by the MCP build); `RelationshipID` is the\n`<select>` value from `/members/fanclub/add.asp?artist=$ID`'s live form —\nread that page first if you don't already know the code. Add\n`--data-urlencode \"IsParent=on\"` if the fan is also a parent/guardian.\nVerify by re-`GET`ting the fan club (`get_fans` above) — a successful\ninvite shows up as a pending fan.\n\n### mark_feedback_read — POST `/members/feedback/default.asp?artist=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/mark-read-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"ConfirmAsRead=Mark as Read\" \\\n  \"https://www.artsonia.com/members/feedback/default.asp?artist=$ID\"\n```\nBody: `ConfirmAsRead=Mark as Read` only. Marks **all** of the student's\nfeedback (no per-item control). Verify by re-running `get_feedback` above\nand confirming no row has `is_read: false`.\n\n### set_notifications — read `/members/profile/`, POST `/members/profile/default.asp`\n\nThis is a read-modify-write of the whole profile form — re-send every\ncurrent field, flip only the opt-in(s), never send a password:\n\n```sh\n# 1. Read current form values (also needed for every other field below)\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile.html\n\n# 2. Inspect it to see the *current* value of every input/select in #TheForm\n#    (FirstName, LastName, EmailAddress, MobileNumber, MobileCountryCode,\n#    Action, EmailAddressPrev, DidChangePassword, OptInNews,\n#    OptInArtistActivity, OptInPromos, ...). Checkboxes submit their real\n#    `value` attribute (Artsonia uses \"Y\", never \"on\") when checked, and are\n#    OMITTED entirely when unchecked — exactly like a browser form.\ngrep -oP '<input[^>]*name=\"[^\"]+\"[^>]*>' /tmp/profile.html\n\n# 3. Re-POST every field verbatim EXCEPT: blank OldPassword/NewPassword/\n#    NewPassword2, keep DidChangePassword=N, and flip only the opt-in(s)\n#    you're changing (include `Field=Y` to turn ON, omit `Field` entirely\n#    to turn OFF).\ncurl -s -b \"$JAR\" -c \"$JAR\" -o /tmp/notif-result.html -w '%{http_code}\\n' \\\n  --data-urlencode \"FirstName=<current>\" \\\n  --data-urlencode \"LastName=<current>\" \\\n  --data-urlencode \"EmailAddress=<current>\" \\\n  --data-urlencode \"EmailAddressPrev=<current>\" \\\n  --data-urlencode \"MobileCountryCode=<current>\" \\\n  --data-urlencode \"MobileNumber=<current>\" \\\n  --data-urlencode \"Action=<current>\" \\\n  --data-urlencode \"DidChangePassword=N\" \\\n  --data-urlencode \"OldPassword=\" \\\n  --data-urlencode \"NewPassword=\" \\\n  --data-urlencode \"NewPassword2=\" \\\n  --data-urlencode \"OptInArtistActivity=Y\" \\\n  \"https://www.artsonia.com/members/profile/default.asp\"\n  # (omit OptInNews / OptInPromos entirely here to turn them off)\n\n# 4. Verify: re-fetch the profile and confirm the checkbox state changed\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/profile/' -o /tmp/profile-after.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseProfile } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseProfile(readFileSync('/tmp/profile-after.html','utf8')).opt_ins));\n\"\n```\nField names: `OptInNews`, `OptInArtistActivity` (default checked),\n`OptInPromos`. This form also carries password-change fields\n(`OldPassword`/`NewPassword`/`NewPassword2`) — always send them blank.\n\nFile v1.2.0:skill-card.md\n\n## Description:\n\nAccess Artsonia student-art portfolios, comments, fans, teacher feedback, artwork downloads, and account pages from a shell using curl and documented endpoint recipes.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrisc\n\nArchive v1.1.4: 4 files, 8956 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2488b), SKILL.md (6415b), _meta.json (131b)\n\nArchive v1.1.3: 4 files, 9064 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2663b), SKILL.md (6415b), _meta.json (131b)\n\nArchive v1.1.2: 4 files, 8893 bytes\n\nFiles: references/endpoints.md (11724b), skill-card.md (2202b), SKILL.md (6415b), _meta.json (131b)","readmeExcerpt":"Skill: artsonia-api Owner: chrischall Summary: Access Artsonia (artsonia.com) student-art portfolios, comments, fans, teacher feedback, and downloads from a shell with curl instead of running the artsonia-mcp server — log in with a username/password form POST to get a session cookie, then curl the server-rendered member pages. Use when you want Artsonia data without the MCP, in a script, or on a machine where the MCP","codeSnippets":[],"executableExamples":[{"language":"sh","snippet":"export ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar"},{"language":"sh","snippet":"printf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp"},{"language":"sh","snippet":"curl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html"},{"language":"sh","snippet":"curl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html"},{"language":"sh","snippet":"grep -oP '(?<=portfolio\\.asp\\?id=)\\d+' /tmp/dash.html | sort -u"},{"language":"sh","snippet":"cd ~/git/artsonia-mcp && npm install && npm run build   # once, if dist/ is missing\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$(pwd)/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html', 'utf8'))));\n\" | jq '.'"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: artsonia-api\ndescription: >-\n  Access Artsonia (artsonia.com) student-art portfolios, comments, fans,\n  teacher feedback, and downloads from a shell with curl instead of running\n  the artsonia-mcp server — log in with a username/password form POST to get\n  a session cookie, then curl the server-rendered member pages. Use when you\n  want Artsonia data without the MCP, in a script, or on a machine where the\n  MCP isn't installed.\n---\n\n# Artsonia via curl (no MCP)\n\nArtsonia is a classic server-rendered `.asp` site — no public API, no JSON\nstore, no bot wall. All the data (students, portfolios, comments, fans,\nfeedback, awards, profile) lives in server-rendered HTML, and auth is a real\n**username + password form POST** that hands back an HttpOnly session\ncookie. No browser, no ContextMint Bridge extension, no `fpx` bridge is needed —\n`curl` with a cookie jar reaches everything a signed-in parent account can.\n(The MCP keeps `@fetchproxy/server` as an optional fallback transport for\nsome future walled endpoint, but nothing documented here needs it.)\n\nThis is the same data the `artsonia_*` MCP tools return, reached with `curl`\ninstead of a running server.\n\n## One-time setup\n\n```sh\nexport ARTSONIA_USERNAME=you@example.com\nexport ARTSONIA_PASSWORD='your-password'   # or: op read \"op://Private/Artsonia/password\"\nJAR=~/.cache/artsonia-cookies.txt\nmkdir -p ~/.cache   # ensure the jar's directory exists on a fresh box\n: > \"$JAR\"   # fresh jar\n```\n\n## Log in (get the session cookie)\n\nRead the password from stdin (`Password@-`) instead of putting it on the\ncommand line — `--data-urlencode name=value` puts `value` in curl's argv,\nwhich any local user can read via `ps`/`/proc` while the process runs. This\nmirrors how the MCP itself sends the password: only in the POST body, never\non a command line.\n\n```sh\nprintf '%s' \"$ARTSONIA_PASSWORD\" | curl -s -c \"$JAR\" -b \"$JAR\" -L \\\n  --data-urlencode \"Username=$ARTSONIA_USERNAME\" \\\n  --data-urlencode \"Password@-\" \\\n  --data-urlencode \"TargetUrl=/members/\" \\\n  --data-urlencode \"Action=login\" \\\n  -o /tmp/artsonia-login.html -w '%{http_code} %{url_effective}\\n' \\\n  https://www.artsonia.com/members/login.asp\n```\n\nA successful login redirects away from `login.asp` (final URL ends in\n`.../members/`) and leaves cookies in `$JAR`. If `url_effective` still ends\nin `login.asp`, or `/tmp/artsonia-login.html` matches `You need to log\nin|Parent \\(or Fan\\) Login`, the credentials are wrong — Artsonia has no\nCSRF/`__VIEWSTATE` token, so a bad login is the only reason this fails.\nMagic-link-only accounts can't use this (there's no password to POST).\n\nRe-run this exact command any time a later request looks logged-out (see\n\"Session expiry\" below) — `curl -c` overwrites the jar with a fresh session.\n\n## Core call pattern\n\nEvery read is a `GET` through the jar; every write is a form-urlencoded\n`POST` through the jar. Fetch to a file, then extract:\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.h"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"artsonia-api\",\n  \"version\": \"1.3.0\",\n  \"publishedAt\": 1791588227153\n}"},{"path":"references/endpoints.md","content":"# Artsonia endpoints — curl + parser recipes\n\nAll paths are relative to `https://www.artsonia.com`. `$JAR` is the cookie\njar from `SKILL.md`'s login step. All parsing functions below are the\nMCP's own live-verified `src/parse.ts` (built to `dist/parse.js`) — build\nonce with `npm install && npm run build` in the repo, then reuse the\n`node --input-type=module` one-liner shown per endpoint. Swap in\n`grep -oP '(?<=KEY=)\\d+'` on the raw HTML instead if you only need an id and\ndon't want to build the repo.\n\nShorthand used below: `$REPO` = the absolute path to this clone of\n`artsonia-mcp` (e.g. `~/git/artsonia-mcp`); `$ID` = a resolved `artist_id`;\n`$ART` = a resolved `artwork_id`.\n\n```sh\n# generic shape of every parse call — swap the function name + input path\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents as fn } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(fn(readFileSync('/tmp/page.html', 'utf8'))));\n\" | jq '.'\n```\n\n## Reads\n\n### list_students — GET `/members/`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" 'https://www.artsonia.com/members/' -o /tmp/dash.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseStudents } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseStudents(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artist_id)\\t\\(.name)\\t\\(.school)\\tgrade \\(.grade)\\t\\(.artwork_count) artworks\"'\n```\nFields: `artist_id`, `name`, `school`, `grade`, `artwork_count`,\n`fan_count`, `comment_count`, `feedback_count`, `award_count`,\n`portfolio_path`. Selector: `.artist-card`.\n\n### get_activity — GET `/members/` (notifications)\n\nSame fetch as above, different parser:\n\n```sh\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parseNotifications } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parseNotifications(readFileSync('/tmp/dash.html','utf8'))));\n\" | jq '.'\n```\nShape: `{ count, items: [{ title, body, href }] }`. Selectors:\n`.textSubhead` (text `Notifications (N)`) + `div.notice`.\n\n### get_portfolio — GET `/artists/portfolio.asp?id=$ID`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/artists/portfolio.asp?id=$ID\" -o /tmp/portfolio.html\nnode --input-type=module -e \"\nimport { readFileSync } from 'node:fs';\nimport { parsePortfolio } from '$REPO/dist/parse.js';\nconsole.log(JSON.stringify(parsePortfolio(readFileSync('/tmp/portfolio.html','utf8'))));\n\" | jq -r '.[] | \"\\(.artwork_id)\\t\\(.is_private)\\t\\(.thumbnail)\"'\n```\nFields: `artwork_id`, `is_private`, `thumbnail` (derived\n`images.artsonia.com/art/small/<id>.jpg`). Selector: `.grid-item` that\ncontains an `a[href*=\"art.asp\"]` (the first `.grid-item` is a non-artwork\nsection header — no art link — and is dropped by that filter).\n\n### get_artwork / list_comments — GET `/museum/art.asp?id=$ART`\n\n```sh\ncurl -s -b \"$JAR\" -c \"$JAR\" \"https://www.artsonia.com/museum/art.asp?id=$ART\" -o /tmp/artwork.html\nnode --input-type=module -e \"\nimport { readFileSync } from"},{"path":"skill-card.md","content":"## Description:\n\nGuides agents in accessing Artsonia student portfolios, comments, fans, feedback, and artwork through authenticated curl requests without running the Artsonia MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and authorized Artsonia account holders use this skill to retrieve student artwork and account information or manage comments, fan invitations, feedback, and notification settings with curl.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Private student artwork may be accessible through direct image links and bulk downloads.\n\nMitigation: Access only accounts and artwork you are authorized to use; avoid bulk or private downloads without explicit permission.\n\nRisk: Form submissions can change live account state or send invitation emails.\n\nMitigation: Review each POST before sending it, use only authorized recipients, and verify changes against the affected page.\n\nRisk: Login credentials, session cookies, and downloaded pages can expose account or student information.\n\nMitigation: Use a trusted machine, protect the cookie jar and temporary files, and clear them when finished.\n\n## Reference(s):\n\n- [Artsonia endpoint recipes](references/endpoints.md)\n- [ClawHub skill listing](https://clawhub.ai/chrischall/skills/artsonia-api)\n- [Artsonia](https://www.artsonia.com)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with curl and shell command examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Authenticated reads and live form submissions; artwork download commands can create image files.]\n\n## Skill Version(s):\n\n1.3.0 (source: ClawHub release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment."}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":null,"editorialQuality":{"score":100,"threshold":65,"status":"thin","wordCount":1377,"uniquenessScore":43,"reasons":["uniqueness-below-45"]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-10T08:15:15.075Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-10T08:15:15.075Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-10T10:43:33.219Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}