@@ -85,6 +85,60 @@ const POST_API_URL =
8585 process . env . POST_API_URL ||
8686 "https://script.google.com/macros/s/AKfycbyuVrlM-7-Jps0GuZxLJtGw_y5R2bouUVYapYBhk5-CFL-xUiS8bIYUlw2crFnkcrWg/exec" ;
8787
88+ async function fetchOgp ( url ) {
89+ try {
90+ const res = await fetch ( url ) ;
91+ if ( ! res . ok ) return null ;
92+ const html = await res . text ( ) ;
93+
94+ const getMeta = ( prop ) => {
95+ const regex = new RegExp (
96+ `<meta\\s+(?:property|name)="${ prop } "\\s+content="([^"]+)"` ,
97+ "i" ,
98+ ) ;
99+ const m = html . match ( regex ) ;
100+ return m ? m [ 1 ] : "" ;
101+ } ;
102+
103+ const title =
104+ getMeta ( "og:title" ) ||
105+ html . match ( / < t i t l e > ( [ ^ < ] + ) < \/ t i t l e > / i) ?. [ 1 ] ||
106+ "" ;
107+ const description =
108+ getMeta ( "og:description" ) || getMeta ( "description" ) ;
109+ const image = getMeta ( "og:image" ) ;
110+
111+ let domain = "" ;
112+ try {
113+ domain = new URL ( url ) . hostname ;
114+ } catch ( e ) { }
115+
116+ return { title, description, image, domain, url } ;
117+ } catch ( e ) {
118+ return null ;
119+ }
120+ }
121+
122+ function createLinkCardHtml ( ogp , type ) {
123+ const isWide = type === "WILDCARD" ;
124+
125+ if ( isWide ) {
126+ // WILDCARD: 大きな画像、画像内左下にタイトル、下にドメイン
127+ const imageHtml = ogp . image
128+ ? `<span class="og-image-wide"><img src="${ escapeHtmlAttr ( ogp . image ) } " alt="${ escapeHtmlAttr ( ogp . title ) } " loading="lazy"><span class="og-title-overlay">${ escapeHtml ( ogp . title ) } </span></span>`
129+ : `<span class="og-image-wide no-image"><span class="og-title-overlay">${ escapeHtml ( ogp . title ) } </span></span>` ;
130+
131+ return `<a href="${ escapeHtmlAttr ( ogp . url ) } " class="og-card wide" target="_blank" rel="noopener noreferrer">${ imageHtml } <span class="og-site-footer">${ escapeHtml ( ogp . domain ) } から</span></a>` ;
132+ } else {
133+ // CARD: 左にサムネイル、右にドメイン・タイトル・説明
134+ const imageHtml = ogp . image
135+ ? `<span class="og-thumbnail"><img src="${ escapeHtmlAttr ( ogp . image ) } " alt="${ escapeHtmlAttr ( ogp . title ) } " loading="lazy"></span>`
136+ : `<span class="og-thumbnail no-image"></span>` ;
137+
138+ return `<a href="${ escapeHtmlAttr ( ogp . url ) } " class="og-card" target="_blank" rel="noopener noreferrer">${ imageHtml } <span class="og-content"><span class="og-site">${ escapeHtml ( ogp . domain ) } </span><span class="og-title">${ escapeHtml ( ogp . title ) } </span><span class="og-description">${ escapeHtml ( ogp . description ) } </span></span></a>` ;
139+ }
140+ }
141+
88142// ---------------------------
89143// helper: XML/HTML escape
90144// ---------------------------
@@ -692,7 +746,50 @@ function createHtml({
692746 ) ;
693747
694748 const raw = fs . readFileSync ( sourcePath , "utf8" ) ;
695- const { data, content } = matter ( raw ) ;
749+ const { data, content : rawContent } = matter ( raw ) ;
750+ let content = rawContent ;
751+
752+ // Process Link Cards: [!CARD](url) and [!WILDCARD](url)
753+ const linkCardRegex =
754+ / \[ ! ( C A R D | W I L D C A R D ) \] \( ( .* ?) \) / g;
755+ const matches = [ ...content . matchAll ( linkCardRegex ) ] ;
756+
757+ // Deduplicate matches to avoid redundant fetches
758+ const uniqueMatches = Array . from (
759+ new Map ( matches . map ( ( m ) => [ m [ 0 ] , m ] ) ) . values ( ) ,
760+ ) ;
761+ const replacements = new Map ( ) ;
762+
763+ await Promise . all (
764+ uniqueMatches . map ( async ( match ) => {
765+ const type = match [ 1 ] ;
766+ const url = match [ 2 ] ;
767+ const key = match [ 0 ] ;
768+
769+ try {
770+ const ogp = await fetchOgp ( url ) ;
771+ if ( ogp ) {
772+ const cardHtml = createLinkCardHtml (
773+ ogp ,
774+ type ,
775+ ) ;
776+ replacements . set ( key , cardHtml ) ;
777+ } else {
778+ replacements . set ( key , `[${ url } ](${ url } )` ) ;
779+ }
780+ } catch ( e ) {
781+ logger . warn (
782+ `Failed to fetch OGP for ${ url } : ${ e . message } ` ,
783+ ) ;
784+ replacements . set ( key , `[${ url } ](${ url } )` ) ;
785+ }
786+ } ) ,
787+ ) ;
788+
789+ // Apply replacements globally
790+ for ( const [ key , value ] of replacements ) {
791+ content = content . split ( key ) . join ( value ) ;
792+ }
696793
697794 const headings = [ ] ;
698795 const slugger = new marked . Slugger ( ) ;
0 commit comments