From 034e5e3312f4b9597f2bd2f0bdd385c76b24bb69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Sun, 9 Aug 2026 09:15:38 +0200 Subject: [PATCH] =?UTF-8?q?seo:=20pr=C3=A6cis=20sitemap-lastmod,=20IndexNo?= =?UTF-8?q?w-ping=20ved=20deploy=20og=20favicon.ico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fire ugers adgangslog (81k requests) viser at ægte Googlebot kun hentede robots.txt, favicon og et par billeder — plus én enkelt HTML-side i hele perioden. Intet af det her løser dét (kun indgående links gør), men det fjerner de undskyldninger vi selv havde lagt ud. - Sitemappet havde ingen lastmod overhovedet. Nu udleder astro.config.mjs den pr. side: statiske sider fra deres sidste commit, blogindlæg fra updated/date i frontmatter, og oversigts-/tagsider fra det nyeste indlæg de viser. Kan en dato ikke fastslås præcist, udelades lastmod helt — en forkert dato er værre end ingen, fordi Google holder op med at stole på feltet. - Det kræver rigtig git-historik, så checkout kører nu fetch-depth: 0 (default shallow ville give alle filer samme dato). - Nyt deploy-step pinger IndexNow med de sider dette push faktisk ændrede — udregnet af scripts/indexnow.mjs ud fra diffen, ikke hele sitemappet, da gentagne fuld-lister er støj. Nøglefilen lå allerede i public/, men blev aldrig brugt. Hjælper kun Bing/Yandex; Google understøtter ikke IndexNow. continue-on-error, så det aldrig vælter et deploy. - /favicon.ico gav 404. Tilføjet som rigtig 3-størrelses ICO (16/32/48) genereret fra favicon.svg med samme baggrund som de øvrige ikoner. Claude-Session: https://claude.ai/code/session_018oXJWTRxTYdcV9Kw6M7e5J --- .gitea/workflows/deploy.yml | 13 ++++ astro.config.mjs | 135 +++++++++++++++++++++++++++++++++++- public/favicon.ico | Bin 0 -> 5371 bytes scripts/indexnow.mjs | 99 ++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 public/favicon.ico create mode 100644 scripts/indexnow.mjs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f671ec4..f58db9c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -12,6 +12,10 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + # Fuld historik: astro.config.mjs udleder sitemap-lastmod fra hver sides + # sidste commit, og IndexNow-steppet differ pushet. Begge er værdiløse + # med den default shallow clone (fetch-depth: 1). + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 @@ -56,3 +60,12 @@ jobs: && { mv $DEPLOY_PATH $DEPLOY_PATH.old 2>/dev/null || true; } \ && mv $DEPLOY_PATH.new $DEPLOY_PATH \ && rm -rf $DEPLOY_PATH.old" + + # Fortæl Bing/Yandex m.fl. med det samme hvilke sider der ændrede sig. + # Kører EFTER deploy, så nøglefilen og siderne er live når de kommer og tjekker. + # Google understøtter ikke IndexNow. Må aldrig vælte et ellers vellykket deploy. + - name: Ping IndexNow + continue-on-error: true + env: + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + run: node scripts/indexnow.mjs diff --git a/astro.config.mjs b/astro.config.mjs index dbdcf58..59f004e 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,136 @@ import { defineConfig } from 'astro/config'; import sitemap from '@astrojs/sitemap'; import AstroPWA from '@vite-pwa/astro'; import tailwindcss from '@tailwindcss/vite'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.dirname(fileURLToPath(import.meta.url)); + +// ── lastmod til sitemap ──────────────────────────────────────────────────────── +// Google bruger kun lastmod hvis den er troværdig, så vi gætter aldrig: statiske +// sider får datoen for deres SIDSTE COMMIT, blogindlæg får updated/date fra +// frontmatter, og alt vi ikke kan datere præcist får slet ingen lastmod. +// NB: kræver fuld git-historik — deploy-workflowen bruger fetch-depth: 0. +// Er historikken shallow (eller mangler .git), falder vi stille tilbage til +// "ingen lastmod" i stedet for at udgive en forkert dato. + +const gitLastModified = (file) => { + try { + const out = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return out || null; + } catch { + return null; + } +}; + +// Shallow clone => hver fil ser ud til at være ændret i den ene commit vi har. +// Så er datoerne værdiløse, og vi dropper lastmod helt. +const gitHistoryIsUsable = (() => { + try { + const n = execFileSync('git', ['rev-list', '--count', 'HEAD'], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return Number(n) > 1; + } catch { + return false; + } +})(); + +const walk = (dir) => + fs.existsSync(dir) + ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = path.join(dir, e.name); + return e.isDirectory() ? walk(p) : [p]; + }) + : []; + +// src/pages/foo.astro -> /foo/ ; src/pages/index.astro -> / ; [dynamiske] springes over +const routeForPage = (file) => { + const rel = path.relative(path.join(ROOT, 'src/pages'), file).replace(/\\/g, '/'); + if (rel.includes('[') || rel.startsWith('404.')) return null; + if (!rel.endsWith('.astro')) return null; + const noExt = rel.slice(0, -'.astro'.length); + if (noExt === 'index') return '/'; + return `/${noExt.replace(/\/index$/, '')}/`; +}; + +// Minimal frontmatter-læsning — vi skal kun bruge date/updated/tags/draft. +const readFrontmatter = (file) => { + const src = fs.readFileSync(file, 'utf8'); + const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!m) return {}; + const fm = {}; + for (const line of m[1].split(/\r?\n/)) { + const kv = line.match(/^(\w+):\s*(.*)$/); + if (!kv) continue; + const [, k, rawv] = kv; + const v = rawv.trim().replace(/^['"]|['"]$/g, ''); + if (k === 'tags') { + const inline = v.match(/^\[(.*)\]$/); + fm.tags = inline + ? inline[1].split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean) + : []; + } else { + fm[k] = v; + } + } + return fm; +}; + +const iso = (v) => { + const d = new Date(v); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +}; +const newest = (dates) => dates.filter(Boolean).sort().at(-1) ?? null; + +const buildLastmodMap = () => { + const map = new Map(); + if (!gitHistoryIsUsable) return map; + + for (const file of walk(path.join(ROOT, 'src/pages'))) { + const route = routeForPage(file); + if (!route) continue; + const d = gitLastModified(file); + if (d) map.set(route, d); + } + + // Blogindlæg: frontmatter er sandheden om hvornår indholdet sidst ændrede sig. + const postDates = []; + const tagDates = new Map(); + for (const file of walk(path.join(ROOT, 'src/content/blog'))) { + if (!file.endsWith('.md')) continue; + const fm = readFrontmatter(file); + if (fm.draft === 'true') continue; + const d = iso(fm.updated || fm.date); + if (!d) continue; + const slug = path.basename(file, '.md'); + map.set(`/blog/${slug}/`, d); + postDates.push(d); + for (const t of fm.tags ?? []) { + const key = t.toLowerCase(); + tagDates.set(key, newest([tagDates.get(key), d])); + } + } + + // Oversigtssider er lige så friske som det nyeste de viser. + const latest = newest(postDates); + if (latest) { + map.set('/blog/', latest); + map.set('/tags/', latest); + for (const [tag, d] of tagDates) map.set(`/tags/${tag}/`, d); + } + return map; +}; + +const lastmodByRoute = buildLastmodMap(); export default defineConfig({ site: 'https://asger.xn--weirse-fya.dk', @@ -11,7 +141,10 @@ export default defineConfig({ changefreq: 'weekly', priority: 0.7, serialize(item) { - if (item.url === 'https://asger.xn--weirse-fya.dk/') item.priority = 1.0; + const pathname = new URL(item.url).pathname; + if (pathname === '/') item.priority = 1.0; + const lastmod = lastmodByRoute.get(pathname); + if (lastmod) item.lastmod = lastmod; return item; }, }), diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6b719f070fd9a6deefc7e23ae8af0e2fd1acc7be GIT binary patch literal 5371 zcmaKubyU>f*2cfUh_pkDlqfMY(v3eFr6i>W=^Rq&l#(8#QR$AMq@@O=1%~dFmhP^X zd+$H*TJK%&I)Ci-th3KP|9sYd9ss}qumCU^c&N0%2qpjs0sufu`yU;N4FEV;0Km=t zA8m~f0P(m05U!^3h7gYm@1d1YL0(qlp*~Ciz&H=@ftXn9f6c%)ReB=}-2dxjHW$PL z0A{FytkgS}So^r&s-z|qT^Wv+Ghgf#Q$`XK&=ibpoS)^wAXF<3RH(12Fl9q&Im-Rj!+#_x{2fy!?H=db`$bNA$wK zVa8ae5WV@stYs&6#E9OkVoeypl~g@HW%g~Zv|Fe%_)Q?_cW%RQ(UjFe+LT<$+Q+dWt_=jHjQ z?!fv$B*>*^Wgu$676>tm6in%9ZICXF>t3N>)}L1Pb`EXV%YsDcv>6;$OkqTBjx8}n zG^`yr6hA|`n4leaFMS*^WM${tE;~jDmY?)b$alr+q3(c^jyX-vzs zaRBCUYfmV`{dRgNaN2%=+UQYw775vs!Si3_D_n#xMGuNAuQi@Xk`NPrlrt&Iy z*9l9Z+yPhI@Lqf`Lp~7AkBDqt{e7$ZVW>20@F@bb>Zz92BY9eS z41!dQA9^KgZcge-oHhVOkX8~#45x7F*%2mG!6hs))e2;?-%#}j6R-wknLenf4BI9t z17|D(*ePzLGj5`to~)GJJIZ}K%1WkD!dyjS`m@FL11kpJ31k9N9jg4#w7L^KEi~Az zMaHGh9swzef(&{5c`mhArmg$T%iGi`8s_F2qsNta}beiSL*(v;4V) z`)eUXzmi4i%Qm7Mb2R(c4_p_U4lb4qrcK!-V$kdBNq7E#)eM^FQ5)syLVFS(GxukF z?Ar#UC&4qSlM|pbErHsm=ak=J;q1U2hD)L<5uJ5E=L4jGf}DzMnY3}>e<7Oo;hOf} zMBh;C<~)f1Lo~MJzlfF(uhF)(Bhw-iceix2Y^ZwDFrRlKwb(hMX78U_Q>rE#t7b}w zyUeaWM3G2wAT<^v$ql**Adx{xgF0yg1&-7zp27=D<-)=qU47cA{h3!iZ{cp`wjg%* zZP%sYCDZ2*PJaC&$7%bieCO$WR^)p%XId&L`7+g$Iki%b-d-FwCJ4>J}yW`MB`@=du6xHDhkP8z{iQ?WNR zw=CU$SwnMO+}EcViBB979w_|h*v-|A|NSY_PT#?SH$cw;p@|q7o+nzBR7TZxwJ* zD7KE(5{f644GHHLbC9Fikn})nviW%R;SU+1Jtg+<4X$JXCAocCf3bnY-i%%%;M_yw zN@gK@^}=vVVSAY0tiEK7gv*@$#RiA`@kkt#6|QZ&_9rG@?xoT8%V>@=-mk$FR$GO2dOGF`pZ43)c>-0!tRfs-ZahW8^unxdxV!K({3YUv zO%uSM5Kq~O1RvsN%Of+R^_Fra1ox60?-5r9m6z2nQ{8MSX@hew@rp9dq2Q+1ZiQKU zec6{;vjHW0sQ?&E;nHB*2-=7lYO;?r1jvU z9#NG1&-B$4@{xZ4ZN$@L?okYXoF-`a>V+-?0@FikhyzI(0bV!XS&JrL++yc(A$hBA zmk_Vj$qm!QxgYxAHnHOou>!Hi9Mtbo*fpzzef&HhVL<-OgsAI_2`P6QW)Rk7{wCHl z6J(J#z6ssOzWb_f{70E#;UZl475)7u@T=CnYWR&TeJ*3{t(R)gEE%@{4&EDB!01f` z^kozD?_k@vq{Kv%lZjkS+4c@DjGzuUDK(v7nI0if^HrsN)kHq?B1{c3uFwz@PCCjy zq@y*RZA&Ai{&f5s)u<^hhDYlD$)%`|I7TE>lZJp+cu^cjtq_4kqfL|)4sKPU-BO@r zC<0^P@EN=8TT;+X^PHOZ=I__t(NRQx`b8~+E;j;XVIYz8NJN+jAKs`HS~*qD*c8@y zRbReMWciPE^pB-)B+gS5$;#3IN~z!OydAsWeG}&O+!_e%hz@p$s4Oh9{@e7^ew5F$ z)&oe2;#Iy0jn7E&_FKP6-_xFamJHEVhp+@@zs!Ax=B~?pRuekqCnvWor>+ip8FD%i zDp{dXWTg55$x=Bo zKI?brWJ}AR5C2mwqPYK(m5nVEZUbw|hjTSo+`jYW`&RyiJxR-_$u|fs_?D)QF&3w8 z>2_Z9I`<(2Ejytuc6#$7$=5e>^&O9osH2XW4gGxEGaeJA5rEo5Xy$jful-7vHx11t z!dV~O<^Qvyhxi3>|J#bDj2(vln-$^H{L6}TgVDO)AKr~q-tW~%`_AoH#-k%&$;;!= zeHNl4#$*i4J__@Hie0F2z>`#wHo!t8n#Z@R{3bNZMxR|ST&r*h6Jo8tjHL$ck-?$G zwUh3#tn#$0Y@FV?7FWHWLNnES3;iX?yG38FEbOQ5Uw=9m+xWC07HS;Nw{(v?#^RgD zxKfbtR$f(Tl+05n6*qfqa-+d*-l?ko^f7n@E-+~KzG^eP8z0YUA1~TNxAbiO#k0BF zUlMI5hQo3!`6bytGu~oGeP|TTAj(MnLgo(wRcBJ_2f>HrfR3lwKWb+RhT7mUAub*rwj{C!!r zzH{x=N!l_F#hu8A3T0MLO7?1OvH!Vbw;%Jk$*Y-@fx-T&nw62U6U5Wzk6tjSFotyF zf>SEds^!Ic4f5j--YtG_eD|$Nog@|U6uQ`n;EUq{uS3l=LS8Esg^qF^Ii~+S+S<2` zzS%X_9n#ngPOlEXO<;oH8l?-LZqzL@vO10AhF2W^e%9;gelZ9g-?wxCF_odB& zg>2z|yzWIt&0CWo1p-VU4qD~xbaag0G_#XqAMlkNx<=)SSDIh8_tkK=-HV3H(K9Ri z2=%RLmTN-LMLYU%P<~wG>dMKvGSs$4Ps|dn4`F-<(|C8^d3W(-zS>jitM#Yae5A(> zC^-wmf8&xU1wG+l53ikbuyyCzyfRj@iFi&7r3Wj-U{`J4j1TA-&Q*F@yls+F@0uNS z<3Kwn<>z;v59n2Qjm*8qXkAt4M8*n9G@M2&<>zj_Sw$XALn=0&NJ#h{Ec*E#sJEFM z1Cp>WAMaD@+;)8K1B&F3*sQX4oP)RRo4D32Xu!^ZaL!(3ygDG zuO74WZHUw0;X%XYNe)x15AxE&r(}&svdPkhX`U|sV`-5u1=ZX>g&`7?b-dn>Iu%5j ziUyjeCB3yIWO@noNGd{|-z0{^yDU}TJhakT>Cph0zjPRl4C5$Cex*s$Lzn(aAb7&V z#EQ9b4zm?M(>Kc}5(1p^6AL?s9Vxa=)0IBE&X2!5i*E9geihZhA`*ChINZEBG~5vS z!VkH*Uh08g_s_x{6gIv|OIK5NlscCr&c(&HQR%iX1$dlB8303D1e&sw2ZkK3ZdbCv zl=mO&=p4t%Yd@XBW0Kml&@RepkPmhN0hYEP;&MB5QPf^bm!oG;%=0Q`xy?taO-zNK zJf-E?LW!2x$4@`6!zJ9|9Z>F8ULNY7y znlr@VM*f`TN~>;o@_s*|y}N%bIDungX6Co&2e`tay!6+H4+{&vPd+Tig@(U|@%Gk~ z@h*L9Xkib}5=0Hzl7g9iVm(%@Ax$5G6_H;bSc&MtQaY<#6{GXpI_#2`WCan~gx6vs zsl)y@y&EIjoxfXZk0jr$h6UT=WpBf+4`=M$N>=EL7%>A|;Ux@Ft{s}p;9u77P`%Te z%^z+|Tl-3HE}uncYq#PMlOlN~#P6of*uQpv7JkjrfW$uBBxlC!Lm!GS2Y~5`r9o}} zJlL(e33+9+Wqn4~ar`2FPrFomp z%;F1C)b;H0rspUUPqQk{>?kw3ir?kE*ld!fx#6W9gsFlyBIKRLOK_03s<#i;;Sohv z7LF&D;p<{lEe`Dkh9~;?{BhHg5;JU$=mh`<1@KJ#-3;eD{&dtP?erl~RuAuUU?7xf z`7{62#wito;Vh*he|;Dk~J=u2fhrVglsR{Hu@x<|8tJsGe0QL-#QTCrh8x z4dBc<>(nP|)|Eia!Qf+x9-i$^9utyp)>e(rvA<($)s-1#nvkcfIZg_%ofR9e{bB-^ z3&_=CvrNu*K^&3arb~i;ZH+%AZNn1J(7*=Mk)!`mg%H^! zZN0FY{%+<|7AvLbLtu;CpQ(k*{Qgb2L2rX4V-9)UZzw*2`gjY21O)|!!YtPM%c#W_ z5@hvGLa`|NNVI>`6O#@0`fTU;)w;XYxx2NvUgRw6;SQp*p}iE3+a4m`%Z*0l#(=5{ z&zF)=l6MFYn0lfNURb38eW?`3MTYaS*W!6VqmWwNtkp>AqHCe;WEN++PWGi7mc9 zp>y~A9f=b=trt%pSAvKe4RtG->L+@^^gFJ71ur-VXjMzrY^1JhzQB|jMS4O_V(+&c zjYO&jr7?o#K>*gT0P%8L+CLCkx@=0kqiSKIEMhWZdiclp!W1+NVQrE_lbc)NKct`y zFe`nH+k@5Lj5(0YJ4xZP>=3f}O{=JJ?~GdKy+!CdnnJvP1fH8k*oK};o@|o z%{_M-@AH+d43#)AR~S7PP9W5V1(v{QRlc}ubMR3+IB9Wl@bqdFpHhlUB(|Fu8%WOP zFgoAz*_^Gt<5ll+KM~FOoa_K?3Vg3s1r-vzJ{4L>Z${DUZc=szTM&mCAQ?sr?g4=T5Itp_DFxH&n(BmL zsUfP;a&uXIN_Vz#onrx~0-yc0%zdGvCrLy=cG0CXJM(Jv;`vw6y7ml#fsRVikO?(1 z1$x;SXf{rqG@0Bef1U|HEJ~%%+8noROpjV(|8l-o$MUP{i(o>asowcMk6kE)K6iYm zVB5yIitn`!mOtMq`IBcNJ7->Te6H(0g2hAT&&_DDRqfMJlB)VGvrk=IK_>)_UUKNR zEw;`_hOg|-(jP^?YCpszXgNuR3Emx`7PtA`Q78aFRLn2+p^)h`;B7>}Jo}44s+folM;`KR=YGe)H%xQA@S}$bOw&XU4l2@3hczo(RQxz8@ zjYs?}aDdmOiA-`k4!GpRYH7Cg{h0O^e09EEJ}5{)&gOW8A=y4eJ@In!hnvE$DZ7jP z%+luLpxca{X+MrihdoJ_kmc;A-+6LyOHD6Wuffvr#*TZ#t4YV|+G;l_6(3dBJ? { + try { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return null; + } +}; + +const sitemapUrls = () => { + const xml = fs.readFileSync('dist/sitemap-0.xml', 'utf8'); + return new Set([...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1])); +}; + +// src/pages/foo.astro -> /foo/ ; src/content/blog/bar.md -> /blog/bar/ +const routeForFile = (f) => { + let m = f.match(/^src\/pages\/(.+)\.astro$/); + if (m) { + if (m[1].includes('[')) return null; // dynamiske ruter kan vi ikke slå op + if (m[1] === 'index') return '/'; + return `/${m[1].replace(/\/index$/, '')}/`; + } + m = f.match(/^src\/content\/blog\/(.+)\.md$/); + if (m) return `/blog/${m[1]}/`; + return null; +}; + +const changedFiles = () => { + const { GITHUB_EVENT_BEFORE, GITHUB_SHA } = process.env; + const zero = /^0*$/; + if (GITHUB_EVENT_BEFORE && !zero.test(GITHUB_EVENT_BEFORE)) { + const d = git(['diff', '--name-only', GITHUB_EVENT_BEFORE, GITHUB_SHA || 'HEAD']); + if (d !== null) return d ? d.split('\n') : []; + } + const d = git(['diff', '--name-only', 'HEAD~1', 'HEAD']); + return d ? d.split('\n') : null; +}; + +const files = changedFiles(); +if (files === null) { + console.log('IndexNow: kan ikke bestemme hvad der ændrede sig (shallow clone?) — springer over.'); + process.exit(0); +} + +const valid = sitemapUrls(); +const urls = [...new Set(files.map(routeForFile).filter(Boolean).map((r) => ORIGIN + r))].filter((u) => + valid.has(u), +); + +// Layout/CSS/config-ændringer rammer hver side, men det er ikke en indholdsændring +// værd at pinge om. Kun ruter med eget ændret kildefil ryger afsted. +if (!urls.length) { + console.log('IndexNow: ingen sider med ændret indhold i dette push — springer over.'); + process.exit(0); +} + +const body = { + host: HOST, + key: KEY, + keyLocation: `${ORIGIN}/${KEY}.txt`, + urlList: urls, +}; + +console.log(`IndexNow: indsender ${urls.length} URL(er):`); +for (const u of urls) console.log(' ', u); + +// `node scripts/indexnow.mjs --dry-run` viser hvad der ville blive sendt. +if (process.argv.includes('--dry-run')) { + console.log('IndexNow: --dry-run, sender ikke.'); + process.exit(0); +} + +const res = await fetch('https://api.indexnow.org/indexnow', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body), +}); + +// 200 = modtaget, 202 = modtaget men nøglen valideres stadig. Begge er fine. +console.log(`IndexNow: HTTP ${res.status} ${res.statusText}`); +if (![200, 202].includes(res.status)) { + console.log(await res.text().catch(() => '')); + console.log('IndexNow: afvist — men deployet er allerede ude, så vi fejler ikke buildet.'); +}