// ==UserScript== // @name Demaecan No Ghosts // @name:ja 出前館ゴースト店舗判定 // @author kuchida1981 // @namespace https://github.com/kuchida1981/demaecan-no-ghosts // @version 0.4.0 // @description Shows shop address details on demae-can.com listing cards and lets you mark/filter ghost-restaurant (delivery-only brand) shops. // @description:ja 出前館の店舗一覧カードから住所などの詳細を確認でき、デリバリー専用ブランド・ゴーストレストランを判定して一覧から非表示にできるユーザースクリプトです。 // @license ISC // @match https://demae-can.com/* // @updateURL https://raw.githubusercontent.com/kuchida1981/demaecan-no-ghosts/stable/demaecan-no-ghosts.user.js // @downloadURL https://raw.githubusercontent.com/kuchida1981/demaecan-no-ghosts/stable/demaecan-no-ghosts.user.js // @run-at document-idle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // ==/UserScript== /** * ⚠️ DO NOT EDIT THIS FILE DIRECTLY ⚠️ * This file is automatically generated by the build process. * Please edit files in the `src/` directory instead and run `npm run build`. */ var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); (function() { "use strict"; const SHOPLIST_ARIA_PATTERN = /^shoplist-(\d+)-shopname$/; const SHOP_MENU_HREF_PATTERN = /\/shop\/menu\/(\d+)/; const SHOP_DETAIL_HREF_PATTERN = /\/shopDetail\/(\d+)/; const ADDRESS_LABEL_TEXT = "住所"; function extractShopIdFromCard(card) { const ariaLabelledBy = card.getAttribute("aria-labelledby"); if (ariaLabelledBy) { const match = SHOPLIST_ARIA_PATTERN.exec(ariaLabelledBy); if (match) { return match[1]; } } const anchor = card.querySelector('a[href*="/shop/menu/"]'); if (anchor) { const href = anchor.getAttribute("href"); const match = SHOP_MENU_HREF_PATTERN.exec(href); if (match) { return match[1]; } } return null; } function extractShopIdFromShopPageUrl(url) { const menuMatch = SHOP_MENU_HREF_PATTERN.exec(url); if (menuMatch) { return menuMatch[1]; } const detailMatch = SHOP_DETAIL_HREF_PATTERN.exec(url); return detailMatch ? detailMatch[1] : null; } function extractAddressFromDetailDocument(doc) { const headings = Array.from(doc.querySelectorAll("h2")); const addressHeading = headings.find((h) => h.textContent.trim() === ADDRESS_LABEL_TEXT); if (!addressHeading) { return null; } const section = addressHeading.closest("section") ?? addressHeading.parentElement; const paragraph = section.querySelector("p"); const text = paragraph == null ? void 0 : paragraph.textContent.trim(); return text ? text : null; } function normalizeAddress(raw) { return raw.normalize("NFKC").trim().replace(/\s+/g, " "); } function buildGoogleMapsUrl(address) { const url = new URL("https://www.google.com/maps/search/"); url.searchParams.set("api", "1"); url.searchParams.set("query", address); return url.toString(); } function buildGoogleSearchUrl(query) { const url = new URL("https://www.google.com/search"); url.searchParams.set("q", query); return url.toString(); } function buildShopDetailUrl(shopId) { return `/shopDetail/${shopId}`; } function buildShopMenuUrl(shopId) { return `/shop/menu/${shopId}`; } function mergeShopRecord(existing, patch) { return { ...existing, ...patch }; } function clearJudgment(record) { if (!record) return void 0; const { judgment: _judgment, judgedAt: _judgedAt, ...rest } = record; return Object.keys(rest).length > 0 ? rest : void 0; } function judgmentKey(record) { if ((record == null ? void 0 : record.judgment) === "ghost") return "ghost"; if ((record == null ? void 0 : record.judgment) === "not-ghost") return "notGhost"; return "unjudged"; } function shouldHideCard(record, visibleJudgments) { return !visibleJudgments[judgmentKey(record)]; } function getBadgeLabel(judgment) { if (judgment === "ghost") return "ゴースト"; if (judgment === "not-ghost") return "実店舗"; return null; } function getIconGlyph(judgment) { if (judgment === "ghost") return "👻"; if (judgment === "not-ghost") return "🏠"; return "i"; } const STORAGE_KEYS = { SHOP_RECORDS: "demaecan-no-ghosts-shop-records", VISIBLE_JUDGMENTS: "demaecan-no-ghosts-visible-judgments", ADDRESS_PREFETCH_ENABLED: "demaecan-no-ghosts-address-prefetch-enabled" }; const DEFAULT_VISIBLE_JUDGMENTS = { ghost: true, notGhost: true, unjudged: true }; const PERSIST_DEBOUNCE_MS = 800; class Store { constructor() { __publicField(this, "state"); __publicField(this, "listeners"); __publicField(this, "persistTimer"); __publicField(this, "_loadShopRecords", () => { const raw = GM_getValue(STORAGE_KEYS.SHOP_RECORDS); if (!raw) return {}; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } }); __publicField(this, "_loadVisibleJudgments", () => { const raw = GM_getValue(STORAGE_KEYS.VISIBLE_JUDGMENTS); if (!raw) return { ...DEFAULT_VISIBLE_JUDGMENTS }; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? { ...DEFAULT_VISIBLE_JUDGMENTS, ...parsed } : { ...DEFAULT_VISIBLE_JUDGMENTS }; } catch { return { ...DEFAULT_VISIBLE_JUDGMENTS }; } }); __publicField(this, "_loadAddressPrefetchEnabled", () => { const raw = GM_getValue(STORAGE_KEYS.ADDRESS_PREFETCH_ENABLED); return raw === void 0 ? true : raw === "true"; }); __publicField(this, "getState", () => { return { ...this.state }; }); __publicField(this, "getShopRecord", (shopId) => { return this.state.shopRecords[shopId]; }); /** * Returns the shopIds of all cached shop records whose address normalizes * to the given normalized address. Shops with no cached address are never * included. */ __publicField(this, "getShopIdsByNormalizedAddress", (normalizedAddress) => { return Object.entries(this.state.shopRecords).filter(([, record]) => record.address !== void 0 && normalizeAddress(record.address) === normalizedAddress).map(([shopId]) => shopId); }); __publicField(this, "updateShopRecord", (shopId, patch) => { const merged = mergeShopRecord(this.state.shopRecords[shopId], patch); this._setShopRecords({ ...this.state.shopRecords, [shopId]: merged }); }); __publicField(this, "clearShopJudgment", (shopId) => { const next = clearJudgment(this.state.shopRecords[shopId]); const shopRecords = { ...this.state.shopRecords }; if (next) { shopRecords[shopId] = next; } else { delete shopRecords[shopId]; } this._setShopRecords(shopRecords); }); __publicField(this, "_setShopRecords", (shopRecords) => { this.state = { ...this.state, shopRecords }; this._schedulePersist(); this._notify(); }); __publicField(this, "_schedulePersist", () => { if (this.persistTimer !== null) { clearTimeout(this.persistTimer); } this.persistTimer = setTimeout(() => { this.persistTimer = null; this._persistShopRecords(); }, PERSIST_DEBOUNCE_MS); }); __publicField(this, "_persistShopRecords", () => { GM_setValue(STORAGE_KEYS.SHOP_RECORDS, JSON.stringify(this.state.shopRecords)); }); /** * Flushes a pending debounced shop-records write immediately. Registered * as a `beforeunload`/`pagehide` handler so a debounced update is not * silently lost when the page is navigated away from. */ __publicField(this, "flush", () => { if (this.persistTimer === null) return; clearTimeout(this.persistTimer); this.persistTimer = null; this._persistShopRecords(); }); __publicField(this, "toggleJudgmentVisibility", (key, visible) => { if (this.state.visibleJudgments[key] === visible) return; const visibleJudgments = { ...this.state.visibleJudgments, [key]: visible }; this.state = { ...this.state, visibleJudgments }; GM_setValue(STORAGE_KEYS.VISIBLE_JUDGMENTS, JSON.stringify(visibleJudgments)); this._notify(); }); __publicField(this, "setAddressPrefetchEnabled", (enabled) => { if (this.state.addressPrefetchEnabled === enabled) return; this.state = { ...this.state, addressPrefetchEnabled: enabled }; GM_setValue(STORAGE_KEYS.ADDRESS_PREFETCH_ENABLED, String(enabled)); this._notify(); }); __publicField(this, "subscribe", (callback) => { this.listeners.push(callback); return () => { this.listeners = this.listeners.filter((l) => l !== callback); }; }); __publicField(this, "_notify", () => { this.listeners.forEach((callback) => { callback(this.getState()); }); }); this.state = { shopRecords: this._loadShopRecords(), visibleJudgments: this._loadVisibleJudgments(), addressPrefetchEnabled: this._loadAddressPrefetchEnabled() }; this.listeners = []; this.persistTimer = null; if (typeof window !== "undefined") { window.addEventListener("beforeunload", this.flush); window.addEventListener("pagehide", this.flush); } } } const SHOP_CARD_SELECTOR = 'article[aria-labelledby^="shoplist-"]'; const SHOP_LINK_SELECTOR = 'a[href*="/shop/menu/"]'; const LINK_CARD_MAX_CLIMB = 8; const FEATURED_IMG_SELECTOR = 'img:not([src*="static-assets/images/"])'; function findLinkCardRoot(anchor) { let node = anchor.parentElement; for (let depth = 0; node && depth < LINK_CARD_MAX_CLIMB; depth += 1) { if (node.querySelector(FEATURED_IMG_SELECTOR)) return node; node = node.parentElement; } return null; } function findExcludedPhotoBlock(anchor) { const img = anchor.querySelector(FEATURED_IMG_SELECTOR); if (!img) return null; let node = img; while (node.parentElement !== anchor) { node = node.parentElement; } return node; } function isTitleCandidate(el) { const hasDirectText = Array.from(el.childNodes).some( (node) => node.nodeType === Node.TEXT_NODE && !!node.textContent && node.textContent.trim().length > 0 ); return hasDirectText && !el.querySelector("img"); } function collectTitleCandidates(container, excluded) { if (isTitleCandidate(container)) return [container]; const children = Array.from(container.children).filter((child) => child !== excluded); if (children.length === 1) return collectTitleCandidates(children[0], excluded); return children.filter(isTitleCandidate); } function findFallbackTitleCandidates(anchor) { return collectTitleCandidates(anchor, findExcludedPhotoBlock(anchor)); } function hasSingleTitleCandidate(anchor) { return findFallbackTitleCandidates(anchor).length === 1; } function getLinkBasedShopCards(container) { const anchors = Array.from(container.querySelectorAll(SHOP_LINK_SELECTOR)); const roots = /* @__PURE__ */ new Set(); for (const anchor of anchors) { if (anchor.closest(SHOP_CARD_SELECTOR)) continue; const root = findLinkCardRoot(anchor); if (root && hasSingleTitleCandidate(anchor)) roots.add(root); } return Array.from(roots); } function isLinkCardRoot(el) { const anchor = el.querySelector(SHOP_LINK_SELECTOR); if (!anchor || anchor.closest(SHOP_CARD_SELECTOR)) return false; return findLinkCardRoot(anchor) === el && hasSingleTitleCandidate(anchor); } const DemaecanListingAdapter = { match: () => true, getListingContainer: () => document.body, getShopCards: (container) => [ ...Array.from(container.querySelectorAll(SHOP_CARD_SELECTOR)), ...getLinkBasedShopCards(container) ], matchesShopCard: (el) => el.matches(SHOP_CARD_SELECTOR) || isLinkCardRoot(el), extractShopId: (card) => extractShopIdFromCard(card), extractShopName: (card) => { const anchor = card.querySelector(SHOP_LINK_SELECTOR); if (!anchor) return null; if (card.matches(SHOP_CARD_SELECTOR)) { const text2 = anchor.textContent; return text2 ? text2.trim() : null; } const candidates = findFallbackTitleCandidates(anchor); if (candidates.length !== 1) return null; const text = candidates[0].textContent; return text ? text.trim() : null; }, /** * Returns the shop-name element itself (as opposed to its text), used to * insert content (e.g. an address label) right after it. Only supported * for `aria-labelledby` cards, whose attribute value doubles as the id of * the element holding the shop name. Link-based fallback cards (e.g. * carousels) return null - the name isn't isolated to its own element. * Looked up within the card itself (rather than `document.getElementById`) * so this also works before the card is attached to the document. */ extractShopNameElement: (card) => { const ariaLabelledBy = card.getAttribute("aria-labelledby"); if (!ariaLabelledBy) return null; return card.querySelector(`[id="${ariaLabelledBy}"]`); } }; const DemaecanShopPageAdapter = { match: (url) => extractShopIdFromShopPageUrl(url) !== null, extractShopId: (url) => extractShopIdFromShopPageUrl(url), getShopName: () => { var _a; const text = (_a = document.querySelector("h1")) == null ? void 0 : _a.textContent; return text ? text.trim() : null; } }; class ShopDetailFetcher { constructor(store) { __publicField(this, "store"); __publicField(this, "pending"); /** * Returns the address for a shop, using the persisted cache when available * and only issuing a network request the first time a shop is looked up. */ __publicField(this, "getAddress", (shopId) => { var _a; const cached = (_a = this.store.getShopRecord(shopId)) == null ? void 0 : _a.address; if (cached) { return Promise.resolve({ status: "cached", address: cached }); } return this._fetchAndCache(shopId); }); /** * Re-fetches and overwrites the cached address regardless of cache state. */ __publicField(this, "refetch", (shopId) => { return this._fetchAndCache(shopId); }); __publicField(this, "_fetchAndCache", (shopId) => { const inFlight = this.pending.get(shopId); if (inFlight) return inFlight; const request = this._request(shopId).finally(() => { this.pending.delete(shopId); }); this.pending.set(shopId, request); return request; }); __publicField(this, "_request", async (shopId) => { try { const res = await fetch(buildShopDetailUrl(shopId)); if (!res.ok) return { status: "error" }; const html = await res.text(); const doc = new DOMParser().parseFromString(html, "text/html"); const address = extractAddressFromDetailDocument(doc); if (!address) return { status: "error" }; this.store.updateShopRecord(shopId, { address, addressFetchedAt: Date.now() }); return { status: "fetched", address }; } catch (error) { console.error(`Failed to fetch shop detail for ${shopId}:`, error); return { status: "error" }; } }); this.store = store; this.pending = /* @__PURE__ */ new Map(); } } class JudgmentManager { constructor(store) { __publicField(this, "store"); __publicField(this, "badges"); __publicField(this, "icons"); __publicField(this, "controls"); __publicField(this, "judge", (shopId, judgment) => { this.store.updateShopRecord(shopId, { judgment, judgedAt: Date.now() }); }); __publicField(this, "clearJudgment", (shopId) => { this.store.clearShopJudgment(shopId); }); /** * Creates (or reuses) a badge element for a shop and keeps it in sync with * the shop's stored judgment. */ __publicField(this, "mountBadge", (shopId) => { const badge = document.createElement("span"); badge.className = "ghosts-badge"; this._registerList(this.badges, shopId, badge); this._renderBadge(badge, shopId); return badge; }); /** * Registers an already-created icon element to keep its glyph in sync * with the shop's stored judgment. */ __publicField(this, "mountIcon", (shopId, icon) => { this._registerList(this.icons, shopId, icon); this._renderIcon(icon, shopId); }); /** * Creates a judgment control widget (ghost / not-ghost / clear) for a shop. */ __publicField(this, "createControls", (shopId) => { const wrapper = document.createElement("div"); wrapper.className = "ghosts-popover__judgment"; const ghostBtn = document.createElement("button"); ghostBtn.type = "button"; ghostBtn.className = "ghosts-judge-btn ghosts-judge-btn--ghost"; ghostBtn.textContent = "ゴースト"; ghostBtn.addEventListener("click", () => { this.judge(shopId, "ghost"); }); const notGhostBtn = document.createElement("button"); notGhostBtn.type = "button"; notGhostBtn.className = "ghosts-judge-btn ghosts-judge-btn--not-ghost"; notGhostBtn.textContent = "実店舗"; notGhostBtn.addEventListener("click", () => { this.judge(shopId, "not-ghost"); }); const clearBtn = document.createElement("button"); clearBtn.type = "button"; clearBtn.className = "ghosts-judge-btn ghosts-judge-btn--clear"; clearBtn.textContent = "解除"; clearBtn.addEventListener("click", () => { this.clearJudgment(shopId); }); wrapper.append(ghostBtn, notGhostBtn, clearBtn); const refs = { shopId, ghostBtn, notGhostBtn, clearBtn }; this._registerList(this.controls, shopId, refs); this._renderControls(refs); return wrapper; }); __publicField(this, "_registerList", (map, shopId, value) => { const list = map.get(shopId) ?? []; list.push(value); map.set(shopId, list); }); __publicField(this, "_renderAll", () => { for (const [shopId, badges] of this.badges) { badges.forEach((badge) => { this._renderBadge(badge, shopId); }); } for (const [shopId, icons] of this.icons) { icons.forEach((icon) => { this._renderIcon(icon, shopId); }); } for (const refsList of this.controls.values()) { refsList.forEach((refs) => { this._renderControls(refs); }); } }); __publicField(this, "_renderBadge", (badge, shopId) => { var _a; const judgment = (_a = this.store.getShopRecord(shopId)) == null ? void 0 : _a.judgment; const label = getBadgeLabel(judgment); badge.textContent = label; badge.style.display = label ? "" : "none"; badge.classList.toggle("ghosts-badge--ghost", judgment === "ghost"); badge.classList.toggle("ghosts-badge--not-ghost", judgment === "not-ghost"); }); __publicField(this, "_renderIcon", (icon, shopId) => { var _a; const judgment = (_a = this.store.getShopRecord(shopId)) == null ? void 0 : _a.judgment; const glyph = getIconGlyph(judgment); icon.textContent = glyph; icon.classList.toggle("ghosts-icon-btn--info", glyph === "i"); }); __publicField(this, "_renderControls", (refs) => { var _a; const judgment = (_a = this.store.getShopRecord(refs.shopId)) == null ? void 0 : _a.judgment; refs.ghostBtn.classList.toggle("is-active", judgment === "ghost"); refs.ghostBtn.setAttribute("aria-pressed", String(judgment === "ghost")); refs.notGhostBtn.classList.toggle("is-active", judgment === "not-ghost"); refs.notGhostBtn.setAttribute("aria-pressed", String(judgment === "not-ghost")); }); this.store = store; this.badges = /* @__PURE__ */ new Map(); this.icons = /* @__PURE__ */ new Map(); this.controls = /* @__PURE__ */ new Map(); this.store.subscribe(() => { this._renderAll(); }); } } const CONCURRENCY_LIMIT = 2; const INTERVAL_MS = 400; class PrefetchQueue { constructor(store, fetcher) { __publicField(this, "store"); __publicField(this, "fetcher"); __publicField(this, "queue"); __publicField(this, "queued"); __publicField(this, "activeCount"); __publicField(this, "enabled"); /** * Adds a shopId to the queue, unless it's already queued/in-flight or * already has a cached address. Enqueueing happens regardless of whether * the prefetch-enabled flag is currently on. */ __publicField(this, "enqueue", (shopId) => { var _a; if (this.queued.has(shopId)) return; if ((_a = this.store.getShopRecord(shopId)) == null ? void 0 : _a.address) return; this.queued.add(shopId); this.queue.push(shopId); this._pump(); }); __publicField(this, "_pump", () => { if (!this.enabled) return; while (this.activeCount < CONCURRENCY_LIMIT && this.queue.length > 0) { const shopId = this.queue.shift(); this.activeCount++; this._processOne(shopId); } }); __publicField(this, "_processOne", (shopId) => { void this.fetcher.getAddress(shopId).finally(() => { this.queued.delete(shopId); setTimeout(() => { this.activeCount--; this._pump(); }, INTERVAL_MS); }); }); this.store = store; this.fetcher = fetcher; this.queue = []; this.queued = /* @__PURE__ */ new Set(); this.activeCount = 0; this.enabled = store.getState().addressPrefetchEnabled; store.subscribe((state) => { const wasEnabled = this.enabled; this.enabled = state.addressPrefetchEnabled; if (this.enabled && !wasEnabled) { this._pump(); } }); } } const HIDDEN_CLASS$1 = "ghosts-address-label--hidden"; const TOOLTIP_OPEN_CLASS = "ghosts-address-tooltip--open"; const HOVER_CLOSE_DELAY_MS$1 = 250; function supportsHover$1() { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; try { return window.matchMedia("(hover: hover)").matches; } catch { return false; } } class AddressLabelManager { constructor(adapter, store) { __publicField(this, "adapter"); __publicField(this, "store"); __publicField(this, "registrations"); /** * Inserts an address label after the card's shop-name element, if one can * be identified (aria-labelledby cards only - link-based fallback cards * are skipped). */ __publicField(this, "decorateCard", (shopId, card) => { const nameEl = this.adapter.extractShopNameElement(card); if (!nameEl) return; const label = document.createElement("p"); label.className = "ghosts-address-label"; nameEl.insertAdjacentElement("afterend", label); const closeTooltip = this._wireTooltip(shopId, label); this.registrations.push({ shopId, label, closeTooltip }); this._renderLabel(shopId, label); }); __publicField(this, "_renderAll", () => { const enabled = this.store.getState().addressPrefetchEnabled; this.registrations.forEach(({ shopId, label, closeTooltip }) => { this._renderLabel(shopId, label); if (!enabled) closeTooltip(); }); }); __publicField(this, "_renderLabel", (shopId, label) => { var _a; label.textContent = ((_a = this.store.getShopRecord(shopId)) == null ? void 0 : _a.address) ?? ""; label.classList.toggle(HIDDEN_CLASS$1, !this.store.getState().addressPrefetchEnabled); }); /** * Mounts the tooltip directly under `document.body` (as a fixed-position * element positioned via JS) rather than inside the card. Each shop card * has its own stacking context (see issue #19), so a tooltip absolutely * positioned inside a card can't escape that card's bounds - it would be * covered by an adjacent card's own content when it overflows past the * card's edge. Living at the body level and using viewport coordinates * sidesteps that entirely. */ __publicField(this, "_wireTooltip", (shopId, label) => { const tooltip = document.createElement("div"); tooltip.className = "ghosts-address-tooltip"; document.body.appendChild(tooltip); let closeTimer; const clearCloseTimer = () => { if (closeTimer === void 0) return; clearTimeout(closeTimer); closeTimer = void 0; }; const open = () => { var _a; clearCloseTimer(); const address = (_a = this.store.getShopRecord(shopId)) == null ? void 0 : _a.address; if (!address) return; const others = this.store.getShopIdsByNormalizedAddress(normalizeAddress(address)).filter((otherId) => otherId !== shopId); if (others.length === 0) return; tooltip.replaceChildren( ...others.map((otherId) => { var _a2; const button = document.createElement("button"); button.type = "button"; button.className = "ghosts-address-tooltip__link"; button.textContent = ((_a2 = this.store.getShopRecord(otherId)) == null ? void 0 : _a2.name) ?? otherId; button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); window.open(buildShopMenuUrl(otherId), "_blank", "noopener,noreferrer"); }); return button; }) ); tooltip.classList.add(TOOLTIP_OPEN_CLASS); this._positionTooltip(label, tooltip); }; const close = () => { clearCloseTimer(); tooltip.classList.remove(TOOLTIP_OPEN_CLASS); }; const scheduleClose = () => { clearCloseTimer(); closeTimer = setTimeout(close, HOVER_CLOSE_DELAY_MS$1); }; label.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (tooltip.classList.contains(TOOLTIP_OPEN_CLASS)) { close(); } else { open(); } }); if (supportsHover$1()) { label.addEventListener("mouseenter", open); label.addEventListener("mouseleave", scheduleClose); tooltip.addEventListener("mouseenter", clearCloseTimer); tooltip.addEventListener("mouseleave", scheduleClose); } window.addEventListener( "scroll", () => { if (tooltip.classList.contains(TOOLTIP_OPEN_CLASS)) close(); }, { passive: true, capture: true } ); return close; }); /** * Positions the (already-open, so measurable) fixed tooltip against the * label's viewport coordinates, opening upward if there isn't enough room * below in the viewport. */ __publicField(this, "_positionTooltip", (label, tooltip) => { const labelRect = label.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); tooltip.style.left = `${labelRect.left}px`; if (labelRect.bottom + tooltipRect.height > window.innerHeight) { tooltip.style.top = `${Math.max(0, labelRect.top - tooltipRect.height)}px`; } else { tooltip.style.top = `${labelRect.bottom}px`; } }); this.adapter = adapter; this.store = store; this.registrations = []; this.store.subscribe(() => { this._renderAll(); }); } } function buildAddressBlock(shopId, shopName, fetcher) { const addressEl = document.createElement("p"); addressEl.className = "ghosts-popover__address"; const linksEl = document.createElement("p"); linksEl.className = "ghosts-popover__links"; linksEl.style.display = "none"; const mapLink = document.createElement("a"); mapLink.textContent = "地図"; mapLink.target = "_blank"; mapLink.rel = "noopener noreferrer"; const searchLink = document.createElement("a"); searchLink.textContent = "検索"; searchLink.target = "_blank"; searchLink.rel = "noopener noreferrer"; searchLink.href = buildGoogleSearchUrl(shopName); linksEl.append(mapLink, searchLink); const renderResult = (result) => { if (result.status === "error") { addressEl.textContent = "住所を取得できませんでした"; linksEl.style.display = "none"; return; } addressEl.textContent = result.address; mapLink.href = buildGoogleMapsUrl(result.address); linksEl.style.display = ""; }; const load = (forceRefetch) => { addressEl.textContent = "読み込み中..."; linksEl.style.display = "none"; const promise = forceRefetch ? fetcher.refetch(shopId) : fetcher.getAddress(shopId); void promise.then(renderResult); }; const refetchBtn = document.createElement("button"); refetchBtn.type = "button"; refetchBtn.className = "ghosts-popover__refetch"; refetchBtn.textContent = "住所を再取得"; refetchBtn.addEventListener("click", () => { load(true); }); return { addressEl, linksEl, refetchBtn, load }; } const STYLE_ELEMENT_ID = "demaecan-no-ghosts-style"; const styles = ` .ghosts-icon-btn { all: unset; position: absolute; top: 0.375rem; right: 0.375rem; z-index: 2147483000; display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: 9999px; background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 1.125rem; font-weight: 700; cursor: pointer; box-sizing: border-box; } .ghosts-icon-btn:hover, .ghosts-icon-btn:focus-visible { background: rgba(0, 0, 0, 0.8); } .ghosts-icon-btn--info { font-style: italic; } .ghosts-popover { display: none; position: absolute; top: 2.375rem; right: 0.375rem; z-index: 2147483000; width: 15rem; max-width: calc(100vw - 1.5rem); background: #fff; color: #111; border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 0.5rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); padding: 0.625rem; font-size: 0.75rem; line-height: 1.4; text-align: left; } .ghosts-popover--open { display: block; } .ghosts-popover__shop-name { font-weight: 700; margin: 0 0 0.375rem; } .ghosts-popover__address { margin: 0 0 0.5rem; min-height: 1.4em; } .ghosts-popover__links { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; } .ghosts-popover__links a { color: #1a73e8; text-decoration: underline; } .ghosts-popover__refetch { all: unset; cursor: pointer; color: #1a73e8; text-decoration: underline; font-size: 0.75rem; margin-bottom: 0.5rem; display: inline-block; } .ghosts-popover__judgment { display: flex; gap: 0.375rem; border-top: 1px solid rgba(0, 0, 0, 0.1); padding-top: 0.5rem; } .ghosts-judge-btn { all: unset; cursor: pointer; box-sizing: border-box; flex: 1; text-align: center; padding: 0.25rem 0.375rem; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.2); font-size: 0.6875rem; } .ghosts-judge-btn--ghost.is-active { background: #da3734; color: #fff; border-color: #da3734; } .ghosts-judge-btn--not-ghost.is-active { background: #2e7d32; color: #fff; border-color: #2e7d32; } .ghosts-badge { position: absolute; top: 0.375rem; left: 0.375rem; z-index: 2147483000; padding: 0.0625rem 0.375rem; border-radius: 9999px; font-size: 0.625rem; font-weight: 700; color: #fff; } .ghosts-badge--ghost { background: #da3734; } .ghosts-badge--not-ghost { background: #2e7d32; } .ghosts-hidden { display: none !important; } .ghosts-address-label { /* demae-can's own shop-name link has a click-area-expanding ::after overlay (position absolute, inset 0) covering the whole card, which otherwise sits above this label and swallows its hover and click events. Lifting the label above it (within the card's own stacking context - see issue #19) keeps pointer events reaching it. */ position: relative; z-index: 2147483000; margin: 0; font-size: 0.6875rem; color: rgba(0, 0, 0, 0.6); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } .ghosts-address-label--hidden { display: none; } .ghosts-address-tooltip { display: none; position: fixed; z-index: 2147483000; min-width: 10rem; max-width: calc(100vw - 1.5rem); background: #fff; color: #111; border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 0.5rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); padding: 0.375rem; font-size: 0.75rem; text-align: left; white-space: normal; } .ghosts-address-tooltip--open { display: flex; flex-direction: column; gap: 0.125rem; } .ghosts-address-tooltip__link { all: unset; cursor: pointer; box-sizing: border-box; width: 100%; color: #1a73e8; text-decoration: underline; text-align: left; padding: 0.125rem 0.25rem; border-radius: 0.25rem; } .ghosts-address-tooltip__link:hover, .ghosts-address-tooltip__link:focus-visible { background: rgba(26, 115, 232, 0.1); } .ghosts-filter-panel { position: fixed; right: 1rem; bottom: 1rem; z-index: 2147483000; display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 0.5rem; background: rgba(0, 0, 0, 0.75); color: #fff; font-size: 0.75rem; } .ghosts-filter-panel label { display: flex; align-items: center; gap: 0.375rem; cursor: pointer; } .ghosts-shop-page-panel { position: fixed; left: 1rem; bottom: 1rem; z-index: 2147483000; display: flex; flex-direction: column; gap: 0.375rem; width: 14rem; padding: 0.625rem; border-radius: 0.5rem; background: rgba(20, 20, 20, 0.96); color: #fff; font-size: 0.75rem; } .ghosts-shop-page-panel__title { margin: 0; font-weight: 700; } .ghosts-shop-page-panel .ghosts-badge { position: static; align-self: flex-start; } .ghosts-shop-page-panel .ghosts-popover__address, .ghosts-shop-page-panel .ghosts-popover__links, .ghosts-shop-page-panel .ghosts-popover__refetch { margin: 0; } .ghosts-shop-page-panel .ghosts-popover__address { word-break: break-word; } .ghosts-shop-page-panel .ghosts-popover__links { flex-wrap: wrap; } .ghosts-shop-page-panel .ghosts-judge-btn { white-space: nowrap; } `; function injectStyles() { if (document.getElementById(STYLE_ELEMENT_ID)) return; const style = document.createElement("style"); style.id = STYLE_ELEMENT_ID; style.textContent = styles; document.head.appendChild(style); } const DECORATED_ATTR = "data-ghosts-decorated"; const HOVER_CLOSE_DELAY_MS = 250; function supportsHover() { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; try { return window.matchMedia("(hover: hover)").matches; } catch { return false; } } class CardOverlayManager { constructor(adapter, fetcher, judgmentManager, store, prefetchQueue, addressLabelManager, onDecorate) { __publicField(this, "adapter"); __publicField(this, "fetcher"); __publicField(this, "judgmentManager"); __publicField(this, "store"); __publicField(this, "prefetchQueue"); __publicField(this, "addressLabelManager"); __publicField(this, "onDecorate"); __publicField(this, "registrations"); __publicField(this, "hoverEnabled"); __publicField(this, "init", () => { injectStyles(); const container = this.adapter.getListingContainer(); if (!container) return; this.adapter.getShopCards(container).forEach(this.decorateCard); const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { mutation.addedNodes.forEach((node) => { if (!(node instanceof HTMLElement)) return; this._collectCards(node).forEach(this.decorateCard); }); } }); observer.observe(container, { childList: true, subtree: true }); document.addEventListener("click", this._handleOutsideClick); }); __publicField(this, "_collectCards", (node) => { const descendants = this.adapter.getShopCards(node); return this.adapter.matchesShopCard(node) ? [node, ...descendants] : descendants; }); __publicField(this, "decorateCard", (card) => { var _a; if (card.hasAttribute(DECORATED_ATTR)) return; const shopId = this.adapter.extractShopId(card); if (!shopId) return; card.setAttribute(DECORATED_ATTR, "true"); this._ensurePositioned(card); const shopName = this.adapter.extractShopName(card) ?? ""; this._cacheShopName(shopId, shopName); this.prefetchQueue.enqueue(shopId); this.addressLabelManager.decorateCard(shopId, card); const { icon, popover, load } = this._buildPopover(shopId, shopName); card.append(icon, popover); this._wireEvents(card, icon, popover, load); (_a = this.onDecorate) == null ? void 0 : _a.call(this, shopId, card); }); /** * Caches the shop name the first time it's observed for a shopId. Once * cached, later detections of the same shop's card (e.g. re-rendered by * the host page) don't overwrite it. */ __publicField(this, "_cacheShopName", (shopId, shopName) => { var _a; if (!shopName) return; if ((_a = this.store.getShopRecord(shopId)) == null ? void 0 : _a.name) return; this.store.updateShopRecord(shopId, { name: shopName }); }); __publicField(this, "_ensurePositioned", (card) => { const position = window.getComputedStyle(card).position; if (!position || position === "static") { card.style.position = "relative"; card.style.zIndex = "0"; } }); __publicField(this, "_buildPopover", (shopId, shopName) => { const icon = document.createElement("button"); icon.type = "button"; icon.className = "ghosts-icon-btn"; icon.setAttribute("aria-label", `${shopName}の詳細情報を表示`); this.judgmentManager.mountIcon(shopId, icon); const popover = document.createElement("div"); popover.className = "ghosts-popover"; popover.addEventListener("click", (event) => { event.stopPropagation(); }); const nameEl = document.createElement("p"); nameEl.className = "ghosts-popover__shop-name"; nameEl.textContent = shopName; const { addressEl, linksEl, refetchBtn, load } = buildAddressBlock(shopId, shopName, this.fetcher); const controls = this.judgmentManager.createControls(shopId); popover.append(nameEl, addressEl, linksEl, refetchBtn, controls); return { icon, popover, load }; }); __publicField(this, "_wireEvents", (card, icon, popover, load) => { let closeTimer; const clearCloseTimer = () => { if (closeTimer === void 0) return; clearTimeout(closeTimer); closeTimer = void 0; }; const open = () => { clearCloseTimer(); if (popover.classList.contains("ghosts-popover--open")) return; popover.classList.add("ghosts-popover--open"); load(false); }; const close = () => { clearCloseTimer(); popover.classList.remove("ghosts-popover--open"); }; const scheduleClose = () => { clearCloseTimer(); closeTimer = setTimeout(close, HOVER_CLOSE_DELAY_MS); }; icon.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (popover.classList.contains("ghosts-popover--open")) { close(); } else { open(); } }); if (this.hoverEnabled) { icon.addEventListener("mouseenter", open); icon.addEventListener("mouseleave", scheduleClose); popover.addEventListener("mouseenter", clearCloseTimer); popover.addEventListener("mouseleave", scheduleClose); } this.registrations.push({ card, popover, close }); }); __publicField(this, "_handleOutsideClick", (event) => { const target = event.target; this.registrations.forEach(({ card, popover, close }) => { if (popover.classList.contains("ghosts-popover--open") && !card.contains(target)) { close(); } }); }); this.adapter = adapter; this.fetcher = fetcher; this.judgmentManager = judgmentManager; this.store = store; this.prefetchQueue = prefetchQueue; this.addressLabelManager = addressLabelManager; this.onDecorate = onDecorate; this.registrations = []; this.hoverEnabled = supportsHover(); } } const LOCATIONCHANGE_EVENT = "ghosts-locationchange"; const POLL_INTERVAL_MS = 1e3; let historyPatched = false; function patchHistory() { if (historyPatched) return; historyPatched = true; const originalPushState = history.pushState.bind(history); const originalReplaceState = history.replaceState.bind(history); history.pushState = ((...args) => { originalPushState(...args); window.dispatchEvent(new Event(LOCATIONCHANGE_EVENT)); }); history.replaceState = ((...args) => { originalReplaceState(...args); window.dispatchEvent(new Event(LOCATIONCHANGE_EVENT)); }); } function onRouteChange(callback) { patchHistory(); let lastUrl = window.location.href; const checkForChange = () => { const currentUrl = window.location.href; if (currentUrl === lastUrl) return; lastUrl = currentUrl; callback(currentUrl); }; window.addEventListener(LOCATIONCHANGE_EVENT, checkForChange); window.addEventListener("popstate", checkForChange); const intervalId = setInterval(checkForChange, POLL_INTERVAL_MS); return () => { window.removeEventListener(LOCATIONCHANGE_EVENT, checkForChange); window.removeEventListener("popstate", checkForChange); clearInterval(intervalId); }; } const HIDDEN_CLASS = "ghosts-hidden"; const JUDGMENT_CHECKBOX_LABELS = [ { key: "ghost", text: "ゴースト" }, { key: "notGhost", text: "実店舗" }, { key: "unjudged", text: "未評価" } ]; class FilterManager { constructor(store, adapter) { __publicField(this, "store"); __publicField(this, "adapter"); __publicField(this, "registrations"); __publicField(this, "checkboxes"); __publicField(this, "addressCheckbox"); __publicField(this, "panel"); __publicField(this, "mounted"); __publicField(this, "unsubscribeRouteWatcher"); __publicField(this, "init", () => { injectStyles(); this._sync(window.location.href); this.unsubscribeRouteWatcher = onRouteChange(this._sync); }); /** * Stops watching for route changes. Not used in production (the manager * lives for the page's lifetime) but keeps tests isolated from each other. */ __publicField(this, "destroy", () => { var _a; (_a = this.unsubscribeRouteWatcher) == null ? void 0 : _a.call(this); this.unsubscribeRouteWatcher = null; }); /** * Registers a shop card so its visibility tracks the filter and the shop's * judgment. Applies the current state immediately. */ __publicField(this, "registerCard", (shopId, card) => { this.registrations.push({ shopId, card }); this._applyCard(shopId, card); }); __publicField(this, "_sync", (url) => { const shouldShow = !this.adapter.match(url); if (shouldShow === this.mounted) return; if (shouldShow) { this._mountPanel(); } else { this._removePanel(); } this.mounted = shouldShow; }); __publicField(this, "_mountPanel", () => { const panel = document.createElement("div"); panel.className = "ghosts-filter-panel"; JUDGMENT_CHECKBOX_LABELS.forEach(({ key, text }) => { const label = document.createElement("label"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = this.store.getState().visibleJudgments[key]; checkbox.addEventListener("change", () => { this.store.toggleJudgmentVisibility(key, checkbox.checked); }); this.checkboxes[key] = checkbox; const span = document.createElement("span"); span.textContent = text; label.append(checkbox, span); panel.append(label); }); const addressLabel = document.createElement("label"); const addressCheckbox = document.createElement("input"); addressCheckbox.type = "checkbox"; addressCheckbox.checked = this.store.getState().addressPrefetchEnabled; addressCheckbox.addEventListener("change", () => { this.store.setAddressPrefetchEnabled(addressCheckbox.checked); }); this.addressCheckbox = addressCheckbox; const addressSpan = document.createElement("span"); addressSpan.textContent = "住所表示"; addressLabel.append(addressCheckbox, addressSpan); panel.append(addressLabel); document.body.appendChild(panel); this.panel = panel; }); __publicField(this, "_removePanel", () => { var _a; (_a = this.panel) == null ? void 0 : _a.remove(); this.panel = null; this.checkboxes = {}; this.addressCheckbox = void 0; }); __publicField(this, "_applyAll", () => { const visibleJudgments = this.store.getState().visibleJudgments; JUDGMENT_CHECKBOX_LABELS.forEach(({ key }) => { const checkbox = this.checkboxes[key]; if (checkbox) { checkbox.checked = visibleJudgments[key]; } }); this.registrations.forEach(({ shopId, card }) => { this._applyCard(shopId, card); }); if (this.addressCheckbox) { this.addressCheckbox.checked = this.store.getState().addressPrefetchEnabled; } }); __publicField(this, "_applyCard", (shopId, card) => { const hide = shouldHideCard(this.store.getShopRecord(shopId), this.store.getState().visibleJudgments); card.classList.toggle(HIDDEN_CLASS, hide); }); this.store = store; this.adapter = adapter; this.registrations = []; this.checkboxes = {}; this.panel = null; this.mounted = false; this.unsubscribeRouteWatcher = null; this.store.subscribe(() => { this._applyAll(); }); } } const PANEL_CLASS = "ghosts-shop-page-panel"; const PANEL_TITLE = "ゴースト店舗判定"; class ShopPageManager { constructor(adapter, judgmentManager, fetcher) { __publicField(this, "adapter"); __publicField(this, "judgmentManager"); __publicField(this, "fetcher"); __publicField(this, "panel"); __publicField(this, "currentShopId"); __publicField(this, "unsubscribeRouteWatcher"); __publicField(this, "init", () => { this._sync(window.location.href); this.unsubscribeRouteWatcher = onRouteChange(this._sync); }); /** * Stops watching for route changes. Not used in production (the manager * lives for the page's lifetime) but keeps tests isolated from each other. */ __publicField(this, "destroy", () => { var _a; (_a = this.unsubscribeRouteWatcher) == null ? void 0 : _a.call(this); this.unsubscribeRouteWatcher = null; }); __publicField(this, "_sync", (url) => { const shopId = this.adapter.match(url) ? this.adapter.extractShopId(url) : null; if (shopId === this.currentShopId) return; this._removePanel(); this.currentShopId = shopId; if (shopId) { this._mountPanel(shopId); } }); __publicField(this, "_mountPanel", (shopId) => { const panel = document.createElement("div"); panel.className = PANEL_CLASS; const title = document.createElement("p"); title.className = `${PANEL_CLASS}__title`; title.textContent = PANEL_TITLE; const badge = this.judgmentManager.mountBadge(shopId); const shopName = this.adapter.getShopName() ?? ""; const { addressEl, linksEl, refetchBtn, load } = buildAddressBlock(shopId, shopName, this.fetcher); const controls = this.judgmentManager.createControls(shopId); panel.append(title, badge, addressEl, linksEl, refetchBtn, controls); document.body.appendChild(panel); this.panel = panel; load(false); }); __publicField(this, "_removePanel", () => { var _a; (_a = this.panel) == null ? void 0 : _a.remove(); this.panel = null; }); this.adapter = adapter; this.judgmentManager = judgmentManager; this.fetcher = fetcher; this.panel = null; this.currentShopId = null; this.unsubscribeRouteWatcher = null; } } class App { constructor() { __publicField(this, "store"); __publicField(this, "fetcher"); __publicField(this, "judgmentManager"); __publicField(this, "prefetchQueue"); __publicField(this, "addressLabelManager"); __publicField(this, "filterManager"); __publicField(this, "cardOverlayManager"); __publicField(this, "shopPageManager"); __publicField(this, "init", () => { injectStyles(); this.filterManager.init(); this.cardOverlayManager.init(); this.shopPageManager.init(); }); this.store = new Store(); this.fetcher = new ShopDetailFetcher(this.store); this.judgmentManager = new JudgmentManager(this.store); this.prefetchQueue = new PrefetchQueue(this.store, this.fetcher); this.addressLabelManager = new AddressLabelManager(DemaecanListingAdapter, this.store); this.filterManager = new FilterManager(this.store, DemaecanShopPageAdapter); this.cardOverlayManager = new CardOverlayManager( DemaecanListingAdapter, this.fetcher, this.judgmentManager, this.store, this.prefetchQueue, this.addressLabelManager, (shopId, card) => { this.filterManager.registerCard(shopId, card); } ); this.shopPageManager = new ShopPageManager(DemaecanShopPageAdapter, this.judgmentManager, this.fetcher); } } const app = new App(); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", app.init); } else { app.init(); } })();