This commit is contained in:
2026-01-16 18:04:33 +09:00
parent 0da23547a6
commit dcb93e2d9a
15 changed files with 182 additions and 24 deletions

View File

@@ -1,13 +1,13 @@
{ {
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e", "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme",
"title": "ailogを作り直した",
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```", "content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
"createdAt": "2026-01-15T13:59:52.367Z", "createdAt": "2026-01-15T13:59:52.367Z",
"title": "ailogを作り直した",
"translations": { "translations": {
"en": { "en": {
"content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```", "title": "recreated ailog",
"title": "recreated ailog" "content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```"
}
} }
},
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -17,5 +17,5 @@
"indexedAt": "2025-09-19T06:17:42.000Z", "indexedAt": "2025-09-19T06:17:42.000Z",
"followersCount": 1, "followersCount": 1,
"followsCount": 1, "followsCount": 1,
"postsCount": 74 "postsCount": 77
} }

View File

@@ -20,15 +20,49 @@
"type": "string", "type": "string",
"maxLength": 1000000, "maxLength": 1000000,
"maxGraphemes": 100000, "maxGraphemes": 100000,
"description": "The content of the post." "description": "The content of the post (markdown)."
}, },
"createdAt": { "createdAt": {
"type": "string", "type": "string",
"format": "datetime", "format": "datetime",
"description": "Client-declared timestamp when this post was originally created." "description": "Client-declared timestamp when this post was originally created."
},
"lang": {
"type": "string",
"maxLength": 10,
"description": "Language code of the original content (e.g., 'ja', 'en')."
},
"translations": {
"type": "ref",
"ref": "#translationMap",
"description": "Translations of the post in other languages."
} }
} }
} }
},
"translationMap": {
"type": "object",
"description": "Map of language codes to translations.",
"properties": {
"en": { "type": "ref", "ref": "#translation" },
"ja": { "type": "ref", "ref": "#translation" }
}
},
"translation": {
"type": "object",
"description": "A translation of a post.",
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000
}
}
} }
} }
} }

View File

@@ -0,0 +1,68 @@
{
"lexicon": 1,
"id": "ai.syui.log.post",
"defs": {
"main": {
"type": "record",
"description": "Record containing a blog post.",
"key": "tid",
"record": {
"type": "object",
"required": ["title", "content", "createdAt"],
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The title of the post."
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000,
"description": "The content of the post (markdown)."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this post was originally created."
},
"lang": {
"type": "string",
"maxLength": 10,
"description": "Language code of the original content (e.g., 'ja', 'en')."
},
"translations": {
"type": "ref",
"ref": "#translationMap",
"description": "Translations of the post in other languages."
}
}
}
},
"translationMap": {
"type": "object",
"description": "Map of language codes to translations.",
"properties": {
"en": { "type": "ref", "ref": "#translation" },
"ja": { "type": "ref", "ref": "#translation" }
}
},
"translation": {
"type": "object",
"description": "A translation of a post.",
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000
}
}
}
}
}

View File

@@ -199,12 +199,14 @@ function getFaviconDir(did: string): string {
async function downloadFavicon(url: string, filepath: string): Promise<boolean> { async function downloadFavicon(url: string, filepath: string): Promise<boolean> {
try { try {
const res = await fetch(url) const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) return false if (!res.ok) return false
const buffer = await res.arrayBuffer() const buffer = await res.arrayBuffer()
if (buffer.byteLength === 0) return false
fs.writeFileSync(filepath, Buffer.from(buffer)) fs.writeFileSync(filepath, Buffer.from(buffer))
return true return true
} catch { } catch (err) {
console.error(`Failed to download ${url}:`, err)
return false return false
} }
} }
@@ -226,8 +228,21 @@ function getServiceDomain(collection: string): string | null {
return null return null
} }
// Common service domains to always download favicons for
const COMMON_SERVICE_DOMAINS = [
'bsky.app',
'syui.ai',
'atproto.com',
'whtwnd.com',
'frontpage.fyi',
'pinksea.art',
'linkat.blue',
'tangled.sh',
'leaflet.pub',
]
function getServiceDomains(collections: string[]): string[] { function getServiceDomains(collections: string[]): string[] {
const domains = new Set<string>() const domains = new Set<string>(COMMON_SERVICE_DOMAINS)
for (const col of collections) { for (const col of collections) {
const domain = getServiceDomain(col) const domain = getServiceDomain(col)
if (domain) domains.add(domain) if (domain) domains.add(domain)
@@ -241,23 +256,24 @@ async function downloadFavicons(did: string, domains: string[]): Promise<void> {
fs.mkdirSync(faviconDir, { recursive: true }) fs.mkdirSync(faviconDir, { recursive: true })
} }
// Known favicon URLs (prefer official sources over Google)
// Others will use Google's favicon API as fallback
const faviconUrls: Record<string, string> = { const faviconUrls: Record<string, string> = {
'bsky.app': 'https://bsky.app/static/favicon-32x32.png', 'bsky.app': 'https://bsky.app/static/favicon-32x32.png',
'syui.ai': 'https://syui.ai/favicon.png', 'syui.ai': 'https://syui.ai/favicon.png',
} }
for (const domain of domains) { for (const domain of domains) {
const url = faviconUrls[domain]
if (!url) continue
const filepath = path.join(faviconDir, `${domain}.png`) const filepath = path.join(faviconDir, `${domain}.png`)
if (!fs.existsSync(filepath)) { if (fs.existsSync(filepath)) continue
// Try known URL first, then fallback to Google's favicon API
const url = faviconUrls[domain] || `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
const ok = await downloadFavicon(url, filepath) const ok = await downloadFavicon(url, filepath)
if (ok) { if (ok) {
console.log(`Downloaded: ${domain}.png`) console.log(`Downloaded: ${domain}.png`)
} }
} }
}
} }
function getLocalFaviconPath(did: string, domain: string): string | null { function getLocalFaviconPath(did: string, domain: string): string | null {

View File

@@ -1,6 +1,17 @@
import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlc } from '../lib/api.js' import { describeRepo, listRecordsRaw, getRecordRaw, fetchLexicon, resolveHandle, getServiceInfo, resolvePds, getPlcForPds } from '../lib/api.js'
import { deleteRecord } from '../lib/auth.js' import { deleteRecord } from '../lib/auth.js'
import { escapeHtml } from '../lib/utils.js' import { escapeHtml } from '../lib/utils.js'
import type { Networks } from '../types.js'
// Cache networks config
let networksConfig: Networks | null = null
async function loadNetworks(): Promise<Networks> {
if (networksConfig) return networksConfig
const res = await fetch('/networks.json')
networksConfig = await res.json()
return networksConfig!
}
function extractRkey(uri: string): string { function extractRkey(uri: string): string {
const parts = uri.split('/') const parts = uri.split('/')
@@ -8,13 +19,15 @@ function extractRkey(uri: string): string {
} }
async function renderServices(did: string, handle: string): Promise<string> { async function renderServices(did: string, handle: string): Promise<string> {
const [collections, pds] = await Promise.all([ const [collections, pds, networks] = await Promise.all([
describeRepo(did), describeRepo(did),
resolvePds(did) resolvePds(did),
loadNetworks()
]) ])
// Server info section // Server info section - use PLC based on PDS
const plcUrl = `${getPlc()}/${did}/log` const plc = getPlcForPds(pds, networks)
const plcUrl = `${plc}/${did}/log`
const serverHtml = ` const serverHtml = `
<div class="server-info"> <div class="server-info">
<h3>Server</h3> <h3>Server</h3>

View File

@@ -14,6 +14,33 @@ export function getPlc(): string {
return networkConfig?.plc || 'https://plc.directory' return networkConfig?.plc || 'https://plc.directory'
} }
// Get PLC URL based on PDS endpoint
export function getPlcForPds(pds: string, networks: Record<string, { plc: string; bsky: string; web?: string }>): string {
// Check if PDS matches any network
for (const [_key, config] of Object.entries(networks)) {
// Match by domain (e.g., "https://syu.is" or "https://bsky.syu.is")
try {
const pdsHost = new URL(pds).hostname
const bskyHost = new URL(config.bsky).hostname
// Check if PDS host matches network's bsky host
if (pdsHost === bskyHost || pdsHost.endsWith('.' + bskyHost)) {
return config.plc
}
// Also check web host if available
if (config.web) {
const webHost = new URL(config.web).hostname
if (pdsHost === webHost || pdsHost.endsWith('.' + webHost)) {
return config.plc
}
}
} catch {
continue
}
}
// Default to plc.directory
return 'https://plc.directory'
}
function getBsky(): string { function getBsky(): string {
return networkConfig?.bsky || 'https://public.api.bsky.app' return networkConfig?.bsky || 'https://public.api.bsky.app'
} }