From 8267064bc1249c73ea160190cf7479b210c99daf Mon Sep 17 00:00:00 2001 From: "zahkcb@gmail.com" Date: Mon, 7 Sep 2026 11:52:41 +1000 Subject: [PATCH] initial push --- Dockerfile | 10 + README.md | 44 ++ STRATEGIES.md | 95 ++++ app/__init__.py | 0 app/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 114 bytes app/__pycache__/crawler.cpython-314.pyc | Bin 0 -> 30012 bytes app/__pycache__/db.cpython-314.pyc | Bin 0 -> 4464 bytes app/__pycache__/main.cpython-314.pyc | Bin 0 -> 62298 bytes app/__pycache__/strategies.cpython-314.pyc | Bin 0 -> 14231 bytes app/crawler.py | 287 +++++++++++ app/db.py | 93 ++++ app/main.py | 569 +++++++++++++++++++++ app/static/app.js | 132 +++++ app/static/event.js | 16 + app/static/source.js | 118 +++++ app/static/style.css | 1 + app/strategies.py | 164 ++++++ app/templates/base.html | 5 + app/templates/event.html | 22 + app/templates/event_form.html | 6 + app/templates/index.html | 31 ++ app/templates/source_events.html | 6 + app/templates/source_form.html | 27 + app/templates/sources.html | 6 + compose.yaml | 16 + requirements.txt | 7 + 26 files changed, 1655 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 STRATEGIES.md create mode 100644 app/__init__.py create mode 100644 app/__pycache__/__init__.cpython-314.pyc create mode 100644 app/__pycache__/crawler.cpython-314.pyc create mode 100644 app/__pycache__/db.cpython-314.pyc create mode 100644 app/__pycache__/main.cpython-314.pyc create mode 100644 app/__pycache__/strategies.cpython-314.pyc create mode 100644 app/crawler.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/static/app.js create mode 100644 app/static/event.js create mode 100644 app/static/source.js create mode 100644 app/static/style.css create mode 100644 app/strategies.py create mode 100644 app/templates/base.html create mode 100644 app/templates/event.html create mode 100644 app/templates/event_form.html create mode 100644 app/templates/index.html create mode 100644 app/templates/source_events.html create mode 100644 app/templates/source_form.html create mode 100644 app/templates/sources.html create mode 100644 compose.yaml create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dfa7562 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +COPY README.md . +RUN mkdir -p /data +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb6775b --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# What2Do + +A lightweight, local-first event aggregator. It crawls configured venue pages with CSS selectors or schema.org JSON-LD, normalizes events, and provides date/location/radius search. + +No LLM, ML, computer vision, browser automation, JavaScript build process, PostGIS, Redis, or external database is required. + +## Run + +```sh +docker compose up --build +``` + +Open http://localhost:8000. The source admin is at http://localhost:8000/sources. + +Two example sources are installed automatically: + +- Dicey Riley's recurring weekly specials +- La La La's dated event list + +Click **Crawl now** beside each source after startup. Crawls then run on each source's configured schedule while the app is running. + +## Extraction model + +Rules are relative to a repeating event container: + +- Event container, title, date, time, link, description, and location are CSS selectors. +- Date parsing accepts a Python `strptime` format or flexible text parsing. +- `json-ld` as the event container reads schema.org `Event` entries without CSS rules. +- Weekly pages can create the next 12 occurrences from weekday labels. +- The source's venue address is the fallback when event HTML omits a location. + +Use **Test extraction** to preview up to 20 records before saving. Static server-rendered HTML is intentionally used; sites that only render events with JavaScript are not supported in this low-overhead version. + +## Location search + +Addresses are geocoded and cached using OpenStreetMap's public Nominatim service. The search field also accepts `latitude,longitude` directly, and every source has optional coordinate fields for deterministic/offline venue positioning. Set a real contact in `NOMINATIM_USER_AGENT` before public/shared use and follow the [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/). For heavier use, configure or self-host a compatible geocoder. + +## Operational notes + +- SQLite data is persisted in the `what2do-data` Docker volume. +- Fetches are limited to 5 MB and public HTTP(S) destinations to reduce SSRF risk. +- The crawler does not bypass authentication or anti-bot systems. +- Review each site's terms and `robots.txt`; only crawl sources you are permitted to index. +- The MVP has no authentication. Bind it to localhost or place it behind an authenticated reverse proxy before exposing crawler administration to a network. diff --git a/STRATEGIES.md b/STRATEGIES.md new file mode 100644 index 0000000..d7ea29f --- /dev/null +++ b/STRATEGIES.md @@ -0,0 +1,95 @@ +# Generic extraction strategies + +Sources persist a JSON strategy. Runtime behavior is determined only by this configuration—there are no hostname-specific parsers. + +## HTML + +```json +{ + "mode": "html", + "request_url": "https://example.org/events", + "method": "GET", + "item_selector": ".event-card", + "title_selector": ".title", + "date_selector": ".date", + "time_selector": ".time", + "link_selector": "a.details", + "link_attribute": "href", + "description_selector": ".summary", + "location_selector": ".venue", + "date_format": "" +} +``` + +Selectors may read attributes using `title_attribute`, `date_attribute`, `time_attribute`, `link_attribute`, `description_attribute`, and `location_attribute`. Use `:scope` to read an attribute from the event container itself. + +## JSON API + +```json +{ + "mode": "json", + "request_url": "https://example.org/api/sessions", + "method": "GET", + "items_path": "results.events", + "title_path": "name", + "date_path": "startDate", + "end_path": "endDate", + "description_path": "description", + "location_path": "venue.address", + "link_path": "url", + "disabled_path": "disabled" +} +``` + +Paths use dot notation. Numeric path components index arrays. + +### Joining metadata + +```json +{ + "mode": "json", + "request_url": "https://example.org/api/sessions", + "items_path": "", + "date_path": "utcDate", + "link_path": "link", + "join_item_key": "movieId", + "join_title_path": "name", + "join_description_path": "summary", + "join_duration_path": "duration", + "join": { + "request_url": "https://example.org/api/movies", + "method": "GET", + "items_path": "", + "key_path": "id" + } +} +``` + +## JSON containing HTML fragments + +```json +{ + "mode": "html_fragment", + "request_url": "https://example.org/ajax", + "method": "POST", + "body": {"action": "events", "venue": "123"}, + "html_path": "html", + "item_selector": ".event-card", + "title_selector": ".title", + "date_selector": ".date", + "link_selector": "a", + "link_attribute": "href", + "page_parameter": "paged", + "page_start": 1, + "max_pages": 10, + "total_pages_path": "pages_total" +} +``` + +## Safety limits + +- Request URLs still pass public-network SSRF validation. +- Redirect destinations are validated for normal HTML fetches. +- Responses default to a 5 MB limit. +- Pagination is capped at 20 pages. +- Browser-compatible headers are global; strategy headers may add endpoint-specific `Accept` or `Referer` values. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__pycache__/__init__.cpython-314.pyc b/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8fb6cdfa929480ffd01475ff7e6a3704242b2ea7 GIT binary patch literal 114 zcmdPq_I|p@<2{{|u76#h+MEpdTNf pnU`4-AFo$Xd5gm)H$SB`C)KWq6(|ofp%}#Y#LURZSi}ru0RZ+T6deEn literal 0 HcmV?d00001 diff --git a/app/__pycache__/crawler.cpython-314.pyc b/app/__pycache__/crawler.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..102872f1d57de802b20441677f94e117d4cb97f6 GIT binary patch literal 30012 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 literal 0 HcmV?d00001 diff --git a/app/__pycache__/db.cpython-314.pyc b/app/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cd34e51903e2a341a4a739d3317e2617c4fc0fc GIT binary patch literal 4464 zcmbtY-EZ606~EMnB~iBH&&D5V#LmrV!CK;ctljEngDf+#5?j`2*|D2+94+41j3rVb zsrX|U;0^;Cr`_CP1!`;qy2tFXdstuklI$OF5Cb*X7AUs8^v#iY%62l(J6pG`4Be1j2w-OqkWJ=BS5HWyA)Z@W5RD6x zdRCefJ3PGE>cMmINEF8Sfo)aZW!@BO3K$aFnLpI71W?Q1bE6l7)bc$^Z}^O{^TWK}udf`Xa{ zLm!vhIuSTlQ8c{fsN1lH^JPtwm3d(a?>ruq#h~v2YgT#UaCN&J(V@ zCN32O86rlzM;u;O$_Dss#aZ_)y7-bMyddg^V6DZH;Ov`-v*@}Z8fDx2TpHFi=eBLF z7_QM>>AJpqg47hxbD5lwOOJDtx#W13jvpFp7#!a?D%9+N6l!A8Js!_p;Xq-EbHlb@ zmg3Ar26i;YL4c={ld0s06@QRhu$3Jk33xAz9L@6I{p1=S@=m%(W}Qe9HAAnM)HMu| zj1|e9AmhXG!2gp0EL#fuG7wR@AWITfp6wk~-_S8u_MO5#>%QwH!t>fUbqcb|&Wru- z-6ojY`-#{1O*%8e&D7;+Sy~os-x0uT9m~V5+q8a{R`tH4q*zIxI8H@`MF6L(ia4lP z%wshV#YV`B`2}p1jJsv5IsR2wGLDhOC0Q>O#Fc85Af_spNUHg!QI~hht@R2*+Xufr zov% zegP!ATBsV{1E9n-rMUX0scfrP5*hKnimVvwMbp$WTBBm{&bII8t-ud!zVono-rt89 zl%ukTa>;1O|2&eZ@A_pb9>~RgW~d};B%PUIyevbWRTo#EbAU-5{>a3m8ZoaK8X2}= zjoDzYHG{=PN!HAeezyPx_o5jBRHQ)3HT}H0WJWc0Nw_VN3cO;5@G?{u1Dg$bwOEu5 zGng-^I);q|N(4Chq&c133d5KYu~a%I&7Cc+XaN3@)eJ*NFG2GKIK$tgC*BRcb$M-M zClG$~`Wx3j4s<>VbZ$xyb6bJV?ZAakBP|=T2eHkLr;*cZ!=HAZdhh11Z>|mhBC``{ zc=OsD*S1cu+kyUFUm)1N)7Zp7yXZ4^h4j{yTk|l@Z0+cytCGCD>phs2~^ov_6sh=wEUN> zse5j@K3(-2@O0P9s_eY$r7dU*qz|I9?@AP*yRAHx=&AB8`mTdTBj_|L`AQV;oA!Ml zb}6+Xbc)2#L6ZGP>JC&mj;-4dx`WgHBXu`aBtsM&yt`19gAbFxOO735h!6LH6=Lu! zQF2X>K?8_&OfnB1cM8eC^*m%5@<&$LkrI!rc02K45R3!x<+j>y=i9g$AjsHkuuIkI z50hg#0Nr-VwON?t$Ow~~7@Hc;99_^>d-iK`fPLI-^5EXptF^i+=)Xpphx=J508p~{ zU%2|IEPStU?nlHVm-C|R%LHl*>f9X&YVw6qd06tz!Y0@Sqga5KhM|r?4uNvcCZI{M zMQkjn5=>p?au$PLQWYHw#!9KWu2!H2u(JuzFu{89I-Bs06RZcXvkA|T6s5`of@+XE zkyA5FupYF|COkt>asWaHT?bG-D)-@Z5L^%4ofFvj+}Xc9Ajb6~ayC&#1xe9+nXx0!(&fRZ!zcU9}_v3KalW^DXpW6;I zJA)TKq(7jyhNia%Z#?aqc`|t8Vf*Iv`@NgJ(7oTZ)itvdZQs1S9qr#~Y~HxGer@aI z%y#4K&iRWU_I%K@_3F&_`Prx4uRS?G`>=2G_WJ{y1JJ$Syw&~MPPFF_XSbt+?z*3B zH;N?Z)9dsu3U<8mB??A{zG_8u`+E1+f9;z2`kxJ`=O*=46et_r_q+eC_runI6Hn2> zk6KeG{HWr$u0(^^(Rac*J6VM0?O8+m_*1A>PUb`~&UVK!dnNdJ39dKSfcWpvWhv z`7?CpGt~7t-MA5d5PxT4>�}>6h0+yMgb~fwhKRgF?QRfB0ysea{cQUDErqtC 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 literal 0 HcmV?d00001 diff --git a/app/__pycache__/strategies.cpython-314.pyc b/app/__pycache__/strategies.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b692db4b11b8c1521c87af40724fb2dc9294623d GIT binary patch literal 14231 zcmbtbYj70TmF}MId1>C7(Gv*?@h~!ya6s5H#ys?5goKbs!wBpN@}OzKz-Tn-X@Nw* zOg2t3kljK!R1gWfh&3BTnUs}Os-jeRD>!y4B;_OxEl1jp92_V6BUSqY%J$;&?vFj^ z_H_3kByehP)!bL#=eg(JbG~!$tZ_Q>C~)8Z@%IirQ%O;O!G!9VY~Eh(SCzqsK7YW#`qb5@v{O;(rla2ZxT!-ZfrCAErNx_O>I_xo{$G|I$&bsm z-<^5nIZc!i~;$9LjhH^eg6=chm1PUQlM81^<=;c&(>ECd|oz=8#iE>=B zZ!kGhZGd=0;Dp!!CtJ~va&aL7Ma)Bd{Lo-5fu&y@OmJ<9{y~U48o9`j5bGa`cMlFF ztIe{jF|ezryCu-;Z`sz|66lumn)t}kcni-D^0KpKSl}al0vByL!X*UR-ODK%y@yhR ziQafr_Vgwre2VMELG<#G!~srr^d9B7!|@T4my*3lBJmjcoE+L8kM;G!!1`#I2>#F+ z;D<~3@e1(LREXMVXn2*PqL5ccy+9wN19TX^C~hqh!tgh&Bb#_m7~*k;Y&6yiNv6QqwVDjp_mON(n^Y5W| zF-U@sA)6v&#RxhUeq^lgK_pFu)j7bZ!#F15>a(#Yvgs)FQAUN0*(8*$&(>Q`Euq3D zHNVTE#`JI*6{Mb{)s_wrngILdv`|Tf$riynIc?#*=DGC-6BUKt%BTuzEj45)rN%#IiqX~dF~e6+06*l#Qn5rzh$Q+r9?8PvbY<TQ4UQx^g+W|9x_5Op zbA5wRB?H#L2ktJBg7hq+l!Xnv~7~v}*laM_Cku)`D_KdE)=$kPwy=`7P=PDXA ze*2_oS$fyN81v^`-ZRHe9~)bFsdlV(yk2sx7A>nko3j_36K0Cm+$vfl+G;+bXk-4Y z%X8X#mx0*lDO}dqi?=bMFr#Xk_d%Y* zab&uLtB8k_*I3I*d+$-;U0hEBz6d_{A#GKM>_J#-GcX5igL#V{g2f6_NhU;hX-ub9 zrE(a;O|`yEQAsw$^h1kfRLC#{=iqWGto0RQ!pO$NJ;bUoW~jr2&1#7dt1@d%shS7v zGwmCI-E!O181A7qfk4=1mrJj&#(Emn($L~QN*k?}S_(CmQ^4HXrqJ1(Ff!yLBL`uA z<-p81N1d5vx~|$H8&k=6OyEnPH<^vcQi9CH;1tUXA%`jqoY$6!a>FD}*j;80a6+|_ zUkY{jWmwT1<@zH-aX~is4e>mlg4Vvlfg~KM2_c1xwrbU?%1VXPt>jol_TbmT7s3Ev zhe(>bV{^^Ad>Q+!)0^HtTTpWT#j`JtGvDpaSZ8hdr?<>{OHX&s*$PGvToh((D{k9X z%r2?AvVMHz_08`uS@&AeUmZnb<}1D#$Ew?oRkMZV=MSGfJYFpoZaBUDL#%qwOBJrX z>!a-6bmwQE*rl70tgaoL=gDc{vmcomKEX zh2+Om*+SRcL+|4HyBJ(og0~ow`S3>4rtuq6fBr@a0HcIJrZntPWlWIT=?+l~ZL1Ki zGCyoQ=D|~#v|~{7qGiaAxf`Mjd1Ian8Q|p3N(Z%N7!A{p>@(TYKvbhl z9W!lAf!OC{x-y7IEgV3xWHl@2DTM9?rsS7H6Z{(RWDBrzuK*GoO0WqyqWLf3w{`f< zBt&@loWhd;8Cc<8Q!hEZ`R6bfeyJD0L+17Vhs!ftP(EHfT~K{0I3c`w^4iI%VQH;D z-FnC7fsc30?m1Wf=+oynp4~VWo5+{EHIl1VwA9YpJZH9?+A_9&Jet`e+17~c8fD4w z6vH1nBo*Pj%@BE<3ILp!B?_E-Oid2y)*5noPZ9hhcw8UgI;#zeI9m-pJc{#>L@C_; z$azlMho`mgqHq_3>+2_dKio)i9%ef{r^p=s3r3gDRSoDa%%A6U5uPL72@_QSdoEZ* zW5%2s&@!Ha0fWj;NhU|pVAR}2+XNO<8FC>etj~@*$%d`!1Qr>cQSIyD(XG|gm89{p z?U=q$JElcOVFpTMxg%)~8MsO!d(7H4EhgJkX!cEX(h{V)94ZC%aBtJzAoP1wJBmy@>3}6<3FJMKJwB4wkaZ!+sjf@~ z6~>dDI4Fsy>pZ89ER5oaxJBk*3)ofKsdXC8!N?T#6%`iZ0uJ@-K{{lG9p{v7JogpQ z`2^JMWd=MRhbNl`IN{)6RA#of1Z5Lwfsuif%fJ5vMfifK5DVq;+5jdez z)ePT^by*Htpkza-u)v2mx^s|@1Tx7)t%kil{}XML7d_$x#2#Ji>5031{3|U z14BF)txUy^ah37dKuoBsw(F-Nk6MUq97-lRp6IqbGPZ2a&Omnakr?0^01~~R1q(wd znTc}=5dMP+0VF<;=Le4pcXgg@>WjxP?|IC_ql@2$UL$%q3BCorR`ke3Eb4eu*Z|QR zD$yy2ucbFT1|Cmcat^HrMg;{q&yhKJCVnbD_Ppe*o^iH{&en-NZ-%dhr&da}Ta}C( zjZ)tBbkiJLG{#J`WwVZgGvQO=vDM=(lHDD<%!RW#B@w4&q2C1-S zrf{8DxNg!QIo87`n|IWIzT<4ic%kH5J>#nreRUI!l5K6e8P5Lm;j>|}YTaa`RPao? zbJlFTZ7#ahIk&axM)Nz?(Wl3DpWAY(aDtsk+_F4B>uf$(Av&AK_goEM4o_A~D>u!o zY!p{E-Y`HeC_t`)Gq0R_WvqX4mH0yF-IG5P#PG}Fp|50Kkz9P*Kg-%@*z#L!`7~QG zXU@ws%-ELPvMrI!WfxQ94Ocf^-ZW{O+AA&JE>-xa&Hg!Ou~^bBIXgs4$E?kp-u1A6 z!8Im_^FE0DeF7#&*s!|dtRt;lwRkOyayp9=4hjQt!WyzEH8{irp2E!uy3uk76k&wN z5^uxYN5BYVr&KL?D3MAKp@Mg@=`IG>>q+pIj|A_oc=wg}8$+bP!ve|&O%NJXndb>8 zgb(VW$>{U#Vxh)7w~Wex28J$8DCorkUr0=;M+g8FdcjsS(Um=xVf3J1A+3n(Sr;(o zQod(JeRj0VsX8iY1f6Pu<94yW0w#6->bTrgi=p*H06M0y23vX}3y##R*IuN3i(aB0 zR{&hn{MdG_daXs;&C^S0?OGNoYg1>okbvsaM2Ipv^zj5o2Y?a)^-Sh~3N z(JeAAk6uEhOL&Re10akC_QhlS%hy|5q(5)Kr_NLDPfb6z^@7K?UZ|H)TMrbe%8$AN z6;%6=6~1br+kXcXE!XC2u~UKKkj+l5W4h3=G|#DA-1Dy5I^pC$1s6VbO4J^cuAoa> z_m#NrL8qEN0KG%n5r%xlh1E|~P~)0F=`&e1vqmijVbZD7Z12M7NzRHaGRg&3Bc5~Kvk#-kY<79sDF?(h2iJ&v+(K$OfkF$erl?#LUfhEr%>IbK; zUj>}s?SD~tCS)Xrq)Ji%yl|Bo%e9D>V~zi%bR$yPF&m2TI%2)#WeZx@j{(A#nccxA z*%1@CfnL?XamNxg=R)KF~TpvZD)u!^7TacTpx8;b( z5{LCtPLk9=vj=&uU-rZY`yvlEm#<}Mg~4)RBy*Ropi=Wf3WhA(a=Il%lg*K66cpqX z50*trc1MT6aE~J$h$X;olmdgRS&f?b7?j};VL>yOh+>QUVay^5cpPHYJ{}P{k6MAu zBnFT2W=x`Cga<$a;6$MSv31BsT+I}s077lvNlL)|0W?k&G#>ejN0owq8og>P;MjG9 zz$DLO)WqRc3g95@lFUQ4z>N#JEs>c)xKm*g z+)*VD*B4L*0X1mt0piIF3<)lMveZGlX5uiJyKGTOPmInwydW4u#~GepWdyzb{NO+@ z(Nezw10!nYFF`#4z+o#M^NpR5Y_;j;xxD-{HK%LN4Ue0y=3UO4*eF##JyW?wtlR?N zqiCy?w=LZ?Yj%C>_}7k)?vl(aCalxuFU`3s#@UJ8|28lgl3d$F%eGmEE8RNl_MNw# zwT<~NbzJC}sF=~Iy-DnPS==9$nmEbZpRvrAEW1>8p-f!$tW>gb zrev#FvUO_zO!4+x#oPZ-+;+;JX%a*GW-4n_wApFCT~TPcY#IVo)Ur`h7JQrh*K716w|3ewm3d&}JVq z2xe0fKxUFTiQl%dO)NCl$27B$YGpfFDA@}35GaWOWOlQC7;x-iEZNG&F$ggO7@RaD zSmXrwM{x zrWZ_5GHwiDFwAzFpofZ}>Axw;v)go!p-P|r$nKjfT{AH}acGL3T0K=N)-=!5?6_64 zL#pYx*)Ns8c?hn07&Tl%qNvvEadDmwwV931GG(L2FpW)t0%CqI}D$1Up z-uc-lF3Pjy;!e@^$8TN=#zvS)duA@{n5`ZDrATDIm=dCIwsz!JK`T>|?s-u&pkVyek zV3{=cL+vuE9Y9P^Fb7hbRRsnuk1CjI2NM#x5d-tWHM$c_2za|h?zMD!Ra*$1`ETK; z40@-)1IdOLT>LSNvp{FCXQ7X>5hMiMT&d&pMflanj1^i3>({ow1aj~U8W ze9sIldN+@IN*eq{gv?Gwblq7`cSFE<7vdIWPrj zCYF$4!5a(By8f`yA)py}RE|#?!Ej>=v0!?kTVJQBLDwR)({7~I5oa&ovTzHe71RW7 z$hohcKS$+pNjs==`n6sZ^iFb5jTE2fbP%MU2H4Ajvo`9~SNm1xsD@zWHx0T21tC|+ z9irDW0W-YwvBc?Ivrx#q4Bi>3HF7BE#c@_Fw)WOQ-uzyZ7JdArRm~WrwvM+9DmAl) zly^brK>j1H=e3YgJ^2WLej|vZvupM3LAzQ%s@$7XrV zhDa5+Iw~~`YlaB`v7TJMoL4hnt)YckIXkbMSNopj`G9W$08F?4AS*Xa1OTH-KwQ|T z#^wQFy8Y)m0IsCMi`9VB2v?i9r=b5RIShY&=;r4BrQz%!5!q1OW*K;2j>vOms6 z$yeE-=>okVj5_-?Z34<7iUJO&KK>JUo0v4!ZSK-`He_X82%ZoHsC5Q?uO$r&mkb!P zWtp6^Oh~`F2e6lWb>D3SBO0UfC+t)Pv;mfbvk*=PHv4b@OnPNhw}~qtx!Ti!D4d87 zv}Nk~lG+k!g`BM+kmErb@2X{^PA=IqBbFYWh*I5 zNWBz3De~QfnOv0M&a8w7@t#hnl?x_ge~bfoS@{6|L%T)YCPa?$)aE);A_)zqcrd9bH&eR+U_)LJJ&WA9dDZu zrhWA{giOn)~l_is_Zm(XBWCUNw>6o8C zi=PGbke2)idZ=G2)5{~a8s)h~8gp?bvK7F+@&-eudzE>i=Enascqu$lSM@788|rP~ zwr*9H_CZXKYsz1R(nK9`!tVMUK)oGa7mW>HI)35!Lf5%}*Y{2eQ+sXoh zg2Ej)7^$=w0pOo~C1WeU(~+^>u_0W%-ZW`_qjRcND)pZ&9QFR$TRc`Oc~{JM{i4@D zVR_Sj%`R@(F0J-2iWMI~Z`0x4TQ75Be@c2;kitWsP}Fe+yfqsLYHK%iA);$Gynx`X z+YmxP-vdzYE+BG4AL6?nLllE<1BbzJx*tm>0qjFb!7zd)j~SX7_$C-yFh~N}hayGW z5zkxw%)K>~r|@n~772RhBf`|caoA5n_;uIC^rKn(ocQJSugX__sY<|D4kz^^lv^E5&07y$2{tnpb4{Fg1YH^w= zS(UX(SiS{I(m8KV;F(y!K;Ds55lMhVBd#Z3)ym*4jakhNu?s!=?Wt5{20ji8xSs|2 z7lmuc+zZeEyzqoe4^6{{XVONv_FQ;`+M620psHRJeP!MV3$+mfK3(Ygv_3oRTz#_V zn+wv!44P|}`Z);1s%w;WRM@Lg!K!N{YAtP)L}CW5JLx*O)?2VItKL`0v|x!my@YyA zOwdM-PPjyNrlxk!Ec!}a%R*J(++e!UUWbYrj)h_KBaI$G<=57|_X zKrP~tYvTltVF(AVC+iD*qqv@r7Nm_s=U$)^Zg6l(m(HWQt{c)Ww{#9ox_8=j{kG?$ z9-~HKcPOuGnbKaaAJX(=c)Rqt(K+>%3up)==~;x@+W0i?0X-eGHn)~m^feFXBl)(R z_4BCf+wd0bHaH_t!wu!Z4g?;k(U30C#@2p2t5?HUko^vLhKc_%7$N_Pkl6uzYS|4_ zmK|ta2RnP@0N&pmE8m?+!Na3L9{x}PPnMuoAKq|OVug*Bo$$&YZ7=7brlLQ87u&Fa zhOB7KdF0KMatlD-L}419_9norqx)Gfm;~aNdD`g80xNKt_f;`@=~48lf!%$)+Sb! zH!#+P-dXg12wqnIS6@TP_N+3iXi(q6VnkcojIl}det_QV;K>%1nzD_MBYT@-%b7W` zeLkQj5nV~ql-%4f+>62AKh#CzVYw+HR%oCT=t-{fvHnYkE*zSukd{6xxi-RkB*n5n zW*Rq1wkO~Z-glU7qPJ$+T!U|umNZQH zrc7eV7Rk9)v}~RA6r^`(O-i#Jo_N)6znL!;bY$}83M$9*f3k7v(0eOp*6+H#e%Iv}#oayP-pI^e?$%yV zNruFuBhubupgx?Sx1rJa1l@u<1qP7W3YrN70YebgjuZ553<^VrOdh$anX@@F!)K14 zIzHAhu?&oT%2kltd#-8Bf_Py3rO9PdalPc*Fv&~qjp?>INB-!ue{hs%XhM~VD6zNC z=9iwYJzM*owHfoQ%{97Z+Ez7JR5@PsX3e#l$*Zdd>{^-lwEt$6RK4@2AQc8PwpmvZ4p%Iz znQ_(LcGdp$r5i8ZZV8JKZaNkh2Zp8C$aG|cP@~cCq0N)td6%KAj#2ubtf$#~MU->t z-D09bDG21-$Z!7&UiA{P8)O#$ZKb>ygrh-$0uxBvp2*h$d@g6LmY$uS`ic> zGy7BZ* zr3@C%H0XE}oFYSUZZrQ5Btc None: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise CrawlError("Only absolute http:// and https:// URLs are allowed.") + try: + infos = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)) + except socket.gaierror as exc: + raise CrawlError(f"Host cannot be resolved: {exc}") from exc + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if not ip.is_global: + raise CrawlError("URLs resolving to private, loopback, link-local, or reserved addresses are blocked.") + +def _http_client(timeout: int = 20) -> httpx.Client: + return httpx.Client(follow_redirects=True, timeout=timeout, headers=BROWSER_HEADERS) + +def fetch_html(url: str) -> str: + validate_public_url(url) + with _http_client() as client: + response = client.get(url) + response.raise_for_status() + validate_public_url(str(response.url)) + content_type = response.headers.get("content-type", "") + if "html" not in content_type.lower(): + raise CrawlError(f"Expected HTML but received {content_type or 'an unknown content type'}.") + if len(response.content) > 5_000_000: + raise CrawlError("Page exceeds the 5 MB crawl limit.") + return response.text + +def _text(node, selector: str) -> str: + if not selector: + return "" + found = node.select_one(selector) + return found.get_text(" ", strip=True) if found else "" + +def _link(node, selector: str, base_url: str) -> str: + found = node.select_one(selector) if selector else None + target = found or node + for attribute in ("href", "data-permalink", "data-url", "data-href"): + value = target.get(attribute) + if value: + return urljoin(base_url, str(value)) + return base_url + +def _parse_date_range(value: str, source: dict, now: datetime) -> tuple[datetime, datetime | None]: + value = " ".join(value.replace("–", "-").replace("—", "-").split()) + same_month = re.fullmatch(r"(\d{1,2})\s*-\s*(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})(?:\s+(.+))?", value) + cross_month = re.fullmatch(r"(\d{1,2})\s+([A-Za-z]+)\s*-\s*(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})(?:\s+(.+))?", value) + if same_month: + start_day, end_day, month, year, time_text = same_month.groups() + suffix = f" {time_text}" if time_text else "" + start = _parse_date(f"{start_day} {month} {year}{suffix}", source, now) + end = _parse_date(f"{end_day} {month} {year}{suffix}", source, now) + return start, end + if cross_month: + start_day, start_month, end_day, end_month, year, time_text = cross_month.groups() + suffix = f" {time_text}" if time_text else "" + start = _parse_date(f"{start_day} {start_month} {year}{suffix}", source, now) + end = _parse_date(f"{end_day} {end_month} {year}{suffix}", source, now) + return start, end + return _parse_date(value, source, now), None + +def _parse_date(value: str, source: dict, now: datetime) -> datetime: + value = " ".join(value.split()) + tz = ZoneInfo(source.get("timezone") or "UTC") + fmt = source.get("date_format", "").strip() + if fmt: + candidates = [fmt] + # Event lists commonly omit a year. Resolve to the next occurrence. + for candidate in candidates: + parsed = datetime.strptime(value, candidate).replace(tzinfo=tz) + if "%Y" not in candidate and "%y" not in candidate: + parsed = parsed.replace(year=now.year) + if parsed < now - timedelta(days=2): + parsed = parsed.replace(year=now.year + 1) + return parsed + if not re.search(r"\b\d{1,4}\b", value): + raise ValueError("Date text must contain a numeric day or year") + parsed = date_parser.parse(value, fuzzy=True, default=now.replace(tzinfo=None), tzinfos={"INFO": None, "IPAC": None, "IMAX": None}) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=tz) + if not now.year - 1 <= parsed.year <= now.year + 10: + raise ValueError("Parsed year is outside the plausible event range") + return parsed.replace(microsecond=0) + +def _extract_json_ld(soup: BeautifulSoup, base_url: str, source: dict, now: datetime) -> list[ExtractedEvent]: + result = [] + def walk(value): + if isinstance(value, list): + for item in value: yield from walk(item) + elif isinstance(value, dict): + if value.get("@type") == "Event" or "Event" in (value.get("@type") or []): + yield value + for key in ("@graph", "itemListElement"): + if key in value: yield from walk(value[key]) + for script in soup.select('script[type="application/ld+json"]'): + try: + data = json.loads(script.string or script.get_text()) + except (json.JSONDecodeError, TypeError): + continue + for event in walk(data): + location = event.get("location") or {} + address = location.get("address", "") if isinstance(location, dict) else "" + if isinstance(address, dict): + address = ", ".join(str(address.get(k, "")) for k in ("streetAddress", "addressLocality", "addressRegion", "postalCode") if address.get(k)) + try: + start = _parse_date(str(event["startDate"]), source, now) + end = _parse_date(str(event["endDate"]), source, now) if event.get("endDate") else None + except (KeyError, ValueError, OverflowError): + continue + result.append(ExtractedEvent(title=str(event.get("name", "Untitled event")), starts_at=start, ends_at=end, description=str(event.get("description", "")), url=urljoin(base_url, str(event.get("url", base_url))), address=str(address))) + return result + + +def _weekly_dates(day_text: str, now: datetime, weeks: int = 12) -> list[datetime]: + names = {name.lower(): i for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))} + match = next(((name, idx) for name, idx in names.items() if name in day_text.lower()), None) + if not match: + return [] + delta = (match[1] - now.weekday()) % 7 + first = (now + timedelta(days=delta)).replace(hour=0, minute=0, second=0, microsecond=0) + return [first + timedelta(weeks=i) for i in range(weeks)] + +def _selector_for(node) -> str: + if node.get("id"): + return f"#{node['id']}" + classes = [value for value in node.get("class", []) if not value.startswith(("elementor-element-", "post-"))] + return node.name + "".join(f".{value}" for value in classes[:2]) + +def detect_rules(html: str, url: str, timezone: str = "Australia/Sydney") -> tuple[dict, list[ExtractedEvent], str]: + soup = BeautifulSoup(html, "html.parser") + now = datetime.now(ZoneInfo(timezone)) + json_source = {"url": url, "timezone": timezone, "item_selector": "json-ld", "title_selector": "", "date_selector": "", "time_selector": "", "link_selector": "", "description_selector": "", "location_selector": "", "date_format": "", "recurring_weekly": 0} + json_events = extract_events(html, json_source) + if json_events: + return json_source, json_events, "Found schema.org JSON-LD Event data." + + date_pattern = re.compile(r"(?:\b(?:mon|tue|wed|thu|fri|sat|sun)(?:day)?\b|\b\d{1,2}[\s/.-](?:\d{1,2}|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{1,2})", re.I) + candidates = [] + container_selectors = ("article", "li", ".event", ".events", "[class*='event']", "[class*='Event']", ".event-list__item", ".item", "[class*='item']", ".wpupg-item", ".wpupg-item.wpupg-type-event", "[data-permalink]", ".wp-block-post", ".elementor-loop-item", ".tribe-events-calendar-list__event-row", ".em-event", ".mec-event-article") + for selector in container_selectors: + try: nodes = soup.select(selector) + except Exception: continue + if not 2 <= len(nodes) <= 300: + continue + nodes = [node for node in nodes if not any(marker in " ".join(str(value) for value in (node.get("class") or [])).casefold() for marker in SOCIAL_WIDGET_MARKERS)] + if len(nodes) < 2: continue + dated = [node for node in nodes if date_pattern.search(node.get_text(" ", strip=True))] + titled = [node for node in dated if node.find(["h1", "h2", "h3", "h4", "a"]) or node.select_one("[class*='title'], [itemprop='name']")] + score = len(titled) * 3 + len(dated) - max(0, len(nodes) - len(dated)) + if len(titled) >= 2: + candidates.append((score, selector, titled)) + if not candidates: + raise CrawlError("No repeated event-like HTML containers were detected. Try JSON-LD or configure CSS selectors manually.") + trials = [] + for _, item_selector, nodes in sorted(candidates, reverse=True)[:8]: + title_selectors = [] + date_selectors = [] + link_selectors = [] + for sample in nodes[:8]: + for node in sample.select("h1, h2, h3, h4, a, [class*='title'], [itemprop='name']"): + selector = _selector_for(node) + if selector not in title_selectors: title_selectors.append(selector) + for node in sample.find_all(["time", "span", "div", "li", "p", "ul"]): + if date_pattern.search(node.get_text(" ", strip=True)): + selector = _selector_for(node) + if selector not in date_selectors: date_selectors.append(selector) + for node in sample.find_all("a"): + selector = _selector_for(node) + if selector not in link_selectors: link_selectors.append(selector) + if any(sample.get(attribute) for attribute in ("data-permalink", "data-url", "data-href")) and ":scope" not in link_selectors: + link_selectors.insert(0, ":scope") + time_selectors = [] + for sample in nodes[:8]: + for node in sample.find_all(["time", "span", "div", "p"]): + if re.search(r"\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b", node.get_text(" ", strip=True), re.IGNORECASE): + selector = _selector_for(node) + if selector not in time_selectors: time_selectors.append(selector) + for title_selector in title_selectors[:6]: + for date_selector in date_selectors[:10]: + for time_selector in [""] + time_selectors[:4]: + for recurring in (0, 1): + rules = {"url": url, "timezone": timezone, "item_selector": item_selector, "title_selector": title_selector, "date_selector": date_selector, "time_selector": time_selector, "link_selector": link_selectors[0] if link_selectors else "", "description_selector": "", "location_selector": "", "date_format": "", "recurring_weekly": recurring} + events = extract_events(html, rules) + unique_titles = len({event.title for event in events}) + explicit_dates = sum(1 for event in events if event.starts_at.second == 0 and event.starts_at.microsecond == 0 and now.year - 1 <= event.starts_at.year <= now.year + 10) + if events and unique_titles >= 2 and explicit_dates == len(events): + occurrence_penalty = 12 if recurring else 1 + trials.append((unique_titles * 10 + len(events) / occurrence_penalty, rules, events, len(nodes))) + if not trials: + raise CrawlError("Event-like containers were found, but no selector combination produced parseable events. Adjust selectors manually.") + _, rules, events, container_count = max(trials, key=lambda value: value[0]) + winning_nodes = soup.select(rules["item_selector"]) + sample = winning_nodes[0] if winning_nodes else None + if sample: + description_node = sample.find(["p", "summary"]) + location_node = next((node for node in sample.find_all(["span", "div", "p"]) if re.search(r"\b(?:venue|location|address|theatre|hall|hotel|club)\b", node.get_text(" ", strip=True), re.I)), None) + rules["description_selector"] = _selector_for(description_node) if description_node else "" + rules["location_selector"] = _selector_for(location_node) if location_node else "" + return rules, events, f"Guessed CSS selectors from {container_count} repeated event-like containers." + +def extract_events(html: str, source: dict) -> list[ExtractedEvent]: + soup = BeautifulSoup(html, "html.parser") + tz = ZoneInfo(source.get("timezone") or "UTC") + now = datetime.now(tz) + item_selector = source.get("item_selector", "").strip() + if not item_selector or item_selector == "json-ld": + json_events = _extract_json_ld(soup, source["url"], source, now) + if json_events or item_selector == "json-ld": + return json_events + events = [] + for item in soup.select(item_selector): + title = _text(item, source.get("title_selector", "")) + raw_date = _text(item, source.get("date_selector", "")) + raw_time = _text(item, source.get("time_selector", "")) + if not title or not raw_date: + continue + description = _text(item, source.get("description_selector", "")) + address = _text(item, source.get("location_selector", "")) + link = _link(item, source.get("link_selector", ""), source["url"]) + if source.get("recurring_weekly"): + for start in _weekly_dates(raw_date, now): + events.append(ExtractedEvent(title=title, starts_at=start, description=description, url=link, address=address)) + continue + try: + start, end = _parse_date_range(f"{raw_date} {raw_time}".strip(), source, now) + except (ValueError, OverflowError): + continue + events.append(ExtractedEvent(title=title, starts_at=start, ends_at=end, description=description, url=link, address=address)) + return events + +def infer_page_fields(html: str, url: str) -> dict: + soup = BeautifulSoup(html, "html.parser") + title = "" + og_site = soup.select_one('meta[property="og:site_name"]') + if og_site: + content = og_site.get("content") + title = str(content).strip() if content else "" + if not title and soup.title: + title = re.split(r"[|–—-]", soup.title.get_text(" ", strip=True))[0].strip() + heading = soup.find("h1") + source_name = heading.get_text(" ", strip=True) if heading else title or urlparse(url).hostname or "Event source" + text = soup.get_text(" ", strip=True) + address_match = re.search(r"\b\d{1,5}[A-Za-z/-]*\s+[A-Za-z][A-Za-z .'-]+(?:Street|St|Road|Rd|Lane|Ln|Avenue|Ave|Drive|Dr|Way|Place|Pl),?\s+[A-Za-z ]+\s+(?:NSW|VIC|QLD|SA|WA|TAS|NT|ACT)\s+\d{4}\b", text, re.I) + address = address_match.group(0).strip() if address_match else "" + return {"name": source_name, "venue_name": title or source_name, "venue_address": address} + +def external_key(event: ExtractedEvent) -> str: + value = f"{event.title.casefold()}|{event.starts_at.isoformat()}|{event.address.casefold()}" + return hashlib.sha256(value.encode()).hexdigest() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..a96841a --- /dev/null +++ b/app/db.py @@ -0,0 +1,93 @@ +import os +import sqlite3 +from contextlib import contextmanager +from pathlib import Path + +DB_PATH = os.getenv("DATABASE_PATH", "data/what2do.db") + +SCHEMA = """ +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; +CREATE TABLE IF NOT EXISTS sources ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + venue_name TEXT NOT NULL, + venue_address TEXT NOT NULL, + latitude REAL, + longitude REAL, + timezone TEXT NOT NULL DEFAULT 'Australia/Sydney', + strategy_json TEXT NOT NULL DEFAULT '{}', + item_selector TEXT NOT NULL, + title_selector TEXT NOT NULL, + date_selector TEXT NOT NULL, + time_selector TEXT NOT NULL DEFAULT '', + link_selector TEXT NOT NULL DEFAULT '', + description_selector TEXT NOT NULL DEFAULT '', + location_selector TEXT NOT NULL DEFAULT '', + date_format TEXT NOT NULL DEFAULT '', + recurring_weekly INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + crawl_interval_minutes INTEGER NOT NULL DEFAULT 360, + last_crawled_at TEXT, + last_status TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE, + external_key TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + starts_at TEXT NOT NULL, + ends_at TEXT, + venue_name TEXT NOT NULL, + address TEXT NOT NULL, + latitude REAL, + longitude REAL, + event_url TEXT NOT NULL, + hidden INTEGER NOT NULL DEFAULT 0, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(source_id, external_key) +); +CREATE INDEX IF NOT EXISTS idx_events_starts_at ON events(starts_at); +CREATE INDEX IF NOT EXISTS idx_events_source ON events(source_id); +CREATE TABLE IF NOT EXISTS geocode_cache ( + query TEXT PRIMARY KEY, + latitude REAL, + longitude REAL, + display_name TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +""" + +@contextmanager +def connect(): + path = Path(DB_PATH) + path.parent.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(path) + db.row_factory = sqlite3.Row + db.execute("PRAGMA foreign_keys=ON") + try: + yield db + db.commit() + finally: + db.close() + +def init_db(): + with connect() as db: + db.executescript(SCHEMA) + event_columns = {row["name"] for row in db.execute("PRAGMA table_info(events)")} + if "hidden" not in event_columns: + db.execute("ALTER TABLE events ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0") + source_columns = {row["name"] for row in db.execute("PRAGMA table_info(sources)")} + if "strategy_json" not in source_columns: + db.execute("ALTER TABLE sources ADD COLUMN strategy_json TEXT NOT NULL DEFAULT '{}'") + db.execute("""UPDATE sources SET strategy_json=json_object( + 'mode','html','request_url',url,'method','GET','response_type','html', + 'item_selector',item_selector,'title_selector',title_selector,'date_selector',date_selector, + 'time_selector',time_selector,'link_selector',link_selector,'description_selector',description_selector, + 'location_selector',location_selector,'date_format',date_format,'recurring_weekly',recurring_weekly + ) WHERE strategy_json='{}' OR strategy_json=''""") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..710d281 --- /dev/null +++ b/app/main.py @@ -0,0 +1,569 @@ +import asyncio +import json +import math +import os +import secrets +import threading +from calendar import Calendar +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from urllib.parse import urljoin + +import httpx +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from bs4 import BeautifulSoup + +from .crawler import CrawlError, _http_client, detect_rules, external_key, fetch_html, infer_page_fields +from .db import connect, init_db +from .strategies import execute_strategy, parse_strategy + +TEMPLATES = Jinja2Templates(directory="app/templates") +NOMINATIM_USER_AGENT = os.getenv("NOMINATIM_USER_AGENT", "what2do-local/0.1") +SCHEDULER_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", "60")) +PICKER_SNAPSHOTS = {} +PICKER_LOCK = threading.Lock() +PICKER_TTL = timedelta(minutes=15) +EVENT_TAGS = { + "music": ("music", "band", "dj", "album", "tour", "concert", "punk", "emo", "rock", "singer"), + "art": ("art", "gallery", "exhibition", "paint", "creative", "workshop"), + "comedy": ("comedy", "comedian", "stand-up", "standup"), + "trivia": ("trivia", "quiz"), + "film": ("film", "movie", "cinema", "screening"), + "food": ("food", "dinner", "lunch", "brunch", "schnitty", "steak", "surf & turf"), + "sports": ("sport", "football", "rugby", "darts", "poker", "race"), + "community": ("community", "market", "fundraiser", "family", "festival"), + "nightlife": ("party", "club", "night", "bingo", "jackpot"), +} + +SEEDS = [ + { + "name": "Dicey Riley's weekly specials", "url": "https://www.diceyrileyshotel.com.au/pub-specials/", + "venue_name": "Dicey Riley's Hotel", "venue_address": "329-333 Crown St, Wollongong NSW 2500, Australia", + "latitude": -34.42538, "longitude": 150.89375, + "item_selector": "article.weekly_specials", "title_selector": "h2.elementor-heading-title", + "date_selector": ".elementor-post-info__item--type-custom", "time_selector": "", "link_selector": "a", + "date_format": "", "recurring_weekly": 1, + }, + { + "name": "La La La's events", "url": "https://www.lalalas.com.au/all-events/", + "venue_name": "La La La's", "venue_address": "3 Globe Lane, Wollongong NSW 2500, Australia", + "latitude": -34.42569, "longitude": 150.89816, + "item_selector": ".slider_container > .items", "title_selector": ".feed_titles h2", + "date_selector": ".feed_dates ul", "time_selector": "", "link_selector": "a.slider_button", + "date_format": "%d %B %H:%M", "recurring_weekly": 0, + }, +] + +def source_values(form) -> dict: + raw_strategy = form.get("strategy_json", "{}").strip() or "{}" + try: strategy = json.loads(raw_strategy) + except json.JSONDecodeError as exc: raise ValueError(f"Strategy JSON is invalid: {exc.msg} at line {exc.lineno}") from exc + if strategy.get("mode", "html") == "html": + strategy.update({"mode": "html", "request_url": form.get("url", "").strip(), "method": strategy.get("method", "GET"), "response_type": "html", "item_selector": form.get("item_selector", "").strip(), "title_selector": form.get("title_selector", "").strip(), "date_selector": form.get("date_selector", "").strip(), "time_selector": form.get("time_selector", "").strip(), "link_selector": form.get("link_selector", "").strip(), "description_selector": form.get("description_selector", "").strip(), "location_selector": form.get("location_selector", "").strip(), "date_format": form.get("date_format", "").strip(), "recurring_weekly": 1 if form.get("recurring_weekly") else 0}) + return { + "name": form.get("name", "").strip(), "url": form.get("url", "").strip(), + "venue_name": form.get("venue_name", "").strip(), "venue_address": form.get("venue_address", "").strip(), + "latitude": float(form["latitude"]) if form.get("latitude") else None, + "longitude": float(form["longitude"]) if form.get("longitude") else None, + "timezone": form.get("timezone", "Australia/Sydney").strip(), "item_selector": form.get("item_selector", "").strip(), + "title_selector": form.get("title_selector", "").strip(), "date_selector": form.get("date_selector", "").strip(), + "time_selector": form.get("time_selector", "").strip(), "link_selector": form.get("link_selector", "").strip(), + "description_selector": form.get("description_selector", "").strip(), "location_selector": form.get("location_selector", "").strip(), + "date_format": form.get("date_format", "").strip(), "recurring_weekly": 1 if form.get("recurring_weekly") else 0, + "strategy_json": json.dumps(strategy), + "enabled": 1 if form.get("enabled") else 0, "crawl_interval_minutes": int(form.get("crawl_interval_minutes") or 360), + } + +def seed_sources(): + with connect() as db: + for source in SEEDS: + source_strategy = {"mode": "html", "request_url": source["url"], "method": "GET", "response_type": "html", "item_selector": source["item_selector"], "title_selector": source["title_selector"], "date_selector": source["date_selector"], "time_selector": source.get("time_selector", ""), "link_selector": source.get("link_selector", ""), "link_attribute": "href", "description_selector": "", "location_selector": "", "date_format": source.get("date_format", ""), "recurring_weekly": source.get("recurring_weekly", 0)} + fields = {**source, "strategy_json": json.dumps(source_strategy), "timezone": "Australia/Sydney", "time_selector": source.get("time_selector", ""), "link_selector": source.get("link_selector", ""), "description_selector": "", "location_selector": "", "enabled": 1, "crawl_interval_minutes": 360} + columns = ",".join(fields) + db.execute(f"INSERT OR IGNORE INTO sources ({columns}) VALUES ({','.join('?' for _ in fields)})", tuple(fields.values())) + db.execute("UPDATE sources SET latitude=COALESCE(latitude,?),longitude=COALESCE(longitude,?) WHERE url=?", (source.get("latitude"), source.get("longitude"), source["url"])) + db.execute("UPDATE events SET latitude=COALESCE(latitude,?),longitude=COALESCE(longitude,?) WHERE source_id=(SELECT id FROM sources WHERE url=?)", (source.get("latitude"), source.get("longitude"), source["url"])) + +def geocode(query: str): + if not query: + return None + try: + latitude, longitude = (float(value.strip()) for value in query.split(",", 1)) + if -90 <= latitude <= 90 and -180 <= longitude <= 180: + return latitude, longitude, query + except (ValueError, TypeError): + pass + cache_key = query.casefold() + with connect() as db: + cached = db.execute("SELECT latitude, longitude, display_name FROM geocode_cache WHERE query=?", (cache_key,)).fetchone() + if cached: + return tuple(cached) + parts = [part.strip() for part in query.split(",") if part.strip()] + candidates = [query] + if len(parts) > 1: + candidates.append(", ".join(parts[1:])) + result = [] + try: + for candidate in candidates: + response = httpx.get("https://nominatim.openstreetmap.org/search", params={"q": candidate, "format": "jsonv2", "limit": 1, "countrycodes": "au"}, headers={"User-Agent": NOMINATIM_USER_AGENT}, timeout=15) + response.raise_for_status() + result = response.json() + if result: + break + except (httpx.HTTPError, ValueError): + return None + if not result: + return None + value = (float(result[0]["lat"]), float(result[0]["lon"]), result[0].get("display_name", query)) + with connect() as db: + db.execute("INSERT OR REPLACE INTO geocode_cache(query,latitude,longitude,display_name) VALUES(?,?,?,?)", (cache_key, *value)) + return value + +def crawl_source(source_id: int): + with connect() as db: + row = db.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone() + if not row: + raise CrawlError("Source not found.") + source = dict(row) + try: + with _http_client(timeout=45) as client: + events = execute_strategy(source, client) + coords = (source["latitude"], source["longitude"]) + if None in coords: + point = geocode(source["venue_address"]) + coords = point[:2] if point else (None, None) + seen = [] + with connect() as db: + db.execute("UPDATE sources SET latitude=?,longitude=? WHERE id=?", (*coords, source_id)) + for event in events: + key = external_key(event) + seen.append(key) + address = event.address or source["venue_address"] + db.execute("""INSERT INTO events(source_id,external_key,title,description,starts_at,ends_at,venue_name,address,latitude,longitude,event_url) + VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(source_id,external_key) DO UPDATE SET title=excluded.title,description=excluded.description, + starts_at=excluded.starts_at,ends_at=excluded.ends_at,venue_name=excluded.venue_name,address=excluded.address,latitude=excluded.latitude,longitude=excluded.longitude,event_url=excluded.event_url,last_seen_at=CURRENT_TIMESTAMP""", + (source_id, key, event.title, event.description, event.starts_at.astimezone(timezone.utc).isoformat(), event.ends_at.astimezone(timezone.utc).isoformat() if event.ends_at else None, source["venue_name"], address, *coords, event.url)) + if seen: + placeholders = ",".join("?" for _ in seen) + db.execute(f"DELETE FROM events WHERE source_id=? AND external_key NOT IN ({placeholders})", (source_id, *seen)) + db.execute("UPDATE sources SET last_crawled_at=CURRENT_TIMESTAMP,last_status=?,last_error=NULL WHERE id=?", (f"Found {len(events)} events", source_id)) + return len(events) + except Exception as exc: + with connect() as db: + db.execute("UPDATE sources SET last_crawled_at=CURRENT_TIMESTAMP,last_status='Failed',last_error=? WHERE id=?", (str(exc)[:1000], source_id)) + raise + +async def scheduler(): + while True: + await asyncio.sleep(max(1, SCHEDULER_INTERVAL) * 60) + with connect() as db: + rows = db.execute("SELECT id,last_crawled_at,crawl_interval_minutes FROM sources WHERE enabled=1").fetchall() + now = datetime.now(timezone.utc) + for row in rows: + last = datetime.fromisoformat(row["last_crawled_at"] + "+00:00") if row["last_crawled_at"] else None + if not last or now - last >= timedelta(minutes=row["crawl_interval_minutes"]): + try: await asyncio.to_thread(crawl_source, row["id"]) + except Exception: pass + +@asynccontextmanager +async def lifespan(app): + init_db(); seed_sources() + task = asyncio.create_task(scheduler()) + yield + task.cancel() + +app = FastAPI(title="What2Do", lifespan=lifespan) +app.mount("/static", StaticFiles(directory="app/static"), name="static") + +def classify_event(title: str, description: str) -> list[str]: + text = f"{title} {description}".casefold() + tags = [tag for tag, keywords in EVENT_TAGS.items() if any(keyword in text for keyword in keywords)] + return tags or ["other"] + +def distance_km(lat1, lon1, lat2, lon2): + p1, p2 = math.radians(lat1), math.radians(lat2) + dp, dl = math.radians(lat2-lat1), math.radians(lon2-lon1) + a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2 + return 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + +@app.get("/", response_class=HTMLResponse) +def index(request: Request, location: str = "", latitude: float | None = None, longitude: float | None = None, start: str = "", end: str = "", radius: float = 25, tag: list[str] = Query(default=[]), term: str = ""): + point = (latitude, longitude, location) if latitude is not None and longitude is not None else (geocode(location) if location else None) + local_now = datetime.now().astimezone() + default_start = local_now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if default_start.month == 12: + default_end = default_start.replace(year=default_start.year + 1, month=1) + else: + default_end = default_start.replace(month=default_start.month + 1) + if not start: + start = default_start.strftime("%Y-%m-%dT%H:%M") + if not end: + end = default_end.strftime("%Y-%m-%dT%H:%M") + try: start_at = datetime.fromisoformat(start).astimezone(timezone.utc) + except ValueError: + start_at = default_start.astimezone(timezone.utc) + start = default_start.strftime("%Y-%m-%dT%H:%M") + try: end_at = datetime.fromisoformat(end).astimezone(timezone.utc) + except ValueError: + end_at = default_end.astimezone(timezone.utc) + end = default_end.strftime("%Y-%m-%dT%H:%M") + with connect() as db: + rows = [dict(r) for r in db.execute("SELECT events.*,sources.name source_name FROM events JOIN sources ON sources.id=events.source_id WHERE events.hidden=0 AND starts_at < ? AND COALESCE(ends_at,starts_at) >= ? ORDER BY starts_at", (end_at.isoformat(), start_at.isoformat())).fetchall()] + for event in rows: + event["distance"] = distance_km(point[0], point[1], event["latitude"], event["longitude"]) if point and event["latitude"] is not None else None + local_start = datetime.fromisoformat(event["starts_at"]).astimezone() + event["display_time"] = local_start.strftime("%a %d %b %Y, %H:%M") + if event["ends_at"]: + local_end = datetime.fromisoformat(event["ends_at"]).astimezone() + event["display_time"] += f" – {local_end.strftime('%a %d %b %Y, %H:%M')}" + event["day_key"] = local_start.strftime("%Y-%m-%d") + event["end_day_key"] = datetime.fromisoformat(event["ends_at"]).astimezone().strftime("%Y-%m-%d") if event["ends_at"] else event["day_key"] + event["tags"] = classify_event(event["title"], event["description"]) + if point: rows = [event for event in rows if event["distance"] is not None and event["distance"] <= radius] + selected_tags = [value for value in tag if value in EVENT_TAGS or value == "other"] + if selected_tags: rows = [event for event in rows if set(selected_tags).intersection(event["tags"])] + if term: + needle = term.casefold() + rows = [event for event in rows if needle in f"{event['title']} {event['description']} {event['venue_name']} {event['address']}".casefold()] + calendar_start = start_at.astimezone() + month_days = list(Calendar(firstweekday=0).itermonthdates(calendar_start.year, calendar_start.month)) + day_counts = {} + for event in rows: + covered_day = datetime.fromisoformat(event["day_key"]).date() + final_day = datetime.fromisoformat(event["end_day_key"]).date() + while covered_day <= final_day: + key = covered_day.strftime("%Y-%m-%d") + day_counts[key] = day_counts.get(key, 0) + 1 + covered_day += timedelta(days=1) + calendar_days = [{"date": day, "key": day.strftime("%Y-%m-%d"), "count": day_counts.get(day.strftime("%Y-%m-%d"), 0), "in_month": day.month == calendar_start.month} for day in month_days] + map_events = [{"id": event["id"], "title": event["title"], "venue": event["venue_name"], "time": event["display_time"], "latitude": event["latitude"], "longitude": event["longitude"]} for event in rows if event["latitude"] is not None] + context = {"events": rows, "map_events": map_events, "calendar_days": calendar_days, "calendar_month": calendar_start.strftime("%B %Y"), "event_tags": EVENT_TAGS.keys(), "location": location, "latitude": latitude, "longitude": longitude, "start": start, "end": end, "radius": radius, "selected_tags": selected_tags, "term": term, "geocoded": point} + return TEMPLATES.TemplateResponse(request, "index.html", context) + +@app.get("/api/locations") +def location_suggestions(q: str = ""): + query = q.strip() + if len(query) < 3: + return [] + try: + response = httpx.get("https://photon.komoot.io/api/", params={"q": query, "limit": 10, "lang": "en"}, headers={"User-Agent": "what2do-local/0.1"}, timeout=10) + response.raise_for_status() + suggestions = [] + for feature in response.json().get("features", []): + properties = feature.get("properties", {}) + if properties.get("countrycode", "").upper() != "AU": + continue + coordinates = feature.get("geometry", {}).get("coordinates", []) + if len(coordinates) < 2: + continue + place = properties.get("name") or properties.get("city") or properties.get("district") or properties.get("county") + label = ", ".join(dict.fromkeys(value for value in (place, properties.get("city"), properties.get("state"), properties.get("postcode"), properties.get("country")) if value)) + suggestion = {"label": label, "latitude": float(coordinates[1]), "longitude": float(coordinates[0])} + if label and suggestion not in suggestions: + suggestions.append(suggestion) + return suggestions[:5] + except (httpx.HTTPError, ValueError, KeyError, TypeError): + return [] + +@app.get("/api/addresses") +def address_suggestions(q: str = ""): + query = q.strip() + if len(query) < 3: + return [] + try: + response = httpx.get("https://photon.komoot.io/api/", params={"q": query, "limit": 8, "lang": "en"}, headers={"User-Agent": "what2do-local/0.1"}, timeout=10) + response.raise_for_status() + results = [] + for feature in response.json().get("features", []): + properties = feature.get("properties", {}) + if properties.get("countrycode", "").upper() != "AU": continue + coordinates = feature.get("geometry", {}).get("coordinates", []) + if len(coordinates) < 2: continue + street = " ".join(value for value in (properties.get("housenumber"), properties.get("street")) if value) + label = ", ".join(dict.fromkeys(value for value in (properties.get("name"), street, properties.get("city"), properties.get("state"), properties.get("postcode"), properties.get("country")) if value)) + item = {"label": label, "latitude": float(coordinates[1]), "longitude": float(coordinates[0])} + if label and item not in results: results.append(item) + return results[:5] + except (httpx.HTTPError, ValueError, KeyError, TypeError): + return [] + +@app.get("/events/{event_id}", response_class=HTMLResponse) +def event_detail(request: Request, event_id: int): + with connect() as db: + row = db.execute("SELECT events.*,sources.name source_name,sources.url source_url FROM events JOIN sources ON sources.id=events.source_id WHERE events.id=? AND events.hidden=0", (event_id,)).fetchone() + if not row: + raise HTTPException(404) + event = dict(row) + local_start = datetime.fromisoformat(event["starts_at"]).astimezone() + event["display_date"] = local_start.strftime("%A, %d %B %Y") + event["display_time"] = local_start.strftime("%H:%M") + event["display_end"] = None + if event["ends_at"]: + local_end = datetime.fromisoformat(event["ends_at"]).astimezone() + event["display_end"] = local_end.strftime("%A, %d %B %Y at %H:%M") + event["canonical_url"] = str(request.url) + return TEMPLATES.TemplateResponse(request, "event.html", {"event": event}) + +@app.get("/sources", response_class=HTMLResponse) +def sources(request: Request): + with connect() as db: rows = db.execute("SELECT *, (SELECT count(*) FROM events WHERE source_id=sources.id) event_count FROM sources ORDER BY name").fetchall() + return TEMPLATES.TemplateResponse(request, "sources.html", {"sources": rows}) + +@app.get("/sources/new", response_class=HTMLResponse) +def new_source(request: Request): + return TEMPLATES.TemplateResponse(request, "source_form.html", {"source": {"enabled": 1, "timezone": "Australia/Sydney", "crawl_interval_minutes": 360, "strategy_json": "{}"}, "action": "/sources", "preview": None}) + +@app.get("/sources/{source_id}", response_class=HTMLResponse) +def edit_source(request: Request, source_id: int): + with connect() as db: source = db.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone() + if not source: raise HTTPException(404) + return TEMPLATES.TemplateResponse(request, "source_form.html", {"source": {**dict(source), "strategy_json": json.dumps(parse_strategy(source["strategy_json"]), indent=2)}, "action": f"/sources/{source_id}", "preview": None}) + +@app.post("/sources") +async def create_source(request: Request): + form = await request.form(); values = source_values(form) + with connect() as db: + columns = ",".join(values); cursor = db.execute(f"INSERT INTO sources ({columns}) VALUES ({','.join('?' for _ in values)})", tuple(values.values())) + return RedirectResponse(f"/sources/{cursor.lastrowid}", 303) + +@app.post("/sources/{source_id}") +async def update_source(request: Request, source_id: int): + form = await request.form(); values = source_values(form) + with connect() as db: db.execute(f"UPDATE sources SET {','.join(f'{key}=?' for key in values)} WHERE id=?", (*values.values(), source_id)) + return RedirectResponse(f"/sources/{source_id}", 303) + +@app.get("/sources/{source_id}/events", response_class=HTMLResponse) +def source_events(request: Request, source_id: int): + with connect() as db: + source = db.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone() + events = db.execute("SELECT * FROM events WHERE source_id=? ORDER BY starts_at", (source_id,)).fetchall() + if not source: raise HTTPException(404) + return TEMPLATES.TemplateResponse(request, "source_events.html", {"source": source, "events": events}) + +@app.get("/sources/{source_id}/events/{event_id}", response_class=HTMLResponse) +def edit_source_event(request: Request, source_id: int, event_id: int): + with connect() as db: + source = db.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone() + event = db.execute("SELECT * FROM events WHERE id=? AND source_id=?", (event_id, source_id)).fetchone() + if not source or not event: raise HTTPException(404) + return TEMPLATES.TemplateResponse(request, "event_form.html", {"source": source, "event": event}) + +@app.post("/sources/{source_id}/events/{event_id}") +async def update_source_event(request: Request, source_id: int, event_id: int): + form = await request.form() + values = (form.get("title", "").strip(), form.get("description", "").strip(), form.get("starts_at", "").strip(), form.get("ends_at", "").strip() or None, form.get("venue_name", "").strip(), form.get("address", "").strip(), float(form["latitude"]) if form.get("latitude") else None, float(form["longitude"]) if form.get("longitude") else None, form.get("event_url", "").strip(), event_id, source_id) + with connect() as db: + db.execute("UPDATE events SET title=?,description=?,starts_at=?,ends_at=?,venue_name=?,address=?,latitude=?,longitude=?,event_url=? WHERE id=? AND source_id=?", values) + return RedirectResponse(f"/sources/{source_id}/events", 303) + +@app.post("/sources/{source_id}/events/{event_id}/visibility") +def toggle_event_visibility(source_id: int, event_id: int): + with connect() as db: + db.execute("UPDATE events SET hidden=CASE hidden WHEN 1 THEN 0 ELSE 1 END WHERE id=? AND source_id=?", (event_id, source_id)) + return RedirectResponse(f"/sources/{source_id}/events", 303) + +@app.post("/sources/{source_id}/crawl") +def run_crawl(source_id: int): + try: crawl_source(source_id) + except Exception: pass + return RedirectResponse("/sources", 303) + +@app.post("/sources/{source_id}/delete") +def delete_source(source_id: int): + with connect() as db: db.execute("DELETE FROM sources WHERE id=?", (source_id,)) + return RedirectResponse("/sources", 303) + +def sanitize_picker_html(html: str, source_url: str) -> str: + soup = BeautifulSoup(html, "html.parser") + for tag in soup.select("script, iframe, frame, frameset, object, embed, form, input, button, textarea, select, base, meta[http-equiv]"): + tag.decompose() + for node in soup.find_all(True): + for attribute in list(node.attrs): + lowered = attribute.lower() + if lowered.startswith("on") or lowered in {"srcdoc", "integrity", "nonce", "formaction"}: + del node.attrs[attribute] + for attribute in ("src", "href", "poster", "action"): + value = node.get(attribute) + if not value: continue + value = str(value).strip() + if value.lower().startswith(("javascript:", "data:text/html", "vbscript:")): + del node.attrs[attribute] + elif attribute in {"src", "poster"}: + node.attrs[attribute] = urljoin(source_url, value) + elif attribute == "href" and node.name == "link" and "stylesheet" in (node.get("rel") or []): + node.attrs[attribute] = urljoin(source_url, value) + elif attribute in {"href", "action"}: + node.attrs[attribute] = "#" + node.attrs.pop("target", None) + head = soup.head + if not head: + head = soup.new_tag("head") + if soup.html: soup.html.insert(0, head) + else: soup.insert(0, head) + style = soup.new_tag("style") + style.string = "html,body{min-height:100%;background:white;color:#17211b} body{margin:12px} *{cursor:crosshair!important} img{max-width:100%;height:auto} .w2d-hover{outline:3px solid #dd5b32!important;outline-offset:2px!important}.w2d-picked{outline:4px solid #234c3c!important;outline-offset:2px!important}" + head.append(style) + script = soup.new_tag("script") + script.string = PICKER_SCRIPT + soup.body.append(script) if soup.body else soup.append(script) + return str(soup) + +PICKER_SCRIPT = r'''(() => { + let hovered = null, picked = null; + const esc = value => CSS.escape(value); + function stableClasses(element) { + return [...element.classList].filter(name => !name.startsWith('w2d-') && !/^elementor-element-[a-f0-9]+$/.test(name) && !/^post-\d+$/.test(name)).slice(0, 3); + } + function idPattern(id) { + const match = id.match(/^(.*?[-_])\d{3,}$/); + return match && match[1].length >= 3 ? `[id^="${CSS.escape(match[1])}"]` : `#${esc(id)}`; + } + function simpleSelector(element) { + if (element.id) return idPattern(element.id); + const classes = stableClasses(element); + return element.tagName.toLowerCase() + classes.map(name => `.${esc(name)}`).join(''); + } + function selectorFor(element) { + if (element.id) return idPattern(element.id); + const parts = []; + let node = element; + while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== 'html') { + let part = node.tagName.toLowerCase(); + const classes = stableClasses(node).slice(0, 2); + if (classes.length) part += classes.map(name => `.${esc(name)}`).join(''); + const parent = node.parentElement; + if (parent && parent.querySelectorAll(`:scope > ${part}`).length > 1) { + const siblings = [...parent.children].filter(child => child.matches(part)); + part += `:nth-of-type(${[...parent.children].indexOf(node) + 1})`; + } + parts.unshift(part); + const candidate = parts.join(' > '); + try { if (document.querySelectorAll(candidate).length === 1 || parts.length >= 4) return candidate; } catch (_) {} + node = parent; + } + return parts.join(' > '); + } + function relativeSelector(element, container) { + if (element === container) return ':scope'; + const parts = []; + let node = element; + while (node && node !== container) { + let part = simpleSelector(node); + if (!node.id) { + const parent = node.parentElement; + if (parent) { + const matches = [...parent.children].filter(child => child.matches(part)); + if (matches.length > 1) part += `:nth-of-type(${[...parent.children].indexOf(node) + 1})`; + } + } + parts.unshift(part); node = node.parentElement; + } + return parts.join(' > '); + } + function repeatedAncestor(element) { + let node = element; + let best = null; + let depth = 0; + while (node && node.tagName && node.tagName.toLowerCase() !== 'body') { + const selector = simpleSelector(node); + let count = 0; + try { count = document.querySelectorAll(selector).length; } catch (_) {} + if (count >= 2 && count <= 300) { + const text = node.textContent.trim(); + const hasUsefulText = text.length >= 10; + const score = (hasUsefulText ? 30 : 0) + Math.min(count, 20) - depth * 4 - (node.querySelectorAll('*').length > 300 ? 20 : 0); + if (!best || score > best.score) best = { node, selector, count, score }; + } + node = node.parentElement; depth += 1; + } + return best; + } + document.addEventListener('mouseover', event => { + event.preventDefault(); event.stopPropagation(); + if (hovered) hovered.classList.remove('w2d-hover'); + hovered = event.target; hovered.classList.add('w2d-hover'); + }, true); + document.addEventListener('mouseout', event => { if (hovered) hovered.classList.remove('w2d-hover'); }, true); + document.addEventListener('click', event => { + event.preventDefault(); event.stopPropagation(); + if (picked) picked.classList.remove('w2d-picked'); + picked = event.target; picked.classList.add('w2d-picked'); + const selector = selectorFor(picked); + const repeated = repeatedAncestor(picked); + parent.postMessage({ type: 'what2do-selector', selector, selectorCount: document.querySelectorAll(selector).length, generalizedSelector: simpleSelector(picked), generalizedCount: document.querySelectorAll(simpleSelector(picked)).length, containerSelector: repeated?.selector || '', containerCount: repeated?.count || 0, relativeSelector: repeated ? relativeSelector(picked, repeated.node) : selector, tag: picked.tagName.toLowerCase(), text: picked.textContent.trim().slice(0, 160), href: picked.getAttribute('href') }, location.origin); + }, true); +})();''' + +@app.post("/picker/snapshots") +async def create_picker_snapshot(request: Request): + form = await request.form() + url = form.get("url", "").strip() + try: + sanitized = sanitize_picker_html(fetch_html(url), url) + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + token = secrets.token_urlsafe(24) + now = datetime.now(timezone.utc) + with PICKER_LOCK: + expired = [key for key, value in PICKER_SNAPSHOTS.items() if now - value["created_at"] > PICKER_TTL] + for key in expired: PICKER_SNAPSHOTS.pop(key, None) + PICKER_SNAPSHOTS[token] = {"html": sanitized, "created_at": now} + return {"preview_url": f"/picker/snapshots/{token}"} + +@app.get("/picker/snapshots/{token}", response_class=HTMLResponse) +def picker_snapshot(token: str): + with PICKER_LOCK: + snapshot = PICKER_SNAPSHOTS.get(token) + if not snapshot or datetime.now(timezone.utc) - snapshot["created_at"] > PICKER_TTL: + raise HTTPException(404, "Picker snapshot expired") + return HTMLResponse(snapshot["html"], headers={"Content-Security-Policy": "default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline' https:; font-src https: data:; script-src 'unsafe-inline'; connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}) + +@app.post("/auto-detect", response_class=HTMLResponse) +async def auto_detect(request: Request): + form = await request.form() + url = form.get("url", "").strip() + source = { + "id": form.get("source_id"), "name": form.get("name", ""), "url": url, + "venue_name": form.get("venue_name", ""), "venue_address": form.get("venue_address", ""), + "latitude": form.get("latitude") or None, "longitude": form.get("longitude") or None, + "timezone": form.get("timezone", "Australia/Sydney"), "enabled": 1, + "crawl_interval_minutes": form.get("crawl_interval_minutes", 360), + } + try: + html = fetch_html(url) + rules, items, message = detect_rules(html, url, source["timezone"]) + strategy = {"mode": "html", "request_url": url, "method": "GET", "response_type": "html", **rules, "link_attribute": "href"} + source["strategy_json"] = json.dumps(strategy, indent=2) + with _http_client(timeout=45) as client: + strategy_items = execute_strategy({**source, "strategy_json": source["strategy_json"]}, client) + if strategy_items: + items = strategy_items + inferred = infer_page_fields(html, url) + for key, value in inferred.items(): + if value and not source.get(key): source[key] = value + source.update(rules) + if source.get("venue_address") and not source.get("latitude"): + point = geocode(source["venue_address"]) + if point: source["latitude"], source["longitude"] = point[:2] + preview_data = [{"title": item.title, "starts_at": item.starts_at.isoformat(), "ends_at": item.ends_at.isoformat() if item.ends_at else None, "url": item.url} for item in items[:20]] + error = None + except Exception as exc: + source.update({"item_selector": "", "title_selector": "", "date_selector": "", "time_selector": "", "link_selector": "", "description_selector": "", "location_selector": "", "date_format": "", "recurring_weekly": 0, "strategy_json": "{}"}) + preview_data, message, error = [], None, str(exc) + action = f"/sources/{source['id']}" if source.get("id") else "/sources" + return TEMPLATES.TemplateResponse(request, "source_form.html", {"source": source, "action": action, "preview": preview_data, "preview_error": error, "detect_message": message}) + +@app.post("/preview", response_class=HTMLResponse) +async def preview(request: Request): + form = await request.form(); values = source_values(form); values["url"] = form.get("url", "") + try: + with _http_client(timeout=45) as client: + items = execute_strategy(values, client)[:20] + preview_data = [{"title": item.title, "starts_at": item.starts_at.isoformat(), "ends_at": item.ends_at.isoformat() if item.ends_at else None, "url": item.url} for item in items] + error = None + except Exception as exc: preview_data, error = [], str(exc) + source_id = form.get("source_id") + return TEMPLATES.TemplateResponse(request, "source_form.html", {"source": values, "action": f"/sources/{source_id}" if source_id else "/sources", "preview": preview_data, "preview_error": error}) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..9bacf62 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,132 @@ +const locationInput = document.querySelector('#location-input'); +const latitudeInput = document.querySelector('#latitude-input'); +const longitudeInput = document.querySelector('#longitude-input'); +const options = document.querySelector('#location-options'); +let locationTimer; + +if (locationInput) { + locationInput.addEventListener('input', () => { + latitudeInput.value = ''; + longitudeInput.value = ''; + clearTimeout(locationTimer); + const query = locationInput.value.trim(); + if (query.length < 3) { options.hidden = true; return; } + locationTimer = setTimeout(async () => { + try { + const response = await fetch(`/api/locations?q=${encodeURIComponent(query)}`); + const places = await response.json(); + options.replaceChildren(); + places.forEach(place => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = place.label; + button.addEventListener('click', () => { + locationInput.value = place.label; + latitudeInput.value = place.latitude; + longitudeInput.value = place.longitude; + options.hidden = true; + }); + options.append(button); + }); + if (places.length === 0) { + const message = document.createElement('div'); + message.className = 'location-message'; + message.textContent = 'No geographic locations found. Try a suburb, town, or postcode.'; + options.append(message); + } + options.hidden = false; + } catch (_) { options.hidden = true; } + }, 300); + }); + document.addEventListener('click', event => { if (!event.target.closest('.location-field')) options.hidden = true; }); +} + +const tagToggle = document.querySelector('#tag-toggle'); +const tagOptions = document.querySelector('#tag-options'); +if (tagToggle && tagOptions) { + tagToggle.addEventListener('click', () => { tagOptions.hidden = !tagOptions.hidden; }); + tagOptions.addEventListener('change', () => { + const count = tagOptions.querySelectorAll('input:checked').length; + tagToggle.textContent = count ? `${count} selected` : 'All types'; + }); + document.addEventListener('click', event => { + if (!event.target.closest('.tag-field')) tagOptions.hidden = true; + }); +} + +const tabs = document.querySelectorAll('.tab'); +let map; +tabs.forEach(tab => tab.addEventListener('click', () => { + tabs.forEach(item => item.classList.toggle('active', item === tab)); + document.querySelectorAll('.view-panel').forEach(panel => panel.classList.toggle('active', panel.id === tab.dataset.view)); + if (tab.dataset.view === 'map-view') initialiseMap(); +})); + +const eventRows = [...document.querySelectorAll('[data-event-start]')]; +const visibleCount = document.querySelector('#visible-event-count'); +const clearDay = document.querySelector('#clear-day'); +const dayEmpty = document.querySelector('#day-empty'); +const rangeStatus = document.querySelector('#range-status'); +let rangeStart = ''; +let rangeEnd = ''; + +function formatDay(dayKey) { + return new Date(`${dayKey}T00:00:00`).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); +} + +function filterToRange(startDay, endDay = startDay) { + let count = 0; + eventRows.forEach(row => { + row.hidden = Boolean(startDay) && (row.dataset.eventStart > endDay || row.dataset.eventEnd < startDay); + if (!row.hidden) count += 1; + }); + document.querySelectorAll('.calendar-day').forEach(day => { + const inRange = Boolean(startDay) && day.dataset.day >= startDay && day.dataset.day <= endDay; + day.classList.toggle('selected', inRange); + day.classList.toggle('range-start', day.dataset.day === startDay); + day.classList.toggle('range-end', day.dataset.day === endDay && endDay !== startDay); + }); + if (visibleCount) visibleCount.textContent = count; + if (clearDay) clearDay.hidden = !startDay; + if (dayEmpty) dayEmpty.hidden = count !== 0; +} + +document.querySelectorAll('.calendar-day').forEach(day => day.addEventListener('click', event => { + event.preventDefault(); + const clicked = day.dataset.day; + if (!rangeStart || rangeEnd) { + rangeStart = clicked; + rangeEnd = ''; + filterToRange(rangeStart); + if (rangeStatus) rangeStatus.textContent = `${formatDay(rangeStart)} selected · choose an end date`; + } else { + rangeEnd = clicked; + if (rangeEnd < rangeStart) [rangeStart, rangeEnd] = [rangeEnd, rangeStart]; + filterToRange(rangeStart, rangeEnd); + if (rangeStatus) rangeStatus.textContent = `${formatDay(rangeStart)} – ${formatDay(rangeEnd)}`; + document.querySelector('#event-list')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } +})); +if (clearDay) clearDay.addEventListener('click', event => { + event.preventDefault(); rangeStart = ''; rangeEnd = ''; filterToRange(''); + if (rangeStatus) rangeStatus.textContent = 'Select a start date'; +}); + +function initialiseMap() { + if (map || !window.maplibregl) return; + const events = JSON.parse(document.querySelector('#map-events').textContent); + const center = events.length ? [events[0].longitude, events[0].latitude] : [150.8931, -34.4278]; + map = new maplibregl.Map({ container: 'events-map', style: { version: 8, sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors' } }, layers: [{ id: 'osm', type: 'raster', source: 'osm' }] }, center, zoom: 12 }); + map.addControl(new maplibregl.NavigationControl(), 'top-right'); + const bounds = new maplibregl.LngLatBounds(); + events.forEach(event => { + const popup = new maplibregl.Popup({ offset: 20 }).setHTML(`${escapeHtml(event.title)}
${escapeHtml(event.venue)}
View event`); + new maplibregl.Marker({ color: '#dd5b32' }).setLngLat([event.longitude, event.latitude]).setPopup(popup).addTo(map); + bounds.extend([event.longitude, event.latitude]); + }); + if (events.length > 1) map.fitBounds(bounds, { padding: 60, maxZoom: 14 }); +} + +function escapeHtml(value) { + const node = document.createElement('div'); node.textContent = value; return node.innerHTML; +} diff --git a/app/static/event.js b/app/static/event.js new file mode 100644 index 0000000..e9add51 --- /dev/null +++ b/app/static/event.js @@ -0,0 +1,16 @@ +const mapNode = document.querySelector('#event-map'); +if (mapNode && window.maplibregl) { + const longitude = Number(mapNode.dataset.longitude); + const latitude = Number(mapNode.dataset.latitude); + const map = new maplibregl.Map({ container: mapNode, style: { version: 8, sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors' } }, layers: [{ id: 'osm', type: 'raster', source: 'osm' }] }, center: [longitude, latitude], zoom: 15 }); + map.addControl(new maplibregl.NavigationControl(), 'top-right'); + new maplibregl.Marker({ color: '#dd5b32' }).setLngLat([longitude, latitude]).setPopup(new maplibregl.Popup({ offset: 20 }).setText(mapNode.dataset.title)).addTo(map); +} + +const shareButton = document.querySelector('#share-button'); +if (shareButton) shareButton.addEventListener('click', async () => { + try { + if (navigator.share) await navigator.share({ title: document.title, url: location.href }); + else { await navigator.clipboard.writeText(location.href); shareButton.textContent = 'Link copied'; } + } catch (_) {} +}); diff --git a/app/static/source.js b/app/static/source.js new file mode 100644 index 0000000..613f70e --- /dev/null +++ b/app/static/source.js @@ -0,0 +1,118 @@ +const pickerFrame = document.querySelector('#selector-picker'); +const loadPicker = document.querySelector('#load-picker'); +const pickerStatus = document.querySelector('#picker-status'); +const pickerSelection = document.querySelector('#picker-selection'); +const pickedSelector = document.querySelector('#picked-selector'); +const pickedText = document.querySelector('#picked-text'); +const pickedCount = document.querySelector('#picked-count'); +const pickedContainer = document.querySelector('#picked-container'); +const pickedRelative = document.querySelector('#picked-relative'); +const pickerTarget = document.querySelector('#picker-target'); +const applySelector = document.querySelector('#apply-selector'); +const applyPattern = document.querySelector('#apply-pattern'); +let currentSelection = null; + +if (loadPicker && pickerFrame) loadPicker.addEventListener('click', async () => { + const url = document.querySelector('input[name="url"]')?.value.trim(); + if (!url) { pickerStatus.textContent = 'Enter a page URL first.'; return; } + loadPicker.disabled = true; pickerStatus.textContent = 'Downloading and sanitizing page…'; + try { + const body = new FormData(); body.append('url', url); + const response = await fetch('/picker/snapshots', { method: 'POST', body }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Unable to create preview'); + pickerFrame.src = result.preview_url; pickerFrame.hidden = false; + pickerStatus.textContent = 'Hover to highlight, then click an element.'; + } catch (error) { pickerStatus.textContent = error.message; } + finally { loadPicker.disabled = false; } +}); + +window.addEventListener('message', event => { + if (!pickerFrame || event.source !== pickerFrame.contentWindow || event.data?.type !== 'what2do-selector') return; + currentSelection = event.data; + pickedSelector.textContent = event.data.selector; + pickedCount.textContent = `${event.data.selectorCount} exact match${event.data.selectorCount === 1 ? '' : 'es'}; ${event.data.generalizedCount} similar elements`; + pickedContainer.textContent = event.data.containerSelector || 'No repeated ancestor detected'; + pickedRelative.textContent = event.data.containerSelector ? `${event.data.containerCount} containers · child: ${event.data.relativeSelector}` : ''; + pickedText.textContent = event.data.text || event.data.tag; + applyPattern.disabled = !event.data.containerSelector; + pickerSelection.hidden = false; +}); + +function updateSelectorField(target, selector) { + const input = document.querySelector(`[name="${target}"]`); + if (input) input.value = selector; + try { + const strategy = JSON.parse(strategyJson.value || '{}'); + if ((strategy.mode || 'html') === 'html') { + strategy[target] = selector; + strategyJson.value = JSON.stringify(strategy, null, 2); + } + } catch (_) {} +} + +if (applySelector) applySelector.addEventListener('click', () => { + if (!currentSelection) return; + updateSelectorField(pickerTarget.value, currentSelection.selector); + pickerStatus.textContent = `Applied exact selector to ${pickerTarget.options[pickerTarget.selectedIndex].text}.`; +}); + +if (applyPattern) applyPattern.addEventListener('click', () => { + if (!currentSelection?.containerSelector) return; + const target = pickerTarget.value; + updateSelectorField('item_selector', currentSelection.containerSelector); + updateSelectorField(target, target === 'item_selector' ? currentSelection.containerSelector : currentSelection.relativeSelector); + pickerStatus.textContent = `Matched ${currentSelection.containerCount} event containers and applied a relative ${pickerTarget.options[pickerTarget.selectedIndex].text.toLowerCase()} selector.`; +}); + +const strategyTemplates = { + html: { mode: 'html', request_url: 'https://example.org/events', method: 'GET', item_selector: '.event-card', title_selector: '.title', date_selector: '.date', time_selector: '.time', link_selector: 'a', link_attribute: 'href', description_selector: '.description', location_selector: '.venue', date_format: '' }, + json: { mode: 'json', request_url: 'https://example.org/api/events', method: 'GET', items_path: 'results', title_path: 'title', date_path: 'startDate', end_path: 'endDate', description_path: 'description', location_path: 'location.address', link_path: 'url', disabled_path: 'disabled' }, + json_join: { mode: 'json', request_url: 'https://example.org/api/sessions', method: 'GET', items_path: '', date_path: 'startDate', link_path: 'url', join_item_key: 'itemId', join_title_path: 'name', join_description_path: 'description', join_duration_path: 'duration', join: { request_url: 'https://example.org/api/items', method: 'GET', items_path: '', key_path: 'id' } }, + html_fragment: { mode: 'html_fragment', request_url: 'https://example.org/ajax', method: 'POST', body: { action: 'events' }, html_path: 'html', item_selector: '.event-card', title_selector: '.title', date_selector: '.date', link_selector: 'a', link_attribute: 'href', page_parameter: 'page', page_start: 1, max_pages: 10, total_pages_path: 'pages_total' } +}; +const strategyTemplate = document.querySelector('#strategy-template'); +const strategyJson = document.querySelector('#strategy-json'); +if (strategyTemplate && strategyJson) strategyTemplate.addEventListener('change', () => { + const template = strategyTemplates[strategyTemplate.value]; + if (template) strategyJson.value = JSON.stringify(template, null, 2); +}); + +const addressInput = document.querySelector('#venue-address'); +const addressOptions = document.querySelector('#address-options'); +const latitudeInput = document.querySelector('#venue-latitude'); +const longitudeInput = document.querySelector('#venue-longitude'); +let addressTimer; + +if (addressInput && addressOptions) { + addressInput.addEventListener('input', () => { + clearTimeout(addressTimer); + latitudeInput.value = ''; + longitudeInput.value = ''; + const query = addressInput.value.trim(); + if (query.length < 3) { addressOptions.hidden = true; return; } + addressTimer = setTimeout(async () => { + try { + const response = await fetch(`/api/addresses?q=${encodeURIComponent(query)}`); + const addresses = await response.json(); + addressOptions.replaceChildren(); + addresses.forEach(address => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = address.label; + button.addEventListener('click', () => { + addressInput.value = address.label; + latitudeInput.value = address.latitude; + longitudeInput.value = address.longitude; + addressOptions.hidden = true; + }); + addressOptions.append(button); + }); + addressOptions.hidden = addresses.length === 0; + } catch (_) { addressOptions.hidden = true; } + }, 300); + }); + document.addEventListener('click', event => { + if (!event.target.closest('.address-field')) addressOptions.hidden = true; + }); +} diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..853741b --- /dev/null +++ b/app/static/style.css @@ -0,0 +1 @@ +:root{--ink:#17211b;--muted:#657068;--paper:#f4f0e7;--card:#fffdf7;--accent:#dd5b32;--green:#234c3c;--line:#d9d4c8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.5 system-ui,-apple-system,sans-serif}a{color:var(--green)}header{height:68px;padding:0 max(24px,calc((100% - 1120px)/2));display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}nav{display:flex;gap:24px}nav a{text-decoration:none;font-weight:650}.brand{font:bold 24px Georgia,serif;text-decoration:none;color:var(--ink)}main{max-width:1120px;margin:auto;padding:48px 24px 90px}h1,h2,h3{font-family:Georgia,serif;line-height:1.1;margin:.25em 0}h1{font-size:clamp(2.5rem,6vw,5.5rem);max-width:850px}h2{font-size:2rem}.hero{padding:45px 0}.hero p:not(.eyebrow){font-size:1.2rem;color:var(--muted)}.eyebrow{font-size:.75rem;font-weight:800;letter-spacing:.16em;color:var(--accent)}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 5px 18px #17211b0a}.search{display:grid;grid-template-columns:2fr 1.4fr 1.4fr .8fr auto;gap:12px;padding:18px;align-items:end}label{display:flex;flex-direction:column;gap:6px;font-size:.8rem;font-weight:750;position:relative}input,select{width:100%;border:1px solid var(--line);background:white;border-radius:7px;padding:12px;color:var(--ink);font:inherit}.suffix{position:absolute;right:12px;bottom:12px;color:var(--muted)}button,.button{border:0;border-radius:7px;padding:12px 18px;background:var(--green);color:white;text-decoration:none;font-weight:750;font:inherit;cursor:pointer;display:inline-block}.secondary{background:transparent;color:var(--green);border:1px solid var(--green)}.danger{background:#9d302c}.section-head{display:flex;justify-content:space-between;align-items:end;margin:52px 0 22px;gap:20px}.section-head h1{font-size:3.5rem}.section-head p{max-width:650px;color:var(--muted)}.events,.source-grid{display:grid;gap:14px}.event{padding:24px}.event h3{font-size:1.55rem}.event p{color:var(--muted)}.date{color:var(--accent);font-weight:800}.event-foot,.source-title,.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:22px}.pill{font-size:.75rem;background:#eee9de;padding:5px 9px;border-radius:99px}.good{background:#dce9df;color:#285039}.empty{text-align:center;padding:60px}.source{padding:24px}.source-stats{display:flex;gap:30px;flex-wrap:wrap;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:15px 0;margin-top:18px}.source-stats>div{display:grid}.source-stats small{color:var(--muted)}.admin-events{display:grid;gap:12px}.admin-event{padding:22px;display:flex;align-items:center;justify-content:space-between;gap:20px}.admin-event.is-hidden{opacity:.6;border-style:dashed}.event-admin-form{padding:25px;display:grid;gap:18px}textarea{width:100%;border:1px solid var(--line);background:white;border-radius:7px;padding:12px;color:var(--ink);font:inherit;resize:vertical}.source-title{margin-top:0}.source-title a{word-break:break-all;font-size:.85rem}dl{display:flex;gap:40px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:15px 0}dt{color:var(--muted);font-size:.75rem}dd{margin:2px 0;font-weight:700}.actions{justify-content:flex-start}.actions form{margin:0}.notice{padding:12px 16px;border-radius:7px}.error{background:#f4deda;color:#792a27}.success{background:#dce9df;color:#285039}.url-detect-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.url-detect-row .detect-submit{width:auto;white-space:nowrap;background:var(--green);color:white;border:0;font-weight:700}.address-field{position:relative}.picker-panel{grid-column:1/-1;padding:25px;display:grid;gap:14px}.picker-controls{display:flex;align-items:end;gap:10px}.picker-controls .field{min-width:220px}.selector-picker{width:100%;height:620px;border:1px solid var(--line);border-radius:8px;background:white}.picker-selection{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:start;gap:14px;padding:14px;background:#eee9de;border-radius:8px}.picker-selection>div{display:grid;gap:5px}.picker-selection code{overflow-wrap:anywhere}.picker-selection span{color:var(--muted);font-size:.8rem;overflow:hidden;text-overflow:ellipsis}.picker-actions{display:flex!important;flex-direction:row;flex-wrap:wrap}.picker-actions button{padding:9px 12px}.rules-details{grid-column:1/-1;overflow:hidden}.rules-details>summary{cursor:pointer;padding:22px;font:700 1.5rem Georgia,serif;display:flex;justify-content:space-between;gap:15px}.rules-details>summary span{font:400 .8rem system-ui;color:var(--muted)}.rules-details[open]>summary{border-bottom:1px solid var(--line)}.sticky-actions input[type=submit]{width:auto;background:var(--green);color:white;border:0;font-weight:700;cursor:pointer}.sticky-actions input.primary{background:var(--accent)}.config-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start}.form-section{padding:25px;display:grid;gap:17px}.form-heading{font:700 2rem/1.1 Georgia,serif;border-bottom:1px solid var(--line);padding-bottom:14px}.form-title{font:700 3.5rem/1.1 Georgia,serif;margin:.25em 0}.muted{color:var(--muted)}.field{display:flex;flex-direction:column;gap:6px;font-size:.8rem;font-weight:750}.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}.check{flex-direction:row;align-items:center}.check input{width:auto}.form-section small{font-weight:400;color:var(--muted)}.sticky-actions{grid-column:1/-1;display:flex;justify-content:flex-end;gap:10px}.primary{background:var(--accent)}.preview{margin-top:24px;padding:25px}.preview div{display:flex;justify-content:space-between;padding:10px;border-top:1px solid var(--line);gap:20px}.preview span{font-size:.8rem;color:var(--muted);white-space:nowrap}footer{text-align:center;border-top:1px solid var(--line);padding:30px;color:var(--muted);font-size:.8rem}.hero-title{font:700 clamp(2.5rem,6vw,5.5rem)/1.05 Georgia,serif;max-width:850px;margin:.12em 0}.hero-copy{font-size:1.2rem;color:var(--muted)}.section-title{font:700 2rem/1.1 Georgia,serif;margin-top:5px}.location-field{position:relative}.location-options{position:absolute;z-index:20;top:100%;left:0;right:0;background:white;border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px #17211b26;overflow:hidden}.location-options button{display:block;width:100%;text-align:left;border-radius:0;background:white;color:var(--ink);font-size:.85rem;border-bottom:1px solid var(--line)}.location-options button:hover{background:#eee9de}.location-message{padding:12px;color:var(--muted);font-size:.8rem;font-weight:400}.tag-field{position:relative}.tag-toggle{width:100%;text-align:left;background:white;color:var(--ink);border:1px solid var(--line);font-weight:400}.tag-options{position:absolute;z-index:19;top:100%;left:0;right:0;min-width:190px;background:white;border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px #17211b26;padding:8px}.tag-option{display:flex;flex-direction:row;align-items:center;gap:8px;padding:7px;border-radius:5px;cursor:pointer}.tag-option:hover{background:#eee9de}.tag-option input{width:auto}.range-field{position:relative}.view-tabs{display:flex;gap:8px;margin-bottom:16px}.tab{background:transparent;color:var(--green);border:1px solid var(--green)}.tab.active{background:var(--green);color:white}.view-panel{display:none}.view-panel.active{display:block}.search-filters{grid-template-columns:1.8fr 1.35fr 1fr .65fr auto;position:relative}.date-calendar{overflow:hidden;margin-bottom:18px}.calendar{overflow:hidden}.calendar-heading{font:700 1.6rem Georgia,serif;padding:22px}.calendar-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 22px 14px;color:var(--muted);font-size:.8rem}.clear-day{background:transparent;color:var(--green);border:1px solid var(--green);padding:7px 10px;font-size:.75rem}.weekdays,.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr)}.weekdays{border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:9px 0;color:var(--muted);font-size:.75rem;font-weight:700;text-align:center}.weekdays>input{display:block;min-width:0;border:0;border-radius:0;padding:0;background:transparent;color:var(--muted);font-size:inherit;font-weight:inherit;text-align:center;opacity:1}.calendar-day{min-width:0;min-height:82px;padding:8px;border:0;border-right:1px solid var(--line);border-bottom:1px solid var(--line);border-radius:0;background:transparent;color:var(--ink);text-align:left;display:flex;flex-direction:column;justify-content:space-between}.calendar-day:nth-child(7n){border-right:0}.calendar-day.outside{opacity:.35}.calendar-day.has-events{background:#fff4e8}.calendar-day.has-events:hover,.calendar-day.selected{background:#f4d6c6}.calendar-day.range-start,.calendar-day.range-end{box-shadow:inset 0 0 0 2px var(--accent)}.calendar-day b{font-size:.7rem;color:var(--accent)}.day-events{display:grid;gap:10px;margin-top:18px}.tag-list{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}[hidden]{display:none!important}.event-row{padding:18px 22px;text-decoration:none;color:var(--ink);display:flex;justify-content:space-between;align-items:center}.event-row:hover{border-color:var(--accent)}.event-title{font:700 1.25rem Georgia,serif;margin:5px 0}.map{height:580px}.back-link{display:inline-block;margin-bottom:34px}.event-detail{display:grid;grid-template-columns:1.1fr .9fr;gap:45px;align-items:start}.detail-title{font:700 clamp(2.5rem,5vw,4.8rem)/1.05 Georgia,serif;margin:10px 0}.detail-time{font:700 2rem Georgia,serif;color:var(--accent)}.description{font-size:1.1rem}.detail-card{padding:22px;margin:30px 0}.detail-field{display:grid;grid-template-columns:90px minmax(0,1fr);align-items:start;gap:14px;padding:13px 0;border-bottom:1px solid var(--line)}.detail-field:last-child{border-bottom:0}.detail-label{font-size:.75rem;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.08em}.detail-value{font-weight:700;overflow-wrap:anywhere}.hint{font-size:.75rem;color:var(--muted);font-weight:400}.detail-actions{display:flex;gap:10px}.detail-map{height:520px;position:sticky;top:24px}@media(max-width:800px){.search,.search-filters,.config-grid,.event-detail,.url-detect-row,.picker-selection{grid-template-columns:1fr}.picker-controls{align-items:stretch;flex-direction:column}.selector-picker{height:480px}.two{grid-template-columns:1fr}.sticky-actions{grid-column:auto}.section-head{align-items:flex-start;flex-direction:column}.source-title,.event-foot,.admin-event{align-items:flex-start;flex-direction:column}dl{flex-wrap:wrap}.search button{width:100%}nav{gap:10px;font-size:.85rem}.calendar-day{min-height:72px;padding:7px}.calendar-day b{font-size:.6rem}.weekdays{font-size:.6rem}.map,.detail-map{height:420px}.event-detail{gap:25px}} diff --git a/app/strategies.py b/app/strategies.py new file mode 100644 index 0000000..dc56ef0 --- /dev/null +++ b/app/strategies.py @@ -0,0 +1,164 @@ +import json +from copy import deepcopy +from datetime import datetime, timedelta +from urllib.parse import urljoin +from zoneinfo import ZoneInfo + +from bs4 import BeautifulSoup + +from .crawler import BROWSER_HEADERS, CrawlError, ExtractedEvent, _extract_json_ld, _parse_date_range, _weekly_dates, validate_public_url + + +def parse_strategy(value) -> dict: + if isinstance(value, dict): + return deepcopy(value) + try: + return json.loads(value or "{}") + except (TypeError, json.JSONDecodeError): + return {} + + +def json_path(value, path: str, default=None): + if not path: + return value + current = value + for component in path.split("."): + if current is None: + return default + if isinstance(current, list): + try: current = current[int(component)] + except (ValueError, IndexError): return default + elif isinstance(current, dict): + current = current.get(component, default) + else: + return default + return current + + +def _value(node, selector: str, attribute: str = "") -> str: + if not selector: + return "" + target = node if selector == ":scope" else node.select_one(selector) + if not target: + return "" + if attribute: + return str(target.get(attribute, "")) + return target.get_text(" ", strip=True) + + +def _request(client, strategy: dict, page: int | None = None): + url = str(strategy.get("request_url") or strategy.get("url") or "") + validate_public_url(url) + method = strategy.get("method", "GET").upper() + params = deepcopy(strategy.get("params") or {}) + body = deepcopy(strategy.get("body") or {}) + page_parameter = strategy.get("page_parameter", "") + if page is not None and page_parameter: + (params if method == "GET" else body)[page_parameter] = str(page) + headers = {**BROWSER_HEADERS, **(strategy.get("headers") or {})} + response = client.request(method, url, params=params, data=body if method != "GET" else None, headers=headers) + response.raise_for_status() + if len(response.content) > int(strategy.get("max_bytes", 5_000_000)): + raise CrawlError("Strategy response exceeds configured size limit.") + return response + + +def _events_from_html(html: str, strategy: dict, source: dict) -> list[ExtractedEvent]: + soup = BeautifulSoup(html, "html.parser") + now = datetime.now(ZoneInfo(source.get("timezone") or "UTC")) + if strategy.get("item_selector") == "json-ld": + return _extract_json_ld(soup, source["url"], source, now) + events = [] + for item in soup.select(strategy.get("item_selector", "")): + title = _value(item, strategy.get("title_selector", ""), strategy.get("title_attribute", "")) + raw_date = _value(item, strategy.get("date_selector", ""), strategy.get("date_attribute", "")) + for old, new in (strategy.get("date_replacements") or {}).items(): raw_date = raw_date.replace(old, new) + raw_time = _value(item, strategy.get("time_selector", ""), strategy.get("time_attribute", "")) + if not title or not raw_date: continue + config = {**source, "date_format": strategy.get("date_format", "")} + if strategy.get("recurring_weekly"): + for start in _weekly_dates(raw_date, now): + events.append(ExtractedEvent(title=title, starts_at=start, description=_value(item, strategy.get("description_selector", ""), strategy.get("description_attribute", "")), url=urljoin(source["url"], _value(item, strategy.get("link_selector", ""), strategy.get("link_attribute", "href"))), address=_value(item, strategy.get("location_selector", ""), strategy.get("location_attribute", "")))) + continue + try: start, end = _parse_date_range(f"{raw_date} {raw_time}".strip(), config, now) + except (ValueError, OverflowError): continue + duration = strategy.get("duration_minutes") + if duration and not end: end = start + timedelta(minutes=int(duration)) + link = _value(item, strategy.get("link_selector", ""), strategy.get("link_attribute", "href")) + description = _value(item, strategy.get("description_selector", ""), strategy.get("description_attribute", "")) + location = _value(item, strategy.get("location_selector", ""), strategy.get("location_attribute", "")) + events.append(ExtractedEvent(title=title, starts_at=start, ends_at=end, description=description, url=urljoin(source["url"], link), address=location)) + return events + + +def _index_join(items: list, join_items: list, item_key: str, join_key: str) -> dict: + index = {} + for item in join_items: + keys = json_path(item, join_key, []) + if not isinstance(keys, list): keys = [keys] + for key in keys: + if key is not None: index[str(key)] = item + return index + + +def _events_from_json(payload, strategy: dict, source: dict, join_index: dict | None = None) -> list[ExtractedEvent]: + items = json_path(payload, strategy.get("items_path", ""), []) + if not isinstance(items, list): return [] + now = datetime.now(ZoneInfo(source.get("timezone") or "UTC")) + events = [] + for item in items: + if strategy.get("disabled_path") and json_path(item, strategy["disabled_path"]): continue + joined = join_index.get(str(json_path(item, strategy.get("join_item_key", "")))) if join_index else None + def field(name, default=""): + item_path = strategy.get(f"{name}_path", "") + join_path = strategy.get(f"join_{name}_path", "") + value = json_path(item, item_path, None) if item_path else None + if (value is None or value == "") and joined is not None and join_path: + value = json_path(joined, join_path, default) + return value if value is not None else default + title = str(field("title") or strategy.get("title_fallback", "Untitled event")) + date_value = str(field("date")) + if not date_value: continue + try: start, end = _parse_date_range(date_value, source, now) + except (ValueError, OverflowError): continue + end_value = field("end") + if end_value: + try: end, _ = _parse_date_range(str(end_value), source, now) + except (ValueError, OverflowError): pass + duration = field("duration") or strategy.get("duration_minutes") + if duration and not end: end = start + timedelta(minutes=int(duration)) + events.append(ExtractedEvent(title=title, starts_at=start, ends_at=end, description=str(field("description")), url=urljoin(source["url"], str(field("link"))), address=str(field("location")))) + return events + + +def execute_strategy(source: dict, client) -> list[ExtractedEvent]: + strategy = parse_strategy(source.get("strategy_json")) + mode = strategy.get("mode", "html") + if mode == "html": + response = _request(client, strategy) + return _events_from_html(response.text, strategy, source) + if mode not in {"json", "html_fragment"}: + raise CrawlError(f"Unsupported extraction mode: {mode}") + join_index = None + join = strategy.get("join") + if join: + join_response = _request(client, join) + join_payload = join_response.json() + join_items = json_path(join_payload, join.get("items_path", ""), []) + join_index = _index_join([], join_items if isinstance(join_items, list) else [], strategy.get("join_item_key", ""), join.get("key_path", "")) + events = [] + page = int(strategy.get("page_start", 1)) + max_pages = min(int(strategy.get("max_pages", 1)), 20) + for _ in range(max_pages): + response = _request(client, strategy, page) + payload = response.json() + if mode == "html_fragment": + fragment = json_path(payload, strategy.get("html_path", "html"), "") + page_events = _events_from_html(str(fragment), strategy, source) + else: + page_events = _events_from_json(payload, strategy, source, join_index) + events.extend(page_events) + total_pages = json_path(payload, strategy.get("total_pages_path", ""), None) + if not strategy.get("page_parameter") or not page_events or (total_pages is not None and page >= int(total_pages)): break + page += 1 + return events diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..6fb1943 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,5 @@ + + +{% block title %}What2Do{% endblock %}{% block head %}{% endblock %} +
What2Do
+
{% block content %}{% endblock %}
Local-first event discovery · No AI, tracking, or proprietary map dependency
{% block scripts %}{% endblock %} diff --git a/app/templates/event.html b/app/templates/event.html new file mode 100644 index 0000000..f8e212a --- /dev/null +++ b/app/templates/event.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}{{ event.title }} · What2Do{% endblock %} +{% block head %}{% endblock %} +{% block content %} +← All events +
+
+
{{ event.display_date }}
+
{{ event.title }}
+
{{ event.display_time }}{% if event.display_end %} – {{ event.display_end }}{% endif %}
+ {% if event.description %}
{{ event.description }}
{% endif %} +
+
Venue
{{ event.venue_name }}
+
Address
{{ event.address }}
+ +
+
Visit source
+
+
{% if event.latitude is not none %}
{% else %}
No map coordinates are available for this event.
{% endif %}
+
+{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/event_form.html b/app/templates/event_form.html new file mode 100644 index 0000000..b341842 --- /dev/null +++ b/app/templates/event_form.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}Edit event · What2Do{% endblock %} +{% block content %} +
EDIT NORMALIZED EVENT
{{ event.title }}
Changes may be overwritten on the next crawl if the source still publishes different values.
← Source events
+
Title
Description
Starts at
Ends at
Venue name
Address
Latitude
Longitude
Event URL
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..1d8aa1c --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Find events · What2Do{% endblock %} +{% block head %}{% endblock %} +{% block content %} +
LOCAL EVENT INDEX
Find something worth going out for.
Search events gathered from local venue websites.
+ +{% if location and not geocoded %}
That location could not be resolved. Select an autocomplete suggestion to apply distance filtering.
{% endif %} +
WHAT'S ON
{{ events|length }} events
+ Add source
+
+
+
+
Choose a range · {{ calendar_month }}
+
Select a start date
+
+
{% for day in calendar_days %}{% endfor %}
+
+
+ {% for event in events %}
{{ event.display_time }}
{{ event.title }}
{{ event.venue_name }} · {{ event.address }}
{% for event_tag in event.tags %}{{ event_tag }}{% endfor %}
{% else %}
No events match these filters.
{% endfor %} + +
+
+
+ +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/source_events.html b/app/templates/source_events.html new file mode 100644 index 0000000..3b49c76 --- /dev/null +++ b/app/templates/source_events.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}{{ source.name }} events · What2Do{% endblock %} +{% block content %} +
SOURCE EVENTS
{{ source.name }}
Edit normalized events or hide them from public search and maps.
← Sources
+
{% for event in events %}
{{ event.starts_at }}{% if event.ends_at %} → {{ event.ends_at }}{% endif %}

{{ event.title }}

{{ event.venue_name }} · {{ event.address }}
{% if event.hidden %}Hidden{% endif %}
Edit
Public page
{% else %}
No events have been crawled for this source.
{% endfor %}
+{% endblock %} diff --git a/app/templates/source_form.html b/app/templates/source_form.html new file mode 100644 index 0000000..9935236 --- /dev/null +++ b/app/templates/source_form.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Configure source · What2Do{% endblock %} +{% block content %} +
SOURCE CONFIGURATION
Configure website
Auto-detect standard HTML or configure a reusable extraction strategy.
← Sources
+{% if detect_message %}
{{ detect_message }} Review the strategy below before saving.
{% endif %} +{% if preview_error %}
Detection or preview failed: {{ preview_error }}
{% endif %} +
+
Website and venue
+
Source name
+
Page URL
+
Venue name
+
Venue address
+
Latitude
Longitude
+
Timezone
+
Visual selector picker
Loads a sanitized, non-interactive copy of the downloaded HTML. Dynamic JavaScript content may not appear.
+
Extraction strategy — advanced
+
HTML mode uses the convenient selector fields below. JSON and HTML-fragment modes use the generic JSON strategy directly.
+
Event container selector
Title selector
Date or range selector
Time selector
Link selector
Description selector
Location selector
Date format
+ +
Strategy template
Persisted strategy JSON
Supports request configuration, dotted JSON paths, CSS selectors and attributes, pagination, joins, and response limits. See STRATEGIES.md.
+
+
Scheduling
Crawl every (minutes)
+
{% if source.get('id') %}{% endif %}
+ +{% if preview is not none %}
Preview ({{ preview|length }})
{% for event in preview %}
{{ event.title }}{{ event.starts_at }}{% if event.ends_at %} → {{ event.ends_at }}{% endif %}
{% else %}

No events matched this strategy.

{% endfor %}
{% endif %} +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/sources.html b/app/templates/sources.html new file mode 100644 index 0000000..9c9e97c --- /dev/null +++ b/app/templates/sources.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}Sources · What2Do{% endblock %} +{% block content %} +
CRAWLER ADMIN
Sources
Configure crawlers and manage their normalized events.
+ Add website
+
{% for source in sources %}

{{ source.name }}

{{ source.url }}
{{ 'Enabled' if source.enabled else 'Paused' }}
Venuesmall>{{ source.venue_name }}
Events{{ source.event_count }}
Last result{{ source.last_status or 'Not crawled' }}
{% if source.last_error %}
{{ source.last_error }}
{% endif %}
ConfigureManage events
{% endfor %}
+{% endblock %} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6a221a8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,16 @@ +services: + web: + build: . + ports: + - "8000:8000" + environment: + DATABASE_PATH: /data/what2do.db + PUBLIC_BASE_URL: http://localhost:8000 + CRAWL_INTERVAL_MINUTES: "60" + NOMINATIM_USER_AGENT: what2do-local/0.1 (set-a-contact@example.com) + volumes: + - what2do-data:/data + restart: unless-stopped + +volumes: + what2do-data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..77b8877 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.116.1 +uvicorn==0.35.0 +httpx==0.28.1 +beautifulsoup4==4.13.4 +python-multipart==0.0.20 +jinja2==3.1.6 +python-dateutil==2.9.0.post0