From 1629729c630c453f3f6a29345b84af775097282a Mon Sep 17 00:00:00 2001 From: Dominic D'Apice Date: Sun, 6 Sep 2026 21:30:41 -0400 Subject: [PATCH 1/3] Add Cosmic Daily APOD workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/cosmic-daily.yml | 90 +++++++ .gitignore | 13 + Gemfile | 11 + Gemfile.lock | 246 ++++++++++++++++++ assets/js/main.js | 53 ---- gists/index.html | 33 --- notes/index.html | 6 +- tools/cosmic-daily/.env.example | 1 + tools/cosmic-daily/README.md | 35 +++ tools/cosmic-daily/cosmic_daily/__init__.py | 4 + tools/cosmic-daily/cosmic_daily/__main__.py | 5 + .../cosmic_daily/article_generator.py | 93 +++++++ tools/cosmic-daily/cosmic_daily/cli.py | 190 ++++++++++++++ .../cosmic_daily/image_processor.py | 68 +++++ .../cosmic-daily/cosmic_daily/nasa_client.py | 102 ++++++++ tools/cosmic-daily/cosmic_daily/repository.py | 87 +++++++ .../cosmic_daily/rights_policy.py | 31 +++ tools/cosmic-daily/pyproject.toml | 22 ++ .../tests/test_article_generator.py | 27 ++ .../tests/test_image_processor.py | 28 ++ tools/cosmic-daily/tests/test_nasa_client.py | 57 ++++ tools/cosmic-daily/tests/test_repository.py | 37 +++ .../cosmic-daily/tests/test_rights_policy.py | 21 ++ 23 files changed, 1171 insertions(+), 89 deletions(-) create mode 100644 .github/workflows/cosmic-daily.yml create mode 100644 .gitignore create mode 100644 Gemfile create mode 100644 Gemfile.lock delete mode 100644 gists/index.html create mode 100644 tools/cosmic-daily/.env.example create mode 100644 tools/cosmic-daily/README.md create mode 100644 tools/cosmic-daily/cosmic_daily/__init__.py create mode 100644 tools/cosmic-daily/cosmic_daily/__main__.py create mode 100644 tools/cosmic-daily/cosmic_daily/article_generator.py create mode 100644 tools/cosmic-daily/cosmic_daily/cli.py create mode 100644 tools/cosmic-daily/cosmic_daily/image_processor.py create mode 100644 tools/cosmic-daily/cosmic_daily/nasa_client.py create mode 100644 tools/cosmic-daily/cosmic_daily/repository.py create mode 100644 tools/cosmic-daily/cosmic_daily/rights_policy.py create mode 100644 tools/cosmic-daily/pyproject.toml create mode 100644 tools/cosmic-daily/tests/test_article_generator.py create mode 100644 tools/cosmic-daily/tests/test_image_processor.py create mode 100644 tools/cosmic-daily/tests/test_nasa_client.py create mode 100644 tools/cosmic-daily/tests/test_repository.py create mode 100644 tools/cosmic-daily/tests/test_rights_policy.py diff --git a/.github/workflows/cosmic-daily.yml b/.github/workflows/cosmic-daily.yml new file mode 100644 index 0000000..9a15feb --- /dev/null +++ b/.github/workflows/cosmic-daily.yml @@ -0,0 +1,90 @@ +name: Cosmic Daily + +on: + workflow_dispatch: + inputs: + date: + description: APOD date to process (YYYY-MM-DD). Leave blank for today. + required: false + default: "" + publish: + description: Create a PR for the generated post instead of previewing only. + required: true + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + +jobs: + cosmic-daily: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Cosmic Daily + working-directory: tools/cosmic-daily + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: Resolve target date + id: target + shell: bash + run: | + target="${{ inputs.date }}" + if [ -z "$target" ]; then + target="$(date -u +%F)" + fi + echo "value=$target" >> "$GITHUB_OUTPUT" + + - name: Preview or generate APOD content + id: apod + if: always() + working-directory: tools/cosmic-daily + env: + NASA_API_KEY: ${{ secrets.NASA_API_KEY }} + shell: bash + run: | + set -euo pipefail + if [ "${{ inputs.publish }}" = "true" ]; then + python -m cosmic_daily generate --date "${{ steps.target.outputs.value }}" | tee /tmp/cosmic-daily.log + else + python -m cosmic_daily preview --date "${{ steps.target.outputs.value }}" | tee /tmp/cosmic-daily.log + fi + + if grep -q "Generated post:" /tmp/cosmic-daily.log; then + post_path="$(grep 'Generated post:' /tmp/cosmic-daily.log | sed 's#.*Generated post: ##')" + echo "post_path=$post_path" >> "$GITHUB_OUTPUT" + fi + + - name: Validate generated post + if: ${{ inputs.publish == true && steps.apod.outputs.post_path != '' }} + working-directory: tools/cosmic-daily + shell: bash + run: | + set -euo pipefail + python -m cosmic_daily check --post-path "${{ steps.apod.outputs.post_path }}" + + - name: Create pull request + if: ${{ inputs.publish == true && steps.apod.outputs.post_path != '' }} + uses: peter-evans/create-pull-request@v6 + with: + branch: "cosmic-daily/${{ steps.target.outputs.value }}" + title: "Cosmic Daily: ${{ steps.target.outputs.value }}" + body: | + Automated APOD article for ${{ steps.target.outputs.value }}. + + - Generated from NASA APOD data + - Validated with the local checker + - Preview-only runs remain available through workflow dispatch + commit-message: "Add Cosmic Daily APOD post for ${{ steps.target.outputs.value }}" + delete-branch: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab2e1c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ +.bundle/ +vendor/bundle/ +_site/ +.sass-cache/ +.gitkeep diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..ed60fb3 --- /dev/null +++ b/Gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# gem "rails" + +gem "jekyll", "~> 4.4" +gem "jekyll-feed", "~> 0.17" +gem "jekyll-sitemap", "~> 1.4" +gem "jekyll-seo-tag", "~> 2.8" +gem "webrick", "~> 1.9" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..127e7de --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,246 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + base64 (0.3.0) + bigdecimal (4.1.2) + colorator (1.1.0) + concurrent-ruby (1.3.8) + csv (3.3.6) + em-websocket (0.5.3) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0) + eventmachine (1.2.7) + ffi (1.17.4) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86-linux-gnu) + ffi (1.17.4-x86-linux-musl) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + forwardable-extended (2.6.0) + google-protobuf (4.36.1) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.1-aarch64-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.1-aarch64-linux-musl) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.1-arm64-darwin) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.1-x86-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.1-x86-linux-musl) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.1-x86_64-darwin) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.1-x86_64-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.1-x86_64-linux-musl) + bigdecimal + rake (~> 13.3) + http_parser.rb (0.8.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + jekyll (4.4.1) + addressable (~> 2.4) + base64 (~> 0.2) + colorator (~> 1.0) + csv (~> 3.0) + em-websocket (~> 0.5) + i18n (~> 1.0) + jekyll-sass-converter (>= 2.0, < 4.0) + jekyll-watch (~> 2.0) + json (~> 2.6) + kramdown (~> 2.3, >= 2.3.1) + kramdown-parser-gfm (~> 1.0) + liquid (~> 4.0) + mercenary (~> 0.3, >= 0.3.6) + pathutil (~> 0.9) + rouge (>= 3.0, < 5.0) + safe_yaml (~> 1.0) + terminal-table (>= 1.8, < 4.0) + webrick (~> 1.7) + jekyll-feed (0.17.0) + jekyll (>= 3.7, < 5.0) + jekyll-sass-converter (3.1.0) + sass-embedded (~> 1.75) + jekyll-seo-tag (2.9.0) + jekyll (>= 3.8, < 5.0) + jekyll-sitemap (1.4.0) + jekyll (>= 3.7, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + json (2.21.2) + kramdown (2.5.2) + rexml (>= 3.4.4) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + liquid (4.0.4) + listen (3.10.0) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + mercenary (0.4.0) + pathutil (0.16.2) + forwardable-extended (~> 2.6) + public_suffix (7.0.5) + rake (13.4.2) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rexml (3.4.4) + rouge (4.7.0) + safe_yaml (1.0.5) + sass-embedded (1.104.0) + google-protobuf (~> 4.31) + rake (>= 13) + sass-embedded (1.104.0-aarch64-linux-android) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-aarch64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-aarch64-linux-musl) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-arm-linux-androideabi) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-arm-linux-gnueabihf) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-arm-linux-musleabihf) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-arm64-darwin) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-riscv64-linux-android) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-riscv64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-riscv64-linux-musl) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-x86_64-darwin) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-x86_64-linux-android) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-x86_64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.104.0-x86_64-linux-musl) + google-protobuf (~> 4.31) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.6.0) + webrick (1.9.2) + +PLATFORMS + aarch64-linux-android + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-androideabi + arm-linux-gnu + arm-linux-gnueabihf + arm-linux-musl + arm-linux-musleabihf + arm64-darwin + riscv64-linux-android + riscv64-linux-gnu + riscv64-linux-musl + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-android + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + jekyll (~> 4.4) + jekyll-feed (~> 0.17) + jekyll-seo-tag (~> 2.8) + jekyll-sitemap (~> 1.4) + webrick (~> 1.9) + +CHECKSUMS + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae + colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38 + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + em-websocket (0.5.3) sha256=f56a92bde4e6cb879256d58ee31f124181f68f8887bd14d53d5d9a292758c6a8 + eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972 + ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf + ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809 + ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + forwardable-extended (2.6.0) sha256=1bec948c469bbddfadeb3bd90eb8c85f6e627a412a3e852acfd7eaedbac3ec97 + google-protobuf (4.36.1) sha256=893ac9d66b36b6ea50da5418f856bc7c2d2072099a578aeefc2d7757dcab6a77 + google-protobuf (4.36.1-aarch64-linux-gnu) sha256=3883fa1b0e99221f68f4a8511bc4fb1888897dcb6cc8bf2805c92c16fc99b499 + google-protobuf (4.36.1-aarch64-linux-musl) sha256=6da393554ecc96168ae6ddf663392bf8ce4e4fce7c18fda348882ce2e97d6a87 + google-protobuf (4.36.1-arm64-darwin) sha256=4d82184f4582dfa123f9793cd7a73beca9b0d3f0d3717948c4120b6cc0d50f2d + google-protobuf (4.36.1-x86-linux-gnu) sha256=4ea208a8d1a2369728da03e256fc7b454c6781bac486cbfa57d0ed7a0e6352f0 + google-protobuf (4.36.1-x86-linux-musl) sha256=ec302210ea53f136027f556904b0d13eaf7b4d17fd88b15ddd64b2f321c85dfd + google-protobuf (4.36.1-x86_64-darwin) sha256=e92bf3c90c0a9c410cbe430cf366190b1b9b5b2b784f6195a43a178c2ef7e338 + google-protobuf (4.36.1-x86_64-linux-gnu) sha256=e735a3f3d6596b1010013c2030778bc030770e659106fe7e4ae0f07631b551cb + google-protobuf (4.36.1-x86_64-linux-musl) sha256=ae2712b7960e8b1f96d52fef8c2c0dc1cd230f2be13e48492dad0aa2a6a7cf03 + http_parser.rb (0.8.1) sha256=9ae8df145b39aa5398b2f90090d651c67bd8e2ebfe4507c966579f641e11097a + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + jekyll (4.4.1) sha256=4c1144d857a5b2b80d45b8cf5138289579a9f8136aadfa6dd684b31fe2bc18c1 + jekyll-feed (0.17.0) sha256=689aab16c877949bb9e7a5c436de6278318a51ecb974792232fd94d8b3acfcc3 + jekyll-sass-converter (3.1.0) sha256=83925d84f1d134410c11d0c6643b0093e82e3a3cf127e90757a85294a3862443 + jekyll-seo-tag (2.9.0) sha256=0260015a8e1df9bf195cdfb0c675b7b2883fd8cbf12556e1c1cbe36a831c6852 + jekyll-sitemap (1.4.0) sha256=0de08c5debc185ea5a8f980e1025c7cd3f8e0c35c8b6ef592f15c46235cf4218 + jekyll-watch (2.2.1) sha256=bc44ed43f5e0a552836245a54dbff3ea7421ecc2856707e8a1ee203a8387a7e1 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa + kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729 + liquid (4.0.4) sha256=4fcfebb1a045e47918388dbb7a0925e7c3893e58d2bd6c3b3c73ec17a2d8fdb3 + listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mercenary (0.4.0) sha256=b25a1e4a59adca88665e08e24acf0af30da5b5d859f7d8f38fba52c28f405138 + pathutil (0.16.2) sha256=e43b74365631cab4f6d5e4228f812927efc9cb2c71e62976edcb252ee948d589 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe + rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739 + safe_yaml (1.0.5) sha256=a6ac2d64b7eb027bdeeca1851fe7e7af0d668e133e8a88066a0c6f7087d9f848 + sass-embedded (1.104.0) sha256=308c7cc890de96d77556d515e5958ba2fcfbc69978496ea2b2f0565dd5869bfd + sass-embedded (1.104.0-aarch64-linux-android) sha256=e273a0ea3a29b4933d30f663e88276982e4700a898c984c1ef6cfed56faecfc2 + sass-embedded (1.104.0-aarch64-linux-gnu) sha256=99e2b95e8101928f8976c7a3f7cced5c5e4289ceb8aca0e1dedd9bb13645e417 + sass-embedded (1.104.0-aarch64-linux-musl) sha256=d54f9dc45e753d29a999021f24cfc126b49c61f14bcb8e96209a02288cde22c3 + sass-embedded (1.104.0-arm-linux-androideabi) sha256=f7aa8ec25c9be4dd91c66e55c56379de01fa07b0b721fe0b45528c7fd52c839d + sass-embedded (1.104.0-arm-linux-gnueabihf) sha256=5dfe35be85c0816131648da14312bc4981b1283d3c3aaaab7254b572420b6d93 + sass-embedded (1.104.0-arm-linux-musleabihf) sha256=43d61264663409b268d46f33d2312b0084cb18b9af0c3d181563f5e05ab0053f + sass-embedded (1.104.0-arm64-darwin) sha256=b1409415e06931b43c2a498fc430fe8ddbad9706be5bb3c4e20b60c14f56b122 + sass-embedded (1.104.0-riscv64-linux-android) sha256=4718d3d153c5332da42f4ca4d553642ed1c6d3a0cd047fe4cf3e4ff6ecab4e2c + sass-embedded (1.104.0-riscv64-linux-gnu) sha256=d3e217e7f845ab756d279a5f1954331bc6f6f1d56f955db2e06ba1e91276cc60 + sass-embedded (1.104.0-riscv64-linux-musl) sha256=3ccab81e9f1f3a32364fc7bee111080545638aa93db3dd40a4d8b461aa5a01ca + sass-embedded (1.104.0-x86_64-darwin) sha256=d463b65e6fefcae614352f7a4dcea61d399f35a1238822b38d88471cee954717 + sass-embedded (1.104.0-x86_64-linux-android) sha256=380d8fdfe6612a8e3fc48c38a67302053c8aa6cc166c450f084e77582f4b0105 + sass-embedded (1.104.0-x86_64-linux-gnu) sha256=7aeaa07beff7db254836599f1524ac7b67216729613ebc57e3828b94345f0488 + sass-embedded (1.104.0-x86_64-linux-musl) sha256=bc14555f68aa7057e923d400726c1623bb603101eef6340962e4e80ee89e67ba + terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131 + +BUNDLED WITH + 4.0.20 diff --git a/assets/js/main.js b/assets/js/main.js index a760911..d40377e 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -272,59 +272,6 @@ }); } - /* ---------- live gists from GitHub API ---------- */ - var gistsGrid = document.getElementById("gists-grid"); - if (gistsGrid) { - fetch("https://api.github.com/users/dapiced/gists?per_page=100") - .then(function (r) { - if (!r.ok) throw new Error("GitHub API " + r.status); - return r.json(); - }) - .then(function (gists) { - if (!gists.length) return; /* no public gists yet: keep the fallback card */ - gistsGrid.innerHTML = ""; - gists.forEach(function (gist) { - var files = Object.keys(gist.files); - var first = gist.files[files[0]] || {}; - - var card = document.createElement("a"); - card.className = "project-card reveal"; - card.href = gist.html_url; - card.target = "_blank"; - card.rel = "noopener"; - - var name = document.createElement("span"); - name.className = "project-name"; - name.textContent = first.filename || "gist"; - - var desc = document.createElement("span"); - desc.className = "project-desc"; - var d = gist.description || files.join(" · "); - desc.textContent = d.length > 130 ? d.slice(0, 129) + "…" : d; - - var meta = document.createElement("span"); - meta.className = "project-meta"; - var parts = []; - if (first.language) { - parts.push('' + first.language); - } - parts.push(files.length + (files.length > 1 ? " files" : " file")); - if (gist.comments > 0) parts.push("💬 " + gist.comments); - meta.innerHTML = parts.map(function (p) { return "" + p + ""; }).join(""); - - card.appendChild(name); - card.appendChild(desc); - card.appendChild(meta); - gistsGrid.appendChild(card); - requestAnimationFrame(function () { card.classList.add("visible"); }); - }); - }) - .catch(function () { - /* API unreachable: keep the static fallback card. */ - }); - } - /* ---------- scroll reveal ---------- */ if ("IntersectionObserver" in window && !reduceMotion) { var io = new IntersectionObserver(function (entries) { diff --git a/gists/index.html b/gists/index.html deleted file mode 100644 index 2a6d5d6..0000000 --- a/gists/index.html +++ /dev/null @@ -1,33 +0,0 @@ ---- -layout: default -title: Gists - Code Snippets -description: >- - Quick scripts, one-liners and code snippets from the terminal - Ansible, - Python, Bash and more. Pulled live from GitHub Gist. -keywords: - - gists - - code snippets - - Ansible snippets - - Python scripts - - Bash one-liners - - DevOps scripts - - Dominic D'Apice ---- - -
-

FIELD NOTES

-

Gists

-

- Quick scripts, one-liners and snippets - too small for a repository, too - useful to lose. Pulled live from - GitHub Gist. -

- -

// live data - refreshed from the GitHub API every time you visit

-
diff --git a/notes/index.html b/notes/index.html index 25f52a4..57ef666 100644 --- a/notes/index.html +++ b/notes/index.html @@ -7,11 +7,11 @@ Redirecting… - - + + -

Notes have moved. Continue to Gists →

+

Notes have moved. Return to the homepage →

diff --git a/tools/cosmic-daily/.env.example b/tools/cosmic-daily/.env.example new file mode 100644 index 0000000..4dca08c --- /dev/null +++ b/tools/cosmic-daily/.env.example @@ -0,0 +1 @@ +NASA_API_KEY=DEMO_KEY diff --git a/tools/cosmic-daily/README.md b/tools/cosmic-daily/README.md new file mode 100644 index 0000000..fc0d936 --- /dev/null +++ b/tools/cosmic-daily/README.md @@ -0,0 +1,35 @@ +# Cosmic Daily + +Cosmic Daily generates a daily NASA APOD article for this Jekyll blog while respecting the project’s conventions and safety checks. + +## Local setup + +```bash +cd tools/cosmic-daily +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +python -m pip install -U pip +python -m pip install -e .[dev] +``` + +Create a local `.env` file from `.env.example` and set `NASA_API_KEY`. + +For the GitHub Actions publishing flow, add the same value as a repository secret named `NASA_API_KEY` in the repository settings before enabling the manual `Cosmic Daily` workflow. + +## Commands + +```bash +python -m cosmic_daily preview +python -m cosmic_daily generate +python -m cosmic_daily check +``` + +The default mode is `preview`. + +## Notes + +- `preview` writes only to a temporary directory and never touches tracked files. +- `generate` creates a Jekyll post and the corresponding WebP image when the media is eligible. +- `check` validates front matter and image references for a generated article. +- Video entries and external-copyright cases are treated as human review only. +- The repository workflow dispatch action supports `publish=false` for preview-only runs and `publish=true` to generate a branch and PR. diff --git a/tools/cosmic-daily/cosmic_daily/__init__.py b/tools/cosmic-daily/cosmic_daily/__init__.py new file mode 100644 index 0000000..9916876 --- /dev/null +++ b/tools/cosmic-daily/cosmic_daily/__init__.py @@ -0,0 +1,4 @@ +"""Cosmic Daily package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/tools/cosmic-daily/cosmic_daily/__main__.py b/tools/cosmic-daily/cosmic_daily/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/tools/cosmic-daily/cosmic_daily/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/cosmic-daily/cosmic_daily/article_generator.py b/tools/cosmic-daily/cosmic_daily/article_generator.py new file mode 100644 index 0000000..4ddcbc9 --- /dev/null +++ b/tools/cosmic-daily/cosmic_daily/article_generator.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path +from typing import Optional +from zoneinfo import ZoneInfo + +from .nasa_client import APODRecord + + +SEO_TEXT_MIN = 120 +SEO_TEXT_MAX = 160 + + +def slugify_title(title: str) -> str: + normalized = title.strip().lower() + slug = re.sub(r"[^a-z0-9]+", "-", normalized) + slug = slug.strip("-") + return slug or "apod" + + +def _local_timezone_offset_for(date_value: str) -> str: + tz = ZoneInfo("America/Toronto") + local_dt = datetime.fromisoformat(f"{date_value}T00:00:00").replace(tzinfo=tz) + offset = local_dt.utcoffset() + if offset is None: + return "-0500" + total_minutes = int(offset.total_seconds() // 60) + sign = "+" if total_minutes >= 0 else "-" + total_minutes = abs(total_minutes) + hours, minutes = divmod(total_minutes, 60) + return f"{sign}{hours:02d}{minutes:02d}" + + +def build_seo_description(title: str, explanation: str) -> str: + base = f"{title}: {explanation.strip()}" + cleaned = re.sub(r"\s+", " ", base).strip() + if len(cleaned) <= SEO_TEXT_MAX: + return cleaned[:SEO_TEXT_MAX] + trimmed = cleaned[:SEO_TEXT_MAX].rsplit(" ", 1)[0] + return trimmed if len(trimmed) >= SEO_TEXT_MIN else cleaned[:SEO_TEXT_MAX] + + +def generate_article_markdown(apod: APODRecord, image_url: str, image_width: int, image_height: int) -> str: + intro = ( + f"Each day, the NASA APOD archive turns a different corner of the cosmos into a brief, human-sized window on the universe. " + f"Today’s image, \"{apod.title}\", is a reminder that even a single observation can carry a surprisingly long story." + ) + summary = apod.explanation.strip() + if len(summary) > 700: + summary = summary[:697].rsplit(" ", 1)[0] + "..." + + credit_line = apod.copyright.strip() if apod.copyright else "Credit: NASA APOD" + body = f"""{intro} + +![{apod.title}]({image_url}){{: width=\"{image_width}\" height=\"{image_height}\" loading=\"eager\" }} + +*{credit_line}* + +{summary} + +## Why it caught my attention + +This image stands out because it gives a compact view of a process or object that is easy to overlook when the sky is treated as a background. The science is not a dramatic narrative invented after the fact; it is the reason the observation matters in the first place: a structure, an event, or a field captured with enough clarity to reward a second look. + +[Original APOD publication]({apod.apod_url}) + +*Source data: NASA APOD.* +""" + return body.strip() + "\n" + + +def generate_article(apod: APODRecord, image_path: str, width: int, height: int) -> tuple[str, str]: + slug = slugify_title(apod.title) + date_value = apod.date + offset = _local_timezone_offset_for(date_value) + seo = build_seo_description(apod.title, apod.explanation) + front_matter = ( + "---\n" + f"layout: post\n" + f'title: "APOD: {apod.title}"\n' + f"date: {date_value} 08:00:00 {offset}\n" + "tags: [astronomy, nasa, apod]\n" + f'description: "{seo}"\n' + f"image: /assets/img/apod/{date_value}-{slug}.webp\n" + f"apod_date: {date_value}\n" + f'apod_url: "{apod.apod_url}"\n' + "generated_by: cosmic-daily\n" + "---\n\n" + ) + article_body = generate_article_markdown(apod, image_path, width, height) + return front_matter, front_matter + article_body diff --git a/tools/cosmic-daily/cosmic_daily/cli.py b/tools/cosmic-daily/cosmic_daily/cli.py new file mode 100644 index 0000000..aa4c0d7 --- /dev/null +++ b/tools/cosmic-daily/cosmic_daily/cli.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import tempfile +from datetime import date +from pathlib import Path + +from .article_generator import generate_article, slugify_title +from .image_processor import process_apod_image +from .nasa_client import fetch_apod +from .repository import RepositoryContext +from .rights_policy import evaluate_media_rights + + +EXIT_SUCCESS = 0 +EXIT_ERROR = 1 + + +def _emit_github_output(**values: str) -> None: + github_output = os.environ.get("GITHUB_OUTPUT") + if not github_output: + return + with open(github_output, "a", encoding="utf-8") as handle: + for key, value in values.items(): + handle.write(f"{key}={value}\n") + + +def _write_preview_files(apod, repo: RepositoryContext, output_dir: Path) -> tuple[Path, Path | None]: + slug = slugify_title(apod.title) + image_target_dir = output_dir / "assets" / "img" / "apod" + image_target_dir.mkdir(parents=True, exist_ok=True) + if apod.media_type == "image": + decision = evaluate_media_rights(apod.media_type, apod.copyright) + if decision.status != "allowed": + raise ValueError(decision.reason) + image_path, size = process_apod_image(apod.hdurl or apod.url, image_target_dir, f"{apod.date}-{slug}") + article_front, article_text = generate_article(apod, f"/assets/img/apod/{apod.date}-{slug}.webp", size[0], size[1]) + post_path = output_dir / "_posts" / f"{apod.date}-apod-{slug}.md" + post_path.parent.mkdir(parents=True, exist_ok=True) + post_path.write_text(article_front + article_text.split("---\n\n", 1)[1], encoding="utf-8") + return post_path, image_path + raise ValueError("Unsupported media type for preview generation.") + + +def preview(date_value: str | None = None) -> int: + target = date_value or date.today().isoformat() + try: + apod = fetch_apod(target) + except Exception as exc: + print(f"Preview failed: {exc}") + return EXIT_ERROR + + print(json.dumps({ + "date": apod.date, + "title": apod.title, + "media_type": apod.media_type, + "url": apod.url, + "copyright": apod.copyright, + "apod_url": apod.apod_url, + }, indent=2)) + + try: + with tempfile.TemporaryDirectory(prefix="cosmic-daily-") as temp_dir: + repo = RepositoryContext(repo_root=Path.cwd()) + post_path, image_path = _write_preview_files(apod, repo, Path(temp_dir)) + print(f"Preview article: {post_path}") + if image_path: + print(f"Preview image: {image_path}") + return EXIT_SUCCESS + except Exception as exc: + print(f"Preview generation failed: {exc}") + return EXIT_ERROR + + +def generate(date_value: str | None = None) -> int: + target = date_value or date.today().isoformat() + repo = RepositoryContext() + try: + apod = fetch_apod(target) + except Exception as exc: + print(f"Generation failed: {exc}") + return EXIT_ERROR + + decision = evaluate_media_rights(apod.media_type, apod.copyright) + if decision.status == "unsupported_media": + _emit_github_output(apod_date=apod.date, result="unsupported_media", post_path="", image_path="") + print(decision.reason) + return EXIT_ERROR + if decision.status == "review_required": + _emit_github_output(apod_date=apod.date, result="review_required", post_path="", image_path="") + print(decision.reason) + return EXIT_ERROR + + duplicates = repo.find_duplicates(apod.date, apod.apod_url) + if duplicates: + _emit_github_output(apod_date=apod.date, result="duplicate", post_path="", image_path="") + print("Duplicate APOD detected; no file was generated.") + return EXIT_SUCCESS + + slug = slugify_title(apod.title) + image_dir = repo.ensure_directory(repo.assets_apod_dir) + try: + image_path, image_size = process_apod_image(apod.hdurl or apod.url, image_dir, f"{apod.date}-{slug}") + except Exception as exc: + print(f"Image processing failed: {exc}") + return EXIT_ERROR + + article_front, article_text = generate_article(apod, f"/assets/img/apod/{apod.date}-{slug}.webp", image_size[0], image_size[1]) + post_path = repo.ensure_directory(repo.posts_dir) / f"{apod.date}-apod-{slug}.md" + if post_path.exists(): + print(f"Destination already exists: {post_path}") + return EXIT_ERROR + + post_path.write_text(article_text, encoding="utf-8") + _emit_github_output( + apod_date=apod.date, + result="generated", + post_path=str(post_path), + image_path=str(image_path), + ) + print(f"Generated post: {post_path}") + print(f"Generated image: {image_path}") + return EXIT_SUCCESS + + +def check(post_path: str | None = None) -> int: + repo = RepositoryContext() + candidates = sorted(repo.list_post_files()) + chosen = next((item for item in candidates if "apod" in item.name.lower()), None) + if post_path: + chosen = Path(post_path) + if chosen is None: + print("No APOD article was found to validate.") + return EXIT_ERROR + + if not chosen.exists(): + print(f"Article does not exist: {chosen}") + return EXIT_ERROR + + content = chosen.read_text(encoding="utf-8") + if not content.startswith("---"): + print("Missing YAML front matter.") + return EXIT_ERROR + if "generated_by: cosmic-daily" not in content: + print("Missing generated_by metadata.") + return EXIT_ERROR + if "tags: [astronomy, nasa, apod]" not in content: + print("Missing astronomy tag.") + return EXIT_ERROR + + image_match = next((line for line in content.splitlines() if "![" in line and "/assets/img/apod/" in line), None) + if not image_match: + print("Missing article image reference.") + return EXIT_ERROR + + image_reference = image_match.split("(", 1)[1].split(")", 1)[0] + resolved_image = repo.root / image_reference.lstrip("/") + if not resolved_image.exists(): + print(f"Image file missing: {resolved_image}") + return EXIT_ERROR + + duplicates = repo.find_duplicates( + (content.split("apod_date: ", 1)[1].splitlines()[0].strip()) if "apod_date: " in content else "", + (content.split('apod_url: "', 1)[1].split('"', 1)[0]) if 'apod_url: "' in content else "", + exclude_path=chosen, + ) + if duplicates: + print(f"Duplicate APOD article already exists: {[str(p) for p in duplicates]}") + return EXIT_ERROR + + print(f"Validation passed for {chosen}") + return EXIT_SUCCESS + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate APOD posts for the Jekyll site.") + parser.add_argument("command", nargs="?", choices=["preview", "generate", "check"], default="preview") + parser.add_argument("--date", help="APOD date in ISO format (YYYY-MM-DD)") + parser.add_argument("--post-path", help="Path to post to validate") + args = parser.parse_args(argv) + + if args.command == "preview": + return preview(args.date) + if args.command == "generate": + return generate(args.date) + return check(args.post_path) diff --git a/tools/cosmic-daily/cosmic_daily/image_processor.py b/tools/cosmic-daily/cosmic_daily/image_processor.py new file mode 100644 index 0000000..9237c6e --- /dev/null +++ b/tools/cosmic-daily/cosmic_daily/image_processor.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import io +from pathlib import Path +from typing import Tuple +from urllib.parse import urlparse + +from PIL import Image, ImageOps +import requests + + +MAX_IMAGE_BYTES = 8 * 1024 * 1024 +MAX_IMAGE_SIDE = 1600 + + +def _validate_download_url(raw_url: str) -> str: + parsed = urlparse(raw_url) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError(f"Unsupported image URL: {raw_url}") + return raw_url + + +def _download_bytes(image_url: str) -> bytes: + _validate_download_url(image_url) + response = requests.get(image_url, timeout=20, allow_redirects=False, stream=True) + if response.status_code != 200: + raise RuntimeError(f"Image download returned HTTP {response.status_code} for {image_url}") + content_type = response.headers.get("Content-Type", "") + if not content_type.startswith("image/"): + raise ValueError(f"Unexpected image content type: {content_type}") + content = response.content + if len(content) > MAX_IMAGE_BYTES: + raise ValueError("Image exceeds configured size limit.") + return content + + +def process_apod_image(image_url: str, target_directory: str | Path, output_name: str) -> tuple[Path, tuple[int, int]]: + target_path = Path(target_directory) + target_path.mkdir(parents=True, exist_ok=True) + image_bytes = _download_bytes(image_url) + + try: + with Image.open(io.BytesIO(image_bytes)) as image: + image.verify() + except Exception as exc: # pragma: no cover - defensive + raise ValueError("Downloaded image could not be decoded as valid image data.") from exc + + try: + with Image.open(io.BytesIO(image_bytes)) as image: + original = ImageOps.exif_transpose(image).convert("RGB") + width, height = original.size + if width <= 0 or height <= 0: + raise ValueError("Image dimensions are invalid.") + max_side = max(width, height) + if max_side > MAX_IMAGE_SIDE: + scale = MAX_IMAGE_SIDE / max_side + new_width = max(1, int(round(width * scale))) + new_height = max(1, int(round(height * scale))) + original = original.resize((new_width, new_height), Image.Resampling.LANCZOS) + webp_path = target_path / f"{output_name}.webp" + if webp_path.exists(): + raise FileExistsError(f"Refusing to overwrite existing image: {webp_path}") + original.save(webp_path, format="WEBP", quality=85) + size = original.size + except OSError as exc: + raise ValueError("Image file format is not recognized or not supported.") from exc + + return webp_path.resolve(), size diff --git a/tools/cosmic-daily/cosmic_daily/nasa_client.py b/tools/cosmic-daily/cosmic_daily/nasa_client.py new file mode 100644 index 0000000..bd7aae3 --- /dev/null +++ b/tools/cosmic-daily/cosmic_daily/nasa_client.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import date +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +import requests + + +APOD_ENDPOINT = "https://api.nasa.gov/planetary/apod" + + +@dataclass +class APODRecord: + date: str + title: str + media_type: str + url: str + hdurl: Optional[str] + explanation: str + copyright: Optional[str] + apod_url: str + service_version: Optional[str] = None + + +def _validate_https_url(raw_url: str, field_name: str) -> str: + parsed = urlparse(raw_url) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError(f"{field_name} must be an https URL") + return raw_url + + +def build_apod_url(day: date) -> str: + return f"https://apod.nasa.gov/apod/ap{day:%Y%m%d}.html" + + +def _coerce_date(value: Any) -> str: + if not value: + raise ValueError("APOD date is missing") + text = str(value) + try: + valid = date.fromisoformat(text) + except ValueError as exc: # pragma: no cover - defensive guard + raise ValueError(f"Invalid APOD date: {text}") from exc + return valid.isoformat() + + +def fetch_apod(day: str | date | None = None, api_key: str | None = None) -> APODRecord: + target_day = day.isoformat() if isinstance(day, date) else (str(day) if day else date.today().isoformat()) + configured_key = api_key or os.getenv("NASA_API_KEY") or "DEMO_KEY" + + try: + response = requests.get( + APOD_ENDPOINT, + params={"api_key": configured_key, "date": target_day}, + timeout=20, + allow_redirects=False, + ) + except requests.RequestException as exc: + raise RuntimeError(f"Failed to fetch APOD for {target_day}: {exc}") from exc + + if response.status_code != 200: + raise RuntimeError(f"NASA API returned HTTP {response.status_code} for {target_day}") + + try: + payload = response.json() + except ValueError as exc: + raise ValueError(f"NASA API returned invalid JSON for {target_day}") from exc + + if not isinstance(payload, dict): + raise ValueError("NASA API response is not a JSON object") + + required_fields = ["date", "title", "media_type", "url", "explanation"] + missing = [name for name in required_fields if name not in payload or payload.get(name) in (None, "")] + if missing: + raise ValueError(f"APOD payload missing required fields: {', '.join(missing)}") + + chosen_day = _coerce_date(payload["date"]) + apod_url = payload.get("apod_url") or build_apod_url(date.fromisoformat(chosen_day)) + _validate_https_url(apod_url, "apod_url") + media_url = payload.get("url") + if not media_url: + raise ValueError("APOD media URL is required") + _validate_https_url(media_url, "url") + + hdurl = payload.get("hdurl") + if hdurl: + _validate_https_url(hdurl, "hdurl") + + return APODRecord( + date=chosen_day, + title=str(payload["title"]).strip(), + media_type=str(payload["media_type"]).strip().lower(), + url=str(media_url), + hdurl=str(hdurl) if hdurl else None, + explanation=str(payload["explanation"]).strip(), + copyright=str(payload["copyright"]).strip() if payload.get("copyright") else None, + apod_url=str(apod_url), + service_version=str(payload.get("service_version")) if payload.get("service_version") else None, + ) diff --git a/tools/cosmic-daily/cosmic_daily/repository.py b/tools/cosmic-daily/cosmic_daily/repository.py new file mode 100644 index 0000000..b7ef36c --- /dev/null +++ b/tools/cosmic-daily/cosmic_daily/repository.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Iterable + + +class RepositoryContext: + def __init__(self, repo_root: str | Path | None = None) -> None: + self.root = self._resolve_root(repo_root) + self.posts_dir = self.root / "_posts" + self.assets_apod_dir = self.root / "assets" / "img" / "apod" + + @staticmethod + def _resolve_root(repo_root: str | Path | None = None) -> Path: + if repo_root is not None: + return Path(repo_root).resolve() + + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + cwd=str(Path.cwd()), + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return Path.cwd().resolve() + return Path(result.stdout.strip()).resolve() + + def ensure_directory(self, path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + return path + + def safe_relative_path(self, path: str | Path) -> Path: + candidate = Path(path) + resolved = (self.root / candidate).resolve() + if self.root not in resolved.parents and resolved != self.root: + raise ValueError(f"Refusing to write outside repository: {path}") + return resolved + + def find_duplicates(self, apod_date: str, apod_url: str, exclude_path: str | Path | None = None) -> list[Path]: + if not self.posts_dir.exists(): + return [] + + excluded = Path(exclude_path).resolve() if exclude_path is not None else None + duplicates: list[Path] = [] + for post_path in sorted(self.posts_dir.glob("*.md")): + if excluded is not None and post_path.resolve() == excluded: + continue + + if post_path.name.startswith(f"{apod_date}-") and "apod" in post_path.name.lower(): + duplicates.append(post_path) + continue + + if self._front_matter_has(post_path, "apod_date", apod_date): + duplicates.append(post_path) + continue + + if self._front_matter_has(post_path, "apod_url", apod_url): + duplicates.append(post_path) + continue + return duplicates + + @staticmethod + def _front_matter_has(post_path: Path, field: str, expected: str) -> bool: + try: + content = post_path.read_text(encoding="utf-8") + except OSError: + return False + if not content.startswith("---"): + return False + header, _, _ = content.partition("\n---\n") + if not header: + return False + for line in header.splitlines()[1:]: + if ":" not in line: + continue + key, value = line.split(":", 1) + if key.strip() == field and value.strip().strip('"\'') == expected: + return True + return False + + def list_post_files(self) -> Iterable[Path]: + if not self.posts_dir.exists(): + return [] + return sorted(self.posts_dir.glob("*.md")) diff --git a/tools/cosmic-daily/cosmic_daily/rights_policy.py b/tools/cosmic-daily/cosmic_daily/rights_policy.py new file mode 100644 index 0000000..72afdff --- /dev/null +++ b/tools/cosmic-daily/cosmic_daily/rights_policy.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class RightsDecision: + status: str + allowed: bool + reason: str + + +def _looks_like_nasa_credit(value: str) -> bool: + upper = value.upper() + return "NASA" in upper or "APOD" in upper or "JPL" in upper or "SPACE TELESCOPE" in upper + + +def evaluate_media_rights(media_type: str, copyright: Optional[str] = None) -> RightsDecision: + normalized = (media_type or "").strip().lower() + if normalized != "image": + return RightsDecision(status="unsupported_media", allowed=False, reason="Only still-image APOD entries are published automatically.") + + if copyright is None or not str(copyright).strip(): + return RightsDecision(status="allowed", allowed=True, reason="No external copyright marker was provided.") + + credit = str(copyright).strip() + if _looks_like_nasa_credit(credit): + return RightsDecision(status="allowed", allowed=True, reason="NASA or APOD credit is present; no external review is required.") + + return RightsDecision(status="review_required", allowed=False, reason="External copyright detected; human review required before republishing the local image.") diff --git a/tools/cosmic-daily/pyproject.toml b/tools/cosmic-daily/pyproject.toml new file mode 100644 index 0000000..c300b71 --- /dev/null +++ b/tools/cosmic-daily/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cosmic-daily" +version = "0.1.0" +description = "Generate NASA APOD articles for the Jekyll site" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "Pillow>=10.0.0", + "requests>=2.31.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", +] + +[tool.pytest.ini_options] +pythonpath = ["."] diff --git a/tools/cosmic-daily/tests/test_article_generator.py b/tools/cosmic-daily/tests/test_article_generator.py new file mode 100644 index 0000000..94b9695 --- /dev/null +++ b/tools/cosmic-daily/tests/test_article_generator.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from cosmic_daily.article_generator import generate_article +from cosmic_daily.nasa_client import APODRecord + + +def test_generate_article_includes_required_fields(): + apod = APODRecord( + date="2024-03-15", + title="A Fine Example", + media_type="image", + url="https://example.com/image.jpg", + hdurl="https://example.com/image-hd.jpg", + explanation="Example explanation for the APOD article.", + copyright="Jane Photographer", + apod_url="https://apod.nasa.gov/apod/ap20240315.html", + ) + + front_matter, article = generate_article(apod, "/assets/img/apod/2024-03-15-a-fine-example.webp", 1200, 800) + assert "title: \"APOD: A Fine Example\"" in front_matter + assert "tags: [astronomy, nasa, apod]" in front_matter + assert "apod_date: 2024-03-15" in front_matter + assert 'apod_url: "https://apod.nasa.gov/apod/ap20240315.html"' in front_matter + assert "generated_by: cosmic-daily" in front_matter + assert "Why it caught my attention" in article + assert "Jane Photographer" in article + assert "https://apod.nasa.gov/apod/ap20240315.html" in article diff --git a/tools/cosmic-daily/tests/test_image_processor.py b/tools/cosmic-daily/tests/test_image_processor.py new file mode 100644 index 0000000..0553535 --- /dev/null +++ b/tools/cosmic-daily/tests/test_image_processor.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import io +from pathlib import Path + +from PIL import Image + +from cosmic_daily.image_processor import process_apod_image + + +def test_process_apod_image_converts_to_webp(monkeypatch, tmp_path): + buffer = io.BytesIO() + image = Image.new("RGB", (2200, 1200), color="blue") + image.save(buffer, format="PNG") + payload = buffer.getvalue() + + class DummyResponse: + status_code = 200 + headers = {"Content-Type": "image/png"} + content = payload + + monkeypatch.setattr("requests.get", lambda *args, **kwargs: DummyResponse()) + target, size = process_apod_image("https://example.com/image.png", tmp_path, "2024-03-15-demo") + + assert target.suffix == ".webp" + assert target.exists() + assert size[0] <= 1600 + assert size[1] <= 1600 diff --git a/tools/cosmic-daily/tests/test_nasa_client.py b/tools/cosmic-daily/tests/test_nasa_client.py new file mode 100644 index 0000000..a4401c1 --- /dev/null +++ b/tools/cosmic-daily/tests/test_nasa_client.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest + +from cosmic_daily.nasa_client import APODRecord, fetch_apod + + +class DummyResponse: + def __init__(self, payload=None, status_code=200, headers=None): + self.payload = payload if payload is not None else {} + self.status_code = status_code + self.headers = headers or {} + + def json(self): + return self.payload + + +def test_fetch_apod_success(monkeypatch): + payload = { + "date": "2024-01-01", + "title": "Example title", + "media_type": "image", + "url": "https://example.com/image.jpg", + "hdurl": "https://example.com/image-hd.jpg", + "explanation": "Example explanation", + "copyright": "NASA", + "apod_url": "https://apod.nasa.gov/apod/ap20240101.html", + } + + def fake_get(*args, **kwargs): + return DummyResponse(payload) + + monkeypatch.setattr("requests.get", fake_get) + record = fetch_apod("2024-01-01", api_key="DEMO_KEY") + + assert isinstance(record, APODRecord) + assert record.date == "2024-01-01" + assert record.media_type == "image" + assert record.apod_url.endswith("ap20240101.html") + + +def test_fetch_apod_missing_field_raises(monkeypatch): + def fake_get(*args, **kwargs): + return DummyResponse({"date": "2024-01-01", "title": "Example"}) + + monkeypatch.setattr("requests.get", fake_get) + with pytest.raises(ValueError): + fetch_apod("2024-01-01", api_key="DEMO_KEY") + + +def test_fetch_apod_network_error_raises(monkeypatch): + def fake_get(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr("requests.get", fake_get) + with pytest.raises(RuntimeError): + fetch_apod("2024-01-01", api_key="DEMO_KEY") diff --git a/tools/cosmic-daily/tests/test_repository.py b/tools/cosmic-daily/tests/test_repository.py new file mode 100644 index 0000000..ea9aebf --- /dev/null +++ b/tools/cosmic-daily/tests/test_repository.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path + +from cosmic_daily.repository import RepositoryContext + + +def test_repository_detects_duplicates(tmp_path): + posts_dir = tmp_path / "_posts" + posts_dir.mkdir() + post_path = posts_dir / "2024-03-15-apod-duplicate.md" + post_path.write_text( + "---\nlayout: post\napod_date: 2024-03-15\napod_url: \"https://apod.nasa.gov/apod/ap20240315.html\"\n---\n", + encoding="utf-8", + ) + + repo = RepositoryContext(repo_root=tmp_path) + duplicates = repo.find_duplicates("2024-03-15", "https://apod.nasa.gov/apod/ap20240315.html") + assert duplicates == [post_path] + + +def test_repository_ignores_current_post_when_checking_duplicates(tmp_path): + posts_dir = tmp_path / "_posts" + posts_dir.mkdir() + post_path = posts_dir / "2024-03-15-apod-duplicate.md" + post_path.write_text( + "---\nlayout: post\napod_date: 2024-03-15\napod_url: \"https://apod.nasa.gov/apod/ap20240315.html\"\n---\n", + encoding="utf-8", + ) + + repo = RepositoryContext(repo_root=tmp_path) + duplicates = repo.find_duplicates( + "2024-03-15", + "https://apod.nasa.gov/apod/ap20240315.html", + exclude_path=post_path, + ) + assert duplicates == [] diff --git a/tools/cosmic-daily/tests/test_rights_policy.py b/tools/cosmic-daily/tests/test_rights_policy.py new file mode 100644 index 0000000..2f319d6 --- /dev/null +++ b/tools/cosmic-daily/tests/test_rights_policy.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from cosmic_daily.rights_policy import evaluate_media_rights + + +def test_allows_nasa_image(): + decision = evaluate_media_rights("image", "NASA") + assert decision.allowed is True + assert decision.status == "allowed" + + +def test_rejects_external_copyright(): + decision = evaluate_media_rights("image", "Jane Photographer") + assert decision.allowed is False + assert decision.status == "review_required" + + +def test_rejects_video(): + decision = evaluate_media_rights("video") + assert decision.allowed is False + assert decision.status == "unsupported_media" From bc7524b57d0cab457d6371b887288c2aaa4abfdf Mon Sep 17 00:00:00 2001 From: Dominic D'Apice Date: Sun, 6 Sep 2026 21:39:16 -0400 Subject: [PATCH 2/3] fix(cosmic-daily): increase NASA API timeout and add retries DEMO_KEY is a globally shared, rate-limited key that can respond slowly under load. The previous 20s timeout caused the GitHub Actions workflow to fail with a read timeout. Use a (10s connect, 45s read) timeout and retry up to 3 times with backoff before failing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../cosmic-daily/cosmic_daily/nasa_client.py | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/tools/cosmic-daily/cosmic_daily/nasa_client.py b/tools/cosmic-daily/cosmic_daily/nasa_client.py index bd7aae3..dc02f58 100644 --- a/tools/cosmic-daily/cosmic_daily/nasa_client.py +++ b/tools/cosmic-daily/cosmic_daily/nasa_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import time from dataclasses import dataclass from datetime import date from typing import Any, Dict, Optional @@ -10,6 +11,11 @@ APOD_ENDPOINT = "https://api.nasa.gov/planetary/apod" +# DEMO_KEY is a shared, rate-limited key; requests can be slow under load, so +# use a generous read timeout and retry a couple of times before giving up. +REQUEST_TIMEOUT = (10, 45) +MAX_ATTEMPTS = 3 +RETRY_BACKOFF_SECONDS = 5 @dataclass @@ -51,15 +57,25 @@ def fetch_apod(day: str | date | None = None, api_key: str | None = None) -> APO target_day = day.isoformat() if isinstance(day, date) else (str(day) if day else date.today().isoformat()) configured_key = api_key or os.getenv("NASA_API_KEY") or "DEMO_KEY" - try: - response = requests.get( - APOD_ENDPOINT, - params={"api_key": configured_key, "date": target_day}, - timeout=20, - allow_redirects=False, - ) - except requests.RequestException as exc: - raise RuntimeError(f"Failed to fetch APOD for {target_day}: {exc}") from exc + response = None + last_error: requests.RequestException | None = None + for attempt in range(1, MAX_ATTEMPTS + 1): + try: + response = requests.get( + APOD_ENDPOINT, + params={"api_key": configured_key, "date": target_day}, + timeout=REQUEST_TIMEOUT, + allow_redirects=False, + ) + break + except requests.RequestException as exc: + last_error = exc + if attempt < MAX_ATTEMPTS: + time.sleep(RETRY_BACKOFF_SECONDS * attempt) + if response is None: + raise RuntimeError( + f"Failed to fetch APOD for {target_day} after {MAX_ATTEMPTS} attempts: {last_error}" + ) from last_error if response.status_code != 200: raise RuntimeError(f"NASA API returned HTTP {response.status_code} for {target_day}") From a7cce4b1f103c973f60029674dbf227e23c42b31 Mon Sep 17 00:00:00 2001 From: Dominic D'Apice Date: Sun, 6 Sep 2026 21:41:07 -0400 Subject: [PATCH 3/3] chore: ignore *.egg-info build artifacts Also removed the tools/cosmic-daily/cosmic_daily.egg-info directory that was accidentally tracked from a local pip install -e . run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ab2e1c3..44d8911 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ !.env.example __pycache__/ *.py[cod] +*.egg-info/ .pytest_cache/ .venv/ venv/