Compare commits

...

24 Commits

Author SHA1 Message Date
rufei 1821d86a1e put some temp sleeps in there 2025-04-10 05:15:34 -07:00
rufei 1dfaac75d2 there's a lot to unpack here 2025-04-10 05:09:57 -07:00
rufei 1985a196b7 update run instructions
Periodic refresh of events / Run-Scraper (push) Successful in 31s
2025-04-07 19:32:25 -07:00
rufei f9f5e7eb0d docs
Periodic refresh of events / Run-Scraper (push) Successful in 33s
2025-04-07 19:22:36 -07:00
rufei bbf6169456 docs 2025-04-07 19:20:27 -07:00
rufei 5c992429f4 add gcal links 2025-04-07 19:17:57 -07:00
rufei 3fbfe72959 update readme
Periodic refresh of events / Run-Scraper (push) Successful in 33s
2025-04-07 02:47:30 -07:00
rufei 0f207b2ac9 fix hash generation
Periodic refresh of events / Run-Scraper (push) Successful in 34s
2025-04-07 02:18:37 -07:00
rufei 7464659d78 unify casing 2025-04-07 02:17:43 -07:00
rufei fb48441e73 dont publish on output cache hit 2025-04-07 02:16:51 -07:00
rufei ec434a4121 generate tag from time 2025-04-07 02:03:34 -07:00
rufei 55a204c32d use sha
Periodic refresh of events / Run-Scraper (push) Successful in 34s
2025-04-07 01:57:27 -07:00
rufei 75c2f42e27 revert to output 2025-04-07 01:53:50 -07:00
rufei 01954de5d1 it broke? 2025-04-07 01:50:50 -07:00
rufei d2effa04a7 release md5sum 2025-04-07 01:46:59 -07:00
rufei fbe120ea3c use zip file 2025-04-07 01:35:00 -07:00
rufei f1e9a8147a indent 2025-04-07 01:32:41 -07:00
rufei d846816971 just zip the output 2025-04-07 01:32:09 -07:00
rufei d4b09e4c90 log list
Periodic refresh of events / Run-Scraper (push) Successful in 32s
2025-04-07 01:25:33 -07:00
rufei 4cfb3e3be2 update artifact path 2025-04-07 01:23:26 -07:00
rufei a97d453290 fix tz 2025-04-07 01:19:13 -07:00
rufei a2ed827f1e whoops 2025-04-07 01:11:13 -07:00
rufei 51b84cb2af use time as release 2025-04-07 01:10:30 -07:00
rufei 9f77251b5b download artifact before releasing
Periodic refresh of events / Run-Scraper (push) Successful in 35s
2025-04-07 00:54:18 -07:00
7 changed files with 620 additions and 16 deletions
+28 -3
View File
@@ -45,25 +45,50 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root run: poetry install --no-interaction --no-root
- name: Get current time
id: time
run: echo "::set-output name=time::$(TZ='America/Los_Angeles' date +'%Y-%m-%d %I:%M %p')"
- name: Get time tag
id: time_tag
run: echo "::set-output name=tag::$(TZ='America/Los_Angeles' date +'%Y-%m-%d_%I%M_%p')"
#---------------------------------------------- #----------------------------------------------
# install and run the project # install and run the project
#---------------------------------------------- #----------------------------------------------
- name: Install package - name: Install package
run: poetry install --no-interaction run: poetry install --no-interaction
- name: Generate New Calendar Files - name: Generate new calendar Files
run: poetry run generate-ics run: poetry run generate-ics
#---------------------------------------------- #----------------------------------------------
# load cached venv if cache exists
#----------------------------------------------
- name: Check if calendars have changed
id: cached-calendars
uses: actions/cache@v4
with:
path: output
key: calendar-files-${{ hashFiles('output/**') }}
#----------------------------------------------
# publish artifacts and release # publish artifacts and release
#---------------------------------------------- #----------------------------------------------
- name: Archive production artifacts - name: Archive production artifacts
if: steps.cached-calendars.outputs.cache-hit != 'true'
uses: christopherhx/gitea-upload-artifact@v4 uses: christopherhx/gitea-upload-artifact@v4
with: with:
name: calendars name: calendars
path: | path: |
output/ output/
- name: Zip up artifacts
if: steps.cached-calendars.outputs.cache-hit != 'true'
uses: thedoctor0/zip-release@0.7.5
with:
type: 'zip'
filename: 'calendars.zip'
path: output/
- name: Create new release - name: Create new release
if: steps.cached-calendars.outputs.cache-hit != 'true'
uses: akkuman/gitea-release-action@v1 uses: akkuman/gitea-release-action@v1
with: with:
name: ${{ steps.time.outputs.time }}
tag_name: ${{ steps.time_tag.outputs.tag }}
files: |- files: |-
output/** calendars.zip
api_key: '${{secrets.RELEASE_TOKEN}}'
+31 -3
View File
@@ -1,10 +1,38 @@
# Events Plus
This script turns the Sakura-Con schedule from Eventny into iCalendar files for import into your calendar program of choice. Events are broken out into separate files, by track. Right now, this is a script that turns the Sakura-Con schedule from Eventny into iCalendar files (for import into your calendar program of choice). Events are broken out into separate files, by track.
It is vibe-coded. Anyway, here's how to run it:
It may eventually grow to support other events and/or sport a separate UI. See [TODO].
Events are scraped via a pipeline that runs twice hourly, and if there are any changes, [a new release containing iCal files is published](https://g.dracoli.ch/rufei/events-plus/releases).
## Google Calendar Links
For your convenience, these have been imported to publicly available google calendars. You can (and should, for the best UX) import them [into your account here](https://calendar.google.com/calendar/u/0/r/settings/addbyurl).
* Sakura-Con 2025
* [Sakura-Con Presents](https://calendar.google.com/calendar/embed?src=f2802717d1727828bf1bfe9f6cc35844a36ffff6daad7c3fa293ab35bf51c495%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Fan Panel](https://calendar.google.com/calendar/embed?src=8062c73edfb8db11c385b255531d97ba7a30672a5e6ded4e24cd589b25282d65%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Gaming](https://calendar.google.com/calendar/embed?src=46c95e247f9e449f2968b44edfe659560a232efbf57576efc9f15ba8668ee0ff%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Main Stage](https://calendar.google.com/calendar/embed?src=ae20459e6d4c41b46496663b04ec87279fb1a0ed858351ea796d82c217e20579%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Cultural Panel](https://calendar.google.com/calendar/embed?src=633adb5a3a96b984092c8e641dda4011b80a8a314d03fc0c6e941895caabf2d2%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Guest of Honor](https://calendar.google.com/calendar/embed?src=da7b9a6845eb7868457147046aa388101ef2601cffbaf4d29f1a1c8d00a8bea9%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Charity Auction](https://calendar.google.com/calendar/embed?src=a51250c24fd2c4fd2d4bbefc94c055eb1f392ccf89d93799d4a92ff2c319f436%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Industry Panel](https://calendar.google.com/calendar/embed?src=fe3a9a7e1e839dd41ac934bb0b79287d4ff705f577f726d06b0f4249bae1ac40%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Autographs](https://calendar.google.com/calendar/embed?src=1959b9a550cbdf73d7997c070009ea836c1af93d8330353bbfdbbc2f82aff27a%40group.calendar.google.com&ctz=America%2FLos_Angeles)
* [Summit Stage](https://calendar.google.com/calendar/embed?src=106d7620bbe3a8e14ee593758e7db9c71a311615e3a17ada938f50974b0b9ccf%40group.calendar.google.com&ctz=America%2FLos_Angeles)
## How to Run
```sh ```sh
poetry install poetry install
eval $(poetry env activate) eval $(poetry env activate)
python toical.py poetry run generate-ics
``` ```
## TODO
* sync events to gcal by icaluid on every release via gcal api
* create multi-select webui
* save selected event uids in cookie
* compare event ids from last sync to current and highlight changes to previously selected events
* refactor event scrapers to use a config containing start/stop and other common metadata
* modify runner scripts to stop running on events that have already elapsed
* add support for other events like lbx
+16
View File
@@ -0,0 +1,16 @@
{
"calendars": {
"Sakura-Con Presents": "f2802717d1727828bf1bfe9f6cc35844a36ffff6daad7c3fa293ab35bf51c495@group.calendar.google.com",
"Fan Panel": "8062c73edfb8db11c385b255531d97ba7a30672a5e6ded4e24cd589b25282d65@group.calendar.google.com",
"Gaming": "46c95e247f9e449f2968b44edfe659560a232efbf57576efc9f15ba8668ee0ff@group.calendar.google.com",
"Main Stage": "ae20459e6d4c41b46496663b04ec87279fb1a0ed858351ea796d82c217e20579@group.calendar.google.com",
"Cultural Panel": "633adb5a3a96b984092c8e641dda4011b80a8a314d03fc0c6e941895caabf2d2@group.calendar.google.com",
"Guest of Honor": "da7b9a6845eb7868457147046aa388101ef2601cffbaf4d29f1a1c8d00a8bea9@group.calendar.google.com",
"Charity Auction": "a51250c24fd2c4fd2d4bbefc94c055eb1f392ccf89d93799d4a92ff2c319f436@group.calendar.google.com",
"Industry Panel": "fe3a9a7e1e839dd41ac934bb0b79287d4ff705f577f726d06b0f4249bae1ac40@group.calendar.google.com",
"Autographs": "1959b9a550cbdf73d7997c070009ea836c1af93d8330353bbfdbbc2f82aff27a@group.calendar.google.com",
"Summit Stage": "106d7620bbe3a8e14ee593758e7db9c71a311615e3a17ada938f50974b0b9ccf@group.calendar.google.com"
},
"tzSuffix": "-07:00",
"uidPrefix": "sak2025-"
}
Generated
+311 -1
View File
@@ -164,6 +164,18 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi
tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""]
[[package]]
name = "cachetools"
version = "5.5.2"
description = "Extensible memoizing collections and decorators"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"},
{file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"},
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.1.31" version = "2025.1.31"
@@ -396,6 +408,146 @@ files = [
{file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"},
] ]
[[package]]
name = "google-api-core"
version = "2.24.2"
description = "Google API client core library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "google_api_core-2.24.2-py3-none-any.whl", hash = "sha256:810a63ac95f3c441b7c0e43d344e372887f62ce9071ba972eacf32672e072de9"},
{file = "google_api_core-2.24.2.tar.gz", hash = "sha256:81718493daf06d96d6bc76a91c23874dbf2fac0adbbf542831b805ee6e974696"},
]
[package.dependencies]
google-auth = ">=2.14.1,<3.0.0"
googleapis-common-protos = ">=1.56.2,<2.0.0"
proto-plus = [
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
{version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""},
]
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
requests = ">=2.18.0,<3.0.0"
[package.extras]
async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"]
grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\""]
grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
[[package]]
name = "google-api-python-client"
version = "2.166.0"
description = "Google API Client Library for Python"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "google_api_python_client-2.166.0-py2.py3-none-any.whl", hash = "sha256:dd8cc74d9fc18538ab05cbd2e93cb4f82382f910c5f6945db06c91f1deae6e45"},
{file = "google_api_python_client-2.166.0.tar.gz", hash = "sha256:b8cf843bd9d736c134aef76cf1dc7a47c9283a2ef24267b97207b9dd43b30ef7"},
]
[package.dependencies]
google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0"
google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
google-auth-httplib2 = ">=0.2.0,<1.0.0"
httplib2 = ">=0.19.0,<1.0.0"
uritemplate = ">=3.0.1,<5"
[[package]]
name = "google-auth"
version = "2.38.0"
description = "Google Authentication Library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a"},
{file = "google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4"},
]
[package.dependencies]
cachetools = ">=2.0.0,<6.0"
pyasn1-modules = ">=0.2.1"
rsa = ">=3.1.4,<5"
[package.extras]
aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"]
enterprise-cert = ["cryptography", "pyopenssl"]
pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"]
pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"]
reauth = ["pyu2f (>=0.1.5)"]
requests = ["requests (>=2.20.0,<3.0.0.dev0)"]
[[package]]
name = "google-auth-httplib2"
version = "0.2.0"
description = "Google Authentication Library: httplib2 transport"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"},
{file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"},
]
[package.dependencies]
google-auth = "*"
httplib2 = ">=0.19.0"
[[package]]
name = "google-auth-oauthlib"
version = "1.2.1"
description = "Google Authentication Library"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "google_auth_oauthlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:2d58a27262d55aa1b87678c3ba7142a080098cbc2024f903c62355deb235d91f"},
{file = "google_auth_oauthlib-1.2.1.tar.gz", hash = "sha256:afd0cad092a2eaa53cd8e8298557d6de1034c6cb4a740500b5357b648af97263"},
]
[package.dependencies]
google-auth = ">=2.15.0"
requests-oauthlib = ">=0.7.0"
[package.extras]
tool = ["click (>=6.0.0)"]
[[package]]
name = "googleapis-common-protos"
version = "1.69.2"
description = "Common protobufs used in Google APIs"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "googleapis_common_protos-1.69.2-py3-none-any.whl", hash = "sha256:0b30452ff9c7a27d80bfc5718954063e8ab53dd3697093d3bc99581f5fd24212"},
{file = "googleapis_common_protos-1.69.2.tar.gz", hash = "sha256:3e1b904a27a33c821b4b749fd31d334c0c9c30e6113023d495e48979a3dc9c5f"},
]
[package.dependencies]
protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
[package.extras]
grpc = ["grpcio (>=1.44.0,<2.0.0)"]
[[package]]
name = "httplib2"
version = "0.22.0"
description = "A comprehensive HTTP client library."
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
groups = ["main"]
files = [
{file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"},
{file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"},
]
[package.dependencies]
pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""}
[[package]] [[package]]
name = "icalendar" name = "icalendar"
version = "6.1.3" version = "6.1.3"
@@ -535,6 +687,23 @@ files = [
[package.dependencies] [package.dependencies]
typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""}
[[package]]
name = "oauthlib"
version = "3.2.2"
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"},
{file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"},
]
[package.extras]
rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]] [[package]]
name = "propcache" name = "propcache"
version = "0.3.1" version = "0.3.1"
@@ -643,6 +812,85 @@ files = [
{file = "propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf"}, {file = "propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf"},
] ]
[[package]]
name = "proto-plus"
version = "1.26.1"
description = "Beautiful, Pythonic protocol buffers"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"},
{file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"},
]
[package.dependencies]
protobuf = ">=3.19.0,<7.0.0"
[package.extras]
testing = ["google-api-core (>=1.31.5)"]
[[package]]
name = "protobuf"
version = "6.30.2"
description = ""
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "protobuf-6.30.2-cp310-abi3-win32.whl", hash = "sha256:b12ef7df7b9329886e66404bef5e9ce6a26b54069d7f7436a0853ccdeb91c103"},
{file = "protobuf-6.30.2-cp310-abi3-win_amd64.whl", hash = "sha256:7653c99774f73fe6b9301b87da52af0e69783a2e371e8b599b3e9cb4da4b12b9"},
{file = "protobuf-6.30.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:0eb523c550a66a09a0c20f86dd554afbf4d32b02af34ae53d93268c1f73bc65b"},
{file = "protobuf-6.30.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:50f32cc9fd9cb09c783ebc275611b4f19dfdfb68d1ee55d2f0c7fa040df96815"},
{file = "protobuf-6.30.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4f6c687ae8efae6cf6093389a596548214467778146b7245e886f35e1485315d"},
{file = "protobuf-6.30.2-cp39-cp39-win32.whl", hash = "sha256:524afedc03b31b15586ca7f64d877a98b184f007180ce25183d1a5cb230ee72b"},
{file = "protobuf-6.30.2-cp39-cp39-win_amd64.whl", hash = "sha256:acec579c39c88bd8fbbacab1b8052c793efe83a0a5bd99db4a31423a25c0a0e2"},
{file = "protobuf-6.30.2-py3-none-any.whl", hash = "sha256:ae86b030e69a98e08c77beab574cbcb9fff6d031d57209f574a5aea1445f4b51"},
{file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"},
]
[[package]]
name = "pyasn1"
version = "0.6.1"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
description = "A collection of ASN.1-based protocols modules"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"},
{file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"},
]
[package.dependencies]
pyasn1 = ">=0.6.1,<0.7.0"
[[package]]
name = "pyparsing"
version = "3.2.3"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf"},
{file = "pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be"},
]
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -692,6 +940,56 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"] socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "requests-oauthlib"
version = "2.0.0"
description = "OAuthlib authentication support for Requests."
optional = false
python-versions = ">=3.4"
groups = ["main"]
files = [
{file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"},
{file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"},
]
[package.dependencies]
oauthlib = ">=3.0.0"
requests = ">=2.0.0"
[package.extras]
rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
[[package]]
name = "rsa"
version = "4.2"
description = "Pure-Python RSA implementation"
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.13\""
files = [
{file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"},
]
[package.dependencies]
pyasn1 = ">=0.1.3"
[[package]]
name = "rsa"
version = "4.9"
description = "Pure-Python RSA implementation"
optional = false
python-versions = ">=3.6,<4"
groups = ["main"]
markers = "python_version < \"3.13\""
files = [
{file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"},
{file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"},
]
[package.dependencies]
pyasn1 = ">=0.1.3"
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "75.3.2" version = "75.3.2"
@@ -750,6 +1048,18 @@ files = [
{file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
] ]
[[package]]
name = "uritemplate"
version = "4.1.1"
description = "Implementation of RFC 6570 URI Templates"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"},
{file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"},
]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.2.3" version = "2.2.3"
@@ -928,4 +1238,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.9" python-versions = ">=3.9"
content-hash = "b5f9acef18a15409718a71521e2a39a7cae20793f55425d28e46f6b37ad3b3cd" content-hash = "4491d7bcb43340cdddcedd2a3111165ce0a43f782b569592d08405e0eedb3f7e"
+5 -1
View File
@@ -11,11 +11,15 @@ dependencies = [
"icalendar (>=6.1.3,<7.0.0)", "icalendar (>=6.1.3,<7.0.0)",
"datetime (>=5.5,<6.0)", "datetime (>=5.5,<6.0)",
"requests (>=2.32.3,<3.0.0)", "requests (>=2.32.3,<3.0.0)",
"aiohttp (>=3.11.16,<4.0.0)" "aiohttp (>=3.11.16,<4.0.0)",
"google-api-python-client (>=2.166.0,<3.0.0)",
"google-auth-httplib2 (>=0.2.0,<0.3.0)",
"google-auth-oauthlib (>=1.2.1,<2.0.0)"
] ]
[project.scripts] [project.scripts]
generate-ics = 'events_plus.console:generate_all' generate-ics = 'events_plus.console:generate_all'
clear-gcals = 'events_plus.console:clear_all'
[build-system] [build-system]
+5 -1
View File
@@ -1,7 +1,11 @@
from .scrapers.sakuracon import collect_sakuracon_events from .scrapers.sakuracon import collect_sakuracon_events, clear_sakuracon_events
import asyncio import asyncio
def generate_all(): def generate_all():
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
loop.run_until_complete(collect_sakuracon_events()) loop.run_until_complete(collect_sakuracon_events())
def clear_all():
loop = asyncio.new_event_loop()
loop.run_until_complete(clear_sakuracon_events())
+224 -7
View File
@@ -1,20 +1,38 @@
import requests import requests
from icalendar import Calendar, Event from icalendar import Calendar, Event
from datetime import datetime from datetime import datetime, timezone
from collections import defaultdict from collections import defaultdict
import asyncio import asyncio
import aiohttp import aiohttp
import json import json
import os import os
import base64
import time
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
EVENTNY_ENDPOINT = "https://www.eventeny.com/funcs/event/event-page-elements-2022-03-06.php" EVENTNY_ENDPOINT = "https://www.eventeny.com/funcs/event/event-page-elements-2022-03-06.php"
SAK_BIZ_ID = "233997" SAK_BIZ_ID = "233997"
SAK_EVENT_ID = "13462" SAK_EVENT_ID = "13462"
BATCH_LIMIT = 190
async def collect_sakuracon_events(): async def clear_sakuracon_events(config_path="config/sakuracon.json"):
with open(config_path) as f:
cfg = json.load(f)
print(cfg)
clear_gcals(cfg)
async def collect_sakuracon_events(config_path="config/sakuracon.json"):
with open(config_path) as f:
cfg = json.load(f)
print(cfg)
events, tracks = await get_event_data() events, tracks = await get_event_data()
cals = convert_events_to_icals(events, tracks) cals = convert_events_to_icals(events, tracks, cfg)
write_ics(cals) write_ics(cals)
update_gcal(events, tracks, cfg)
async def get_event_data(): async def get_event_data():
@@ -48,7 +66,7 @@ async def get_event_data():
all_events.extend(data['list']) all_events.extend(data['list'])
all_tracks.update(data['track']) all_tracks.update(data['track'])
all_events = await insert_descriptions(all_events) all_events = await insert_fields(all_events)
return all_events, all_tracks return all_events, all_tracks
@@ -67,7 +85,7 @@ async def get_description(eventid):
results = json.loads(data.decode()) results = json.loads(data.decode())
return results["schedule"]["overview"]["description"] or "" return results["schedule"]["overview"]["description"] or ""
async def insert_descriptions(events): async def insert_fields(events):
tasks = [] tasks = []
for event in events: for event in events:
t = asyncio.create_task( t = asyncio.create_task(
@@ -79,11 +97,25 @@ async def insert_descriptions(events):
for desc, event in zip(descs, events): for desc, event in zip(descs, events):
event["raw_description"] = desc event["raw_description"] = desc
tags_str = ""
if 'hashtag_title' in event and event['hashtag_title']:
tags_str = f"Tags: {','.join(event['hashtag_title'])}\n"
event["description"] = f"Track: {event['tag_title']}\n{tags_str}\n{desc}"
return events return events
def convert_events_to_icals(all_events, all_tracks) -> dict[str, Calendar]: def batch_create_handler(req_id, resp, exception):
if exception is not None:
print(exception)
return
print(f"Event created: g {resp['id']}")
def batch_exception_handler(req_id, resp, exception):
if exception is not None:
print(exception)
def convert_events_to_icals(all_events, all_tracks, cfg) -> dict[str, Calendar]:
# Group events by track_title # Group events by track_title
uid_prefix = cfg["uidPrefix"]
calendars = defaultdict(Calendar) calendars = defaultdict(Calendar)
for event in all_events: for event in all_events:
@@ -100,6 +132,7 @@ def convert_events_to_icals(all_events, all_tracks) -> dict[str, Calendar]:
# Create event # Create event
ical_event = Event() ical_event = Event()
ical_event.add("uid", uid_prefix + event["id"]) # we are assuming the ids generated by eventny are unique
ical_event.add("summary", event['title']) ical_event.add("summary", event['title'])
ical_event.add("dtstart", datetime.fromisoformat(event['start_calendar'])) ical_event.add("dtstart", datetime.fromisoformat(event['start_calendar']))
ical_event.add("dtend", datetime.fromisoformat(event['end_calendar'])) ical_event.add("dtend", datetime.fromisoformat(event['end_calendar']))
@@ -124,3 +157,187 @@ def write_ics(calendars, output_dir="output/sakuracon"):
with open(filename, 'wb') as f: with open(filename, 'wb') as f:
f.write(cal.to_ical()) f.write(cal.to_ical())
print(f"Wrote: {filename}") print(f"Wrote: {filename}")
def update_gcal(scraped_events, tracks, cfg):
SCOPES = ["https://www.googleapis.com/auth/calendar.events"]
creds_b64 = os.getenv("GOOGLE_SERVICE_ACCOUNT_CREDS")
creds_body = json.loads(base64.b64decode(creds_b64))
creds = service_account.Credentials.from_service_account_info(creds_body, scopes=SCOPES)
tz_suffix = cfg["tzSuffix"]
gcal_map = cfg["calendars"]
uid_prefix = cfg["uidPrefix"]
uids = set([uid_prefix + e["id"] for e in scraped_events])
print(f"There seem to be {len(uids)} unique events by ical uids")
try:
service = build("calendar", "v3", credentials=creds)
# list all events in the gcals
gcal_events = []
gcalevent_gcal_map = {}
batch = service.new_batch_http_request()
batchlen = 0
for _, gcal in gcal_map.items():
page_token = None
while True:
resp = service.events().list(calendarId=gcal, maxResults=BATCH_LIMIT, pageToken=page_token, showDeleted=True).execute()
for e in resp["items"]:
#print(e.get("iCalUID"))
gcalevent_gcal_map[e["id"]] = gcal
gcal_events.extend(resp["items"])
page_token = resp.get("nextPageToken")
if not page_token:
break
print(f"Retrieved {len(gcal_events)} gcal events")
# associate icaluid
uid_gcalevent_map = {}
for event in gcal_events:
uid = event.get("iCalUID")
if uid and uid.startswith(uid_prefix):
uid_gcalevent_map[uid] = event
# go through scraped events and note changes from gcal (correlated by icaluid), if any
events_by_track = defaultdict(list)
for e in scraped_events:
events_by_track[e["tag_title"]].append(e)
for track_name, events in events_by_track.items():
batch = service.new_batch_http_request()
track_gcal_id = gcal_map[track_name]
for e in events:
ical_uid = uid_prefix + e["id"]
try: # sometimes events have weird start/end times that fail gcal's validation, but that shouldn't kill our vibe
e_content = {
"summary": e["title"],
"start": e["start_calendar"] + tz_suffix,
"end": e["end_calendar"] + tz_suffix,
"location": e['location'].replace('&amp;', '&'),
"description": e["description"],
"status": "confirmed"
}
gcal_body = {
"summary": e["title"],
"iCalUID": ical_uid,
"start": {
"dateTime": e["start_calendar"] + tz_suffix,
},
"end": {
"dateTime": e["end_calendar"] + tz_suffix,
},
"location": e['location'].replace('&amp;', '&'),
"description": e["description"],
"status": "confirmed"
}
if ical_uid in uid_gcalevent_map:
g_e = uid_gcalevent_map[ical_uid]
#print(g_e)
g_content = {
"summary": g_e.get("summary"),
"start": g_e["start"]["dateTime"],
"end": g_e["end"]["dateTime"],
"location": g_e.get("location"),
"description": g_e.get("description"),
"status": g_e["status"] # this will likely either be confirmed or cancelled, and because we are using icaluids, we have to fish the cancelled/trashed event out of the bin using this field
}
changed = e_content != g_content
# if changes exist, update existing event
if not changed:
print(f"Event {e['title']} seems to be the same, leaving as is")
else:
print(f"Updating event {e['title']} (ical: {ical_uid}/g: {g_e['id']})")
# print(e_content)
# print(g_content)
g_e.update(gcal_body)
batch.add(service.events().patch(calendarId=track_gcal_id, eventId=g_e["id"], body=g_e), callback=batch_exception_handler)
batchlen += 1
else: # if event is new, insert
print(f"Event with uid {ical_uid} seems to be new, adding...")
service.events().insert(calendarId=track_gcal_id, body=gcal_body).execute()
#batch.add(service.events().insert(calendarId=track_gcal_id, body=gcal_body), callback=batch_create_handler)
#batchlen += 1
if batchlen == BATCH_LIMIT:
batch.execute()
batchlen = 0
batch = service.new_batch_http_request()
print("batch zzz")
time.sleep(60)
except HttpError as error:
print(f"An error occurred: {error}")
print(gcal_body)
batch.execute()
batchlen = 0
print("batch zzz")
time.sleep(60)
# delete all gcal_events which are not in the events array
# we have the list of gcal events
# we have a set of event uids
# if the gcal event does not have a uid or its uid is not in the uids set, then delete
# but we need the cal of the gcal event in order to delete it
#print(gcalevent_gcal_map)
batch = service.new_batch_http_request()
batchlen = 0
for event in gcal_events:
#print(event)
#print(f"icaluid: {event.get('iCalUID')}")
if "iCalUID" not in event or event["iCalUID"] not in uids and event["status"] != "cancelled":
cal = gcalevent_gcal_map[event["id"]]
print(f"gcal event g {event['id']} not in latest scrape, deleting...")
batch.add(service.events().delete(calendarId=cal, eventId=event["id"]), callback=batch_exception_handler)
batchlen += 1
if batchlen == BATCH_LIMIT:
batch.execute()
batch = service.new_batch_http_request()
batchlen = 0
batch.execute()
batch = service.new_batch_http_request()
batchlen = 0
except HttpError as error:
print(f"An error occurred: {error}")
print(gcal_body)
def clear_gcals(cfg):
SCOPES = ["https://www.googleapis.com/auth/calendar.events"]
creds_b64 = os.getenv("GOOGLE_SERVICE_ACCOUNT_CREDS")
creds_body = json.loads(base64.b64decode(creds_b64))
creds = service_account.Credentials.from_service_account_info(creds_body, scopes=SCOPES)
gcal_map = cfg["calendars"]
try:
service = build("calendar", "v3", credentials=creds)
# list all events in the gcals
count = 0
for _, gcal in gcal_map.items():
page_token = None
while True:
batch = service.new_batch_http_request()
resp = service.events().list(calendarId=gcal, maxResults=BATCH_LIMIT, pageToken=page_token).execute()
for e in resp["items"]:
print(e.get("iCalUID"))
events = resp["items"]
print(events)
for e in events:
batch.add(service.events().delete(calendarId=gcal, eventId=e["id"]), callback=batch_exception_handler)
count += len(events)
batch.execute()
page_token = resp.get("nextPageToken")
if not page_token:
break
print(f"Removed {count} gcal events")
except HttpError as error:
print(f"An error occurred: {error}")