diff --git a/apps.html b/apps.html index d1e29bc..48c23eb 100644 --- a/apps.html +++ b/apps.html @@ -25,6 +25,11 @@ .app-card h3 { font-size:1rem; margin-bottom:.15rem; } .app-card p { font-size:.82rem; color:var(--sub); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } .store-badge { display:inline-block; margin-top:.4rem; font-size:.78rem; font-weight:600; color:var(--accent); } + .app-card-dual .app-card-body { min-width:0; } + .store-links { display:flex; flex-wrap:wrap; gap:.45rem; margin-top:.55rem; } + .store-link { color:var(--accent); text-decoration:underline; font-size:.78rem; font-weight:700; padding:.3rem .2rem; } + .store-link:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } + footer { text-align:center; color:var(--sub); font-size:.85rem; padding:2rem; } footer a { color:var(--accent); } /* Browse purpose or search without hiding cards from non-JS crawlers. */ @@ -102,14 +107,17 @@

SheetDecode: Excel Formula Fix

Google Play → - +
Debate Me - AI Debate Practice icon

Debate Me - AI Debate Practice

Practice debating with an AI opponent, get feedback and share your results

- Google Play → +
- +
RoastMe - Community Roasts icon
@@ -126,14 +134,17 @@

Feels Like Weather & Comfort

Google Play →
- +
Dotchi: This or That Polls icon

Dotchi: This or That Polls

Pick A or B on everyday questions. Vote, see results and compare opinions with friends.

- Google Play → +
- +
FileForce - PDF & File Manager icon
@@ -206,14 +217,17 @@

Trend - Anime & Game Picker

Google Play →
- +
Blur: Guess the Image icon

Blur: Guess the Image

Guess the photo as it slowly unblurs — a fun image guessing brain game

- Google Play → +
- +
BrainBurst - Daily Trivia Quiz icon
@@ -574,14 +588,17 @@

Travel Safety Map & Scam Alert

Google Play →
- +
Instant Judge: Vote in 3s icon

Instant Judge: Vote in 3s

Vote on community dilemmas, post your own and see results when revealed

- Google Play → +
- +
KeyClash: Typing Battle icon
@@ -878,9 +895,18 @@

Chaos - Stranger Missions

buttons.forEach(b => b.setAttribute('aria-pressed', String(b === button))); fire('catalog_filter_used', {goal, results: render()}); })); - cards.forEach(card => card.addEventListener('click', () => { - fire('catalog_store_click', {app: slug(card), goal, source: 'apps_catalog'}); - })); + cards.forEach(card => { + const links = card.querySelectorAll?.('a[data-store]') || []; + if (links.length) { + links.forEach(link => link.addEventListener('click', () => { + fire('catalog_store_click', {app: slug(card), goal, source: 'apps_catalog', store: link.dataset.store}); + })); + } else { + card.addEventListener('click', () => { + fire('catalog_store_click', {app: slug(card), goal, source: 'apps_catalog', store: 'google_play'}); + }); + } + }); render(); })(); diff --git a/apps_data.json b/apps_data.json index 2d92f41..1c5bec2 100644 --- a/apps_data.json +++ b/apps_data.json @@ -18,7 +18,8 @@ "pkg": "jp.appdevelopsk.debate_me", "title": "Debate Me - AI Debate Practice", "desc": "Practice debating with an AI opponent, get feedback and share your results", - "installs": 28 + "installs": 28, + "ios_app_id": 6760968208 }, { "app": "roast-me", @@ -39,7 +40,8 @@ "pkg": "com.dotchi.dotchi_app", "title": "Dotchi: This or That Polls", "desc": "Pick A or B on everyday questions. Vote, see results and compare opinions with friends.", - "installs": 22 + "installs": 22, + "ios_app_id": 6760481027 }, { "app": "fileforce", @@ -109,7 +111,8 @@ "pkg": "com.blurapp.blur_app", "title": "Blur: Guess the Image", "desc": "Guess the photo as it slowly unblurs — a fun image guessing brain game", - "installs": 15 + "installs": 15, + "ios_app_id": 6759920723 }, { "app": "brain-burst", @@ -471,7 +474,8 @@ "pkg": "com.instantjudge.instant_judge", "title": "Instant Judge: Vote in 3s", "desc": "Vote on community dilemmas, post your own and see results when revealed", - "installs": 13 + "installs": 13, + "ios_app_id": 6759920352 }, { "app": "key-clash", @@ -699,4 +703,4 @@ "desc": "Daily missions from strangers to break your routine and rediscover the world", "installs": null } -] \ No newline at end of file +] diff --git a/scripts/build_catalog.py b/scripts/build_catalog.py index 9e34827..7830f90 100644 --- a/scripts/build_catalog.py +++ b/scripts/build_catalog.py @@ -51,6 +51,21 @@
""" +# Two independent links only for apps with a verified owner-maintained App Store ID. +# Do not put an anchor inside another anchor; keep crawlers and keyboard users safe. +CARD_DUAL = """
+ {title} icon +
+

{title}

+

{desc}

+ +
+
+""" + LANGS = ["en", "ja", "zh", "zh_TW", "ko", "es", "pt", "fr", "de", "it", "ru", "ar", "th", "id", "tr", "nl", "vi"] @@ -145,8 +160,9 @@ def load_counts(apps, refresh_total): def build_apps_html(t, apps, n): - cards = "".join(CARD.format(pkg=a["pkg"], app=a["app"], title=html.escape(a["title"]), - desc=html.escape(a["desc"])) + cards = "".join((CARD_DUAL if a.get("ios_app_id") else CARD).format( + pkg=a["pkg"], app=a["app"], title=html.escape(a["title"]), + desc=html.escape(a["desc"]), ios_app_id=a.get("ios_app_id", "")) for a in apps if not a.get("hidden")) t = sub1(r'(
\n).*?(
\n )', lambda m: m.group(1) + cards + m.group(2), t, "apps.html の grid", re.S) diff --git a/tests/catalog-growth.test.mjs b/tests/catalog-growth.test.mjs index 32e5fa1..5422788 100644 --- a/tests/catalog-growth.test.mjs +++ b/tests/catalog-growth.test.mjs @@ -12,9 +12,15 @@ test('catalog search retains 92 real indexed store cards and passes install refe const visible=data.filter(x=>!x.hidden); const tags=[...catalog.matchAll(//g)]; + assert.equal(tags.length+dual.length,visible.length); for (const entry of visible) { - assert.ok(tags.some(t=>t[1].includes('id='+entry.pkg)),entry.app); + if (entry.ios_app_id) { + assert.ok(catalog.includes('id='+entry.pkg),entry.app+' Play destination missing'); + assert.ok(catalog.includes('apps.apple.com/app/id'+entry.ios_app_id+'?ct=apps_catalog_'+entry.app),entry.app+' App Store destination missing'); + } else { + assert.ok(tags.some(t=>t[1].includes('id='+entry.pkg)),entry.app); + } } assert.ok(tags.every(t=>t[1].includes('referrer=utm_source%3Dappdevelopsk'))); assert.match(catalog,/id="find-app"/); @@ -51,6 +57,28 @@ test('filters work client-side without tracking raw search text',()=>{ assert.ok(events.some(e=>e[1]==='catalog_store_click'&&e[2].app==='blur')); }); +test('dual-store cards log distinct app-store and Play clicks, not a whole-card click',()=>{ + const source=[...catalog.matchAll(/]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]).find(x=>x.includes('catalog_store_click')); + assert.ok(source); + function el(extra={}){return {events:{},hidden:false,...extra,addEventListener(name,fn){this.events[name]=fn;},setAttribute(key,val){this[key]=val;}}} + const apple=el({dataset:{store:'app_store'}}), play=el({dataset:{store:'google_play'}}); + const dual=el({ + textContent:'Debate Me - AI Debate Practice', + querySelector:()=>({getAttribute:()=> 'icons/apps/debate-me.png'}), + querySelectorAll:()=>[play,apple], + }); + const input=el({value:''}),count=el({textContent:''}),empty=el(); + const buttons=['all','quick-games','habits','decisions','ai'].map(goal=>el({dataset:{goal}})); + const events=[]; + runInNewContext(source,{document:{getElementById(id){return {'find-app':input,'finder-count':count,'finder-empty':empty}[id];},querySelectorAll(q){return q==='.grid .app-card'?[dual]:buttons;}},window:{gtag(...args){events.push(args);}}}); + assert.equal(dual.events.click,undefined,'card body must not masquerade as a store click'); + apple.events.click(); + play.events.click(); + const tracked=events.filter(e=>e[1]==='catalog_store_click'); + assert.deepEqual(tracked.map(e=>e[2].store),['app_store','google_play']); + assert.ok(tracked.every(e=>e[2].app==='debate-me'&&e[2].source==='apps_catalog')); +}); + test('guide landing pages link only to listed published applications and are discoverable',()=>{ for(const slug of guides){ const page=read('guides/'+slug+'/index.html'); @@ -101,7 +129,9 @@ test('public Dotchi catalog aligns with its real two-choice voting product', () assert.match(app.title, /Dotchi: This or That Polls/); assert.match(app.desc, /Pick A or B on everyday questions/); assert.doesNotMatch(app.title + app.desc, /anime|manga|Naruto|Goku/i); - const card = catalog.match(//)?.[0]; + const img = catalog.indexOf('src="icons/apps/twotaku.png"'); + const card = img >= 0 ? catalog.slice(catalog.lastIndexOf('
',img),img+800) : ''; + assert.ok(card, 'missing indexed Dotchi catalog card'); assert.match(card, /Dotchi: This or That Polls/); assert.match(card, /Vote, see results and compare opinions with friends/); diff --git a/tests/catalog-positioning-regression.test.mjs b/tests/catalog-positioning-regression.test.mjs index cd02b6f..fe3a1fa 100644 --- a/tests/catalog-positioning-regression.test.mjs +++ b/tests/catalog-positioning-regression.test.mjs @@ -13,16 +13,27 @@ const highPriority = { }; const encode = value => value.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); -test('catalog cards reproduce the canonical visible app titles and descriptions', () => { +test('catalog cards reproduce canonical titles with one or two valid store destinations', () => { const visible = apps.filter(a=>!a.hidden); - const cards = [...page.matchAll(/([^<]+)<\/h3>\s*

([^<]*)<\/p>[\s\S]*?<\/a>/g)]; - assert.equal(cards.length,visible.length); + assert.equal((page.match(/class="app-card"/g)||[]).length+ + (page.match(/class="app-card app-card-dual"/g)||[]).length,visible.length); for (const a of visible) { - const match=cards.filter(m=>m[1].includes('id='+a.pkg)); - assert.equal(match.length,1,a.app+' missing or duplicate'); - assert.equal(match[0][2],encode(a.title),a.app+' title drift'); - assert.equal(match[0][3],encode(a.desc),a.app+' description drift'); - assert.match(match[0][1],/referrer=utm_source%3Dappdevelopsk/); + const image='src="icons/apps/'+a.app+'.png"'; + const idx=page.indexOf(image); + assert.ok(idx>=0,a.app+' missing catalog icon/card'); + assert.equal(page.split(image).length-1,1,a.app+' duplicate catalog card'); + const start=page.lastIndexOf(a.ios_app_id ? '

' : '' : '\n ',idx); + assert.ok(start>=0&&end>idx,a.app+' card boundary missing'); + const card=page.slice(start,end); + assert.ok(card.includes('

'+encode(a.title)+'

'),a.app+' title drift'); + assert.ok(card.includes('

'+encode(a.desc)+'

'),a.app+' description drift'); + assert.ok(card.includes('id='+a.pkg),a.app+' Play package drift'); + assert.match(card,/referrer=utm_source%3Dappdevelopsk/); + if (a.ios_app_id){ + assert.ok(card.includes('apps.apple.com/app/id'+a.ios_app_id+'?ct=apps_catalog_'+a.app),a.app+' iOS CTA missing'); + assert.ok(card.includes('data-store="app_store"'),a.app+' iOS store label missing'); + } } }); test('five high-traffic-intent entries describe genuinely implemented core value',()=>{