@ewanc26/atproto 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/package.json +29 -0
- package/src/agents.ts +127 -0
- package/src/cache.ts +63 -0
- package/src/documents.ts +247 -0
- package/src/engagement.ts +56 -0
- package/src/fetch.ts +355 -0
- package/src/index.ts +74 -0
- package/src/media.ts +106 -0
- package/src/musicbrainz.ts +188 -0
- package/src/pagination/fetchAllRecords.ts +57 -0
- package/src/pagination/index.ts +2 -0
- package/src/posts.ts +197 -0
- package/src/types.ts +272 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @ewanc26/atproto
|
|
2
|
+
|
|
3
|
+
AT Protocol service layer extracted from [ewancroft.uk](https://ewancroft.uk). Handles identity resolution, record fetching, Bluesky posts, Standard.site documents, music/mood status, and more — with a built-in in-memory cache.
|
|
4
|
+
|
|
5
|
+
**Key difference from the app's internal service layer:** all functions accept `did: string` as their first argument rather than reading `PUBLIC_ATPROTO_DID` from the environment.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @ewanc26/atproto
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires `@atproto/api >= 0.13.0` as a peer dependency.
|
|
14
|
+
|
|
15
|
+
## What's Exported
|
|
16
|
+
|
|
17
|
+
### Fetch Functions
|
|
18
|
+
|
|
19
|
+
| Function | Description |
|
|
20
|
+
|----------|-------------|
|
|
21
|
+
| `fetchProfile(did)` | Bluesky profile including pronouns |
|
|
22
|
+
| `fetchSiteInfo(did)` | `uk.ewancroft.site.info` record |
|
|
23
|
+
| `fetchLinks(did)` | `blue.linkat.board` link cards |
|
|
24
|
+
| `fetchMusicStatus(did)` | teal.fm music status with cascading artwork lookup |
|
|
25
|
+
| `fetchKibunStatus(did)` | kibun.social mood status |
|
|
26
|
+
| `fetchTangledRepos(did)` | Tangled code repositories |
|
|
27
|
+
| `fetchPublications(did)` | Standard.site publications |
|
|
28
|
+
| `fetchDocuments(did, pubRkey)` | Standard.site documents for a publication |
|
|
29
|
+
| `fetchBlogPosts(did, pubRkey)` | Blog posts for a publication |
|
|
30
|
+
| `fetchRecentDocuments(did, limit)` | Most recent documents across all publications |
|
|
31
|
+
| `fetchLatestBlueskyPost(did)` | Latest non-reply Bluesky post with thread context |
|
|
32
|
+
| `fetchPostFromUri(did, uri)` | Single Bluesky post by AT URI |
|
|
33
|
+
| `fetchAllEngagement(uris)` | Like/repost counts via Constellation API |
|
|
34
|
+
|
|
35
|
+
### Agents & Identity
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { resolveIdentity, getPublicAgent, getPDSAgent, createAgent, withFallback, resetAgents } from '@ewanc26/atproto';
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Pagination
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { fetchAllRecords, fetchAllUserRecords } from '@ewanc26/atproto';
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Cache
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { cache, ATProtoCache, CACHE_TTL } from '@ewanc26/atproto';
|
|
51
|
+
|
|
52
|
+
cache.clear();
|
|
53
|
+
cache.delete('profile:did:plc:…');
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Music Artwork
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { findArtwork, searchMusicBrainzRelease, buildCoverArtUrl } from '@ewanc26/atproto';
|
|
60
|
+
// Cascading: MusicBrainz → iTunes → Deezer → Last.fm → PDS blob
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Media
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from '@ewanc26/atproto';
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Types
|
|
70
|
+
|
|
71
|
+
All interfaces (`ProfileData`, `BlueskyPost`, `BlogPost`, `MusicStatusData`, `KibunStatusData`, `TangledRepo`, `StandardSiteDocument`, `StandardSitePublication`, `SiteInfoData`, `LinkData`, `ResolvedIdentity`, …) are exported from the package root.
|
|
72
|
+
|
|
73
|
+
## Build
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pnpm build # tsc
|
|
77
|
+
pnpm dev # tsc --watch
|
|
78
|
+
pnpm check # tsc --noEmit
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Licence
|
|
82
|
+
|
|
83
|
+
See the root [LICENSE](../../LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ewanc26/atproto",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AT Protocol service layer extracted from ewancroft.uk",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"source": "./src/index.ts",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"publishConfig": { "access": "public" },
|
|
16
|
+
"files": ["dist", "src"],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc --project tsconfig.json",
|
|
19
|
+
"dev": "tsc --project tsconfig.json --watch",
|
|
20
|
+
"check": "tsc --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@atproto/api": ">=0.13.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@atproto/api": "^0.18.1",
|
|
27
|
+
"typescript": "^5.9.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/agents.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { AtpAgent } from '@atproto/api';
|
|
2
|
+
import type { ResolvedIdentity } from './types.js';
|
|
3
|
+
import { cache } from './cache.js';
|
|
4
|
+
|
|
5
|
+
export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent {
|
|
6
|
+
const wrappedFetch = fetchFn
|
|
7
|
+
? async (url: URL | RequestInfo, init?: RequestInit) => {
|
|
8
|
+
const urlStr = url instanceof URL ? url.toString() : url;
|
|
9
|
+
const response = await fetchFn(urlStr, init);
|
|
10
|
+
const headers = new Headers(response.headers);
|
|
11
|
+
if (!headers.has('content-type')) {
|
|
12
|
+
headers.set('content-type', 'application/json');
|
|
13
|
+
}
|
|
14
|
+
return new Response(response.body, {
|
|
15
|
+
status: response.status,
|
|
16
|
+
statusText: response.statusText,
|
|
17
|
+
headers
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
: undefined;
|
|
21
|
+
|
|
22
|
+
return new AtpAgent({
|
|
23
|
+
service,
|
|
24
|
+
...(wrappedFetch && { fetch: wrappedFetch })
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const constellationAgent = createAgent('https://constellation.microcosm.blue');
|
|
29
|
+
export const defaultAgent = createAgent('https://public.api.bsky.app');
|
|
30
|
+
|
|
31
|
+
let resolvedAgent: AtpAgent | null = null;
|
|
32
|
+
let pdsAgent: AtpAgent | null = null;
|
|
33
|
+
|
|
34
|
+
export async function resolveIdentity(
|
|
35
|
+
did: string,
|
|
36
|
+
fetchFn?: typeof fetch
|
|
37
|
+
): Promise<ResolvedIdentity> {
|
|
38
|
+
const cacheKey = `identity:${did}`;
|
|
39
|
+
const cached = cache.get<ResolvedIdentity>(cacheKey);
|
|
40
|
+
if (cached) return cached;
|
|
41
|
+
|
|
42
|
+
const _fetch = fetchFn ?? globalThis.fetch;
|
|
43
|
+
const response = await _fetch(
|
|
44
|
+
`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rawText = await response.text();
|
|
54
|
+
let data: any;
|
|
55
|
+
try {
|
|
56
|
+
data = JSON.parse(rawText);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error('Failed to parse identity resolver response');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!data.did || !data.pds) {
|
|
62
|
+
throw new Error('Invalid response from identity resolver');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cache.set(cacheKey, data);
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> {
|
|
70
|
+
if (resolvedAgent) return resolvedAgent;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
try {
|
|
74
|
+
const response = await constellationAgent.getProfile({ actor: did });
|
|
75
|
+
if (response.success) {
|
|
76
|
+
resolvedAgent = constellationAgent;
|
|
77
|
+
return resolvedAgent;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// fall through
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const resolved = await resolveIdentity(did, fetchFn);
|
|
84
|
+
resolvedAgent = createAgent(resolved.pds, fetchFn);
|
|
85
|
+
return resolvedAgent;
|
|
86
|
+
} catch {
|
|
87
|
+
resolvedAgent = defaultAgent;
|
|
88
|
+
return resolvedAgent;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> {
|
|
93
|
+
if (pdsAgent) return pdsAgent;
|
|
94
|
+
const resolved = await resolveIdentity(did, fetchFn);
|
|
95
|
+
pdsAgent = createAgent(resolved.pds, fetchFn);
|
|
96
|
+
return pdsAgent;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function withFallback<T>(
|
|
100
|
+
did: string,
|
|
101
|
+
operation: (agent: AtpAgent) => Promise<T>,
|
|
102
|
+
usePDSFirst = false,
|
|
103
|
+
fetchFn?: typeof fetch
|
|
104
|
+
): Promise<T> {
|
|
105
|
+
const defaultAgentFn = () =>
|
|
106
|
+
fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent);
|
|
107
|
+
|
|
108
|
+
const agents = usePDSFirst
|
|
109
|
+
? [() => getPDSAgent(did, fetchFn), defaultAgentFn]
|
|
110
|
+
: [defaultAgentFn, () => getPDSAgent(did, fetchFn)];
|
|
111
|
+
|
|
112
|
+
let lastError: any;
|
|
113
|
+
for (const getAgent of agents) {
|
|
114
|
+
try {
|
|
115
|
+
const agent = await getAgent();
|
|
116
|
+
return await operation(agent);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
lastError = error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw lastError;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resetAgents(): void {
|
|
125
|
+
resolvedAgent = null;
|
|
126
|
+
pdsAgent = null;
|
|
127
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CacheEntry } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cache TTL values in milliseconds (production defaults).
|
|
5
|
+
* All functions that previously read PUBLIC_ATPROTO_DID from the environment
|
|
6
|
+
* now accept `did: string` as their first argument.
|
|
7
|
+
*/
|
|
8
|
+
export const CACHE_TTL = {
|
|
9
|
+
PROFILE: 60 * 60 * 1000,
|
|
10
|
+
SITE_INFO: 120 * 60 * 1000,
|
|
11
|
+
LINKS: 60 * 60 * 1000,
|
|
12
|
+
MUSIC_STATUS: 10 * 60 * 1000,
|
|
13
|
+
KIBUN_STATUS: 15 * 60 * 1000,
|
|
14
|
+
TANGLED_REPOS: 60 * 60 * 1000,
|
|
15
|
+
BLOG_POSTS: 30 * 60 * 1000,
|
|
16
|
+
PUBLICATIONS: 60 * 60 * 1000,
|
|
17
|
+
INDIVIDUAL_POST: 60 * 60 * 1000,
|
|
18
|
+
IDENTITY: 24 * 60 * 60 * 1000
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export class ATProtoCache {
|
|
22
|
+
private cache = new Map<string, CacheEntry<any>>();
|
|
23
|
+
|
|
24
|
+
private getTTL(key: string): number {
|
|
25
|
+
if (key.startsWith('profile:')) return CACHE_TTL.PROFILE;
|
|
26
|
+
if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO;
|
|
27
|
+
if (key.startsWith('links:')) return CACHE_TTL.LINKS;
|
|
28
|
+
if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS;
|
|
29
|
+
if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS;
|
|
30
|
+
if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS;
|
|
31
|
+
if (key.startsWith('blog-posts:') || key.startsWith('blogposts:')) return CACHE_TTL.BLOG_POSTS;
|
|
32
|
+
if (key.startsWith('publications:') || key.startsWith('standard-site:publications:'))
|
|
33
|
+
return CACHE_TTL.PUBLICATIONS;
|
|
34
|
+
if (key.startsWith('post:') || key.startsWith('blueskypost:')) return CACHE_TTL.INDIVIDUAL_POST;
|
|
35
|
+
if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY;
|
|
36
|
+
return 30 * 60 * 1000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get<T>(key: string): T | null {
|
|
40
|
+
const entry = this.cache.get(key);
|
|
41
|
+
if (!entry) return null;
|
|
42
|
+
const ttl = this.getTTL(key);
|
|
43
|
+
if (Date.now() - entry.timestamp > ttl) {
|
|
44
|
+
this.cache.delete(key);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return entry.data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
set<T>(key: string, data: T): void {
|
|
51
|
+
this.cache.set(key, { data, timestamp: Date.now() });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
delete(key: string): void {
|
|
55
|
+
this.cache.delete(key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
clear(): void {
|
|
59
|
+
this.cache.clear();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const cache = new ATProtoCache();
|
package/src/documents.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { cache } from './cache.js';
|
|
2
|
+
import { withFallback, resolveIdentity } from './agents.js';
|
|
3
|
+
import { buildPdsBlobUrl } from './media.js';
|
|
4
|
+
import type {
|
|
5
|
+
StandardSitePublication,
|
|
6
|
+
StandardSitePublicationsData,
|
|
7
|
+
StandardSiteDocument,
|
|
8
|
+
StandardSiteDocumentsData,
|
|
9
|
+
StandardSiteBasicTheme,
|
|
10
|
+
StandardSiteThemeColor,
|
|
11
|
+
BlogPost
|
|
12
|
+
} from './types.js';
|
|
13
|
+
|
|
14
|
+
interface DocumentRecord {
|
|
15
|
+
site: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
coverImage?: unknown;
|
|
20
|
+
content?: unknown;
|
|
21
|
+
textContent?: string;
|
|
22
|
+
bskyPostRef?: { uri: string; cid: string };
|
|
23
|
+
tags?: string[];
|
|
24
|
+
publishedAt: string;
|
|
25
|
+
updatedAt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PublicationRecord {
|
|
29
|
+
url: string;
|
|
30
|
+
name: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
icon?: unknown;
|
|
33
|
+
basicTheme?: {
|
|
34
|
+
background: StandardSiteThemeColor;
|
|
35
|
+
foreground: StandardSiteThemeColor;
|
|
36
|
+
accent: StandardSiteThemeColor;
|
|
37
|
+
accentForeground: StandardSiteThemeColor;
|
|
38
|
+
};
|
|
39
|
+
preferences?: { showInDiscover?: boolean };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function getBlobUrl(
|
|
43
|
+
did: string,
|
|
44
|
+
blob: any,
|
|
45
|
+
fetchFn?: typeof fetch
|
|
46
|
+
): Promise<string | undefined> {
|
|
47
|
+
try {
|
|
48
|
+
const cid = blob.ref?.$link || blob.cid;
|
|
49
|
+
if (!cid) return undefined;
|
|
50
|
+
const resolved = await resolveIdentity(did, fetchFn);
|
|
51
|
+
return buildPdsBlobUrl(resolved.pds, did, cid);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveViewUrl(
|
|
58
|
+
site: string,
|
|
59
|
+
path: string | undefined,
|
|
60
|
+
publicationUrl: string | undefined,
|
|
61
|
+
rkey: string
|
|
62
|
+
): string {
|
|
63
|
+
const docPath = path || `/${rkey}`;
|
|
64
|
+
const normalizedPath = docPath.startsWith('/') ? docPath : `/${docPath}`;
|
|
65
|
+
|
|
66
|
+
if (site.startsWith('at://')) {
|
|
67
|
+
if (!publicationUrl) return `${site}${normalizedPath}`;
|
|
68
|
+
const baseUrl = publicationUrl.startsWith('http') ? publicationUrl : `https://${publicationUrl}`;
|
|
69
|
+
return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`;
|
|
70
|
+
} else {
|
|
71
|
+
const baseUrl = site.startsWith('http') ? site : `https://${site}`;
|
|
72
|
+
return `${baseUrl.replace(/\/$/, '')}${normalizedPath}`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function fetchPublications(
|
|
77
|
+
did: string,
|
|
78
|
+
fetchFn?: typeof fetch
|
|
79
|
+
): Promise<StandardSitePublicationsData> {
|
|
80
|
+
const cacheKey = `standard-site:publications:${did}`;
|
|
81
|
+
const cached = cache.get<StandardSitePublicationsData>(cacheKey);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
|
|
84
|
+
const publications: StandardSitePublication[] = [];
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const publicationsRecords = await withFallback(
|
|
88
|
+
did,
|
|
89
|
+
async (agent) => {
|
|
90
|
+
const response = await agent.com.atproto.repo.listRecords({
|
|
91
|
+
repo: did,
|
|
92
|
+
collection: 'site.standard.publication',
|
|
93
|
+
limit: 100
|
|
94
|
+
});
|
|
95
|
+
return response.data.records;
|
|
96
|
+
},
|
|
97
|
+
true,
|
|
98
|
+
fetchFn
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
for (const pubRecord of publicationsRecords) {
|
|
102
|
+
const pubValue = pubRecord.value as unknown as PublicationRecord;
|
|
103
|
+
const rkey = pubRecord.uri.split('/').pop() || '';
|
|
104
|
+
|
|
105
|
+
let basicTheme: StandardSiteBasicTheme | undefined;
|
|
106
|
+
if (pubValue.basicTheme) {
|
|
107
|
+
basicTheme = {
|
|
108
|
+
background: pubValue.basicTheme.background,
|
|
109
|
+
foreground: pubValue.basicTheme.foreground,
|
|
110
|
+
accent: pubValue.basicTheme.accent,
|
|
111
|
+
accentForeground: pubValue.basicTheme.accentForeground
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
publications.push({
|
|
116
|
+
name: pubValue.name,
|
|
117
|
+
rkey,
|
|
118
|
+
uri: pubRecord.uri,
|
|
119
|
+
url: pubValue.url,
|
|
120
|
+
description: pubValue.description,
|
|
121
|
+
icon: pubValue.icon ? await getBlobUrl(did, pubValue.icon, fetchFn) : undefined,
|
|
122
|
+
basicTheme,
|
|
123
|
+
preferences: pubValue.preferences
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const data: StandardSitePublicationsData = { publications };
|
|
128
|
+
cache.set(cacheKey, data);
|
|
129
|
+
return data;
|
|
130
|
+
} catch {
|
|
131
|
+
return { publications: [] };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function fetchDocuments(
|
|
136
|
+
did: string,
|
|
137
|
+
fetchFn?: typeof fetch
|
|
138
|
+
): Promise<StandardSiteDocumentsData> {
|
|
139
|
+
const cacheKey = `standard-site:documents:${did}`;
|
|
140
|
+
const cached = cache.get<StandardSiteDocumentsData>(cacheKey);
|
|
141
|
+
if (cached) return cached;
|
|
142
|
+
|
|
143
|
+
const documents: StandardSiteDocument[] = [];
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const publicationsData = await fetchPublications(did, fetchFn);
|
|
147
|
+
const publicationsMap = new Map<string, StandardSitePublication>();
|
|
148
|
+
for (const pub of publicationsData.publications) {
|
|
149
|
+
publicationsMap.set(pub.uri, pub);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const documentsRecords = await withFallback(
|
|
153
|
+
did,
|
|
154
|
+
async (agent) => {
|
|
155
|
+
const response = await agent.com.atproto.repo.listRecords({
|
|
156
|
+
repo: did,
|
|
157
|
+
collection: 'site.standard.document',
|
|
158
|
+
limit: 100
|
|
159
|
+
});
|
|
160
|
+
return response.data.records;
|
|
161
|
+
},
|
|
162
|
+
true,
|
|
163
|
+
fetchFn
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
for (const docRecord of documentsRecords) {
|
|
167
|
+
const docValue = docRecord.value as unknown as DocumentRecord;
|
|
168
|
+
const rkey = docRecord.uri.split('/').pop() || '';
|
|
169
|
+
|
|
170
|
+
const site = docValue.site;
|
|
171
|
+
let publication: StandardSitePublication | undefined;
|
|
172
|
+
let publicationRkey: string | undefined;
|
|
173
|
+
let pubUrl: string | undefined;
|
|
174
|
+
|
|
175
|
+
if (site.startsWith('at://')) {
|
|
176
|
+
publication = publicationsMap.get(site);
|
|
177
|
+
publicationRkey = site.split('/').pop();
|
|
178
|
+
pubUrl = publication?.url;
|
|
179
|
+
} else {
|
|
180
|
+
pubUrl = site;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const url = resolveViewUrl(site, docValue.path, pubUrl, rkey);
|
|
184
|
+
const coverImage = docValue.coverImage
|
|
185
|
+
? await getBlobUrl(did, docValue.coverImage, fetchFn)
|
|
186
|
+
: undefined;
|
|
187
|
+
|
|
188
|
+
documents.push({
|
|
189
|
+
title: docValue.title,
|
|
190
|
+
rkey,
|
|
191
|
+
uri: docRecord.uri,
|
|
192
|
+
url,
|
|
193
|
+
site,
|
|
194
|
+
path: docValue.path,
|
|
195
|
+
description: docValue.description,
|
|
196
|
+
coverImage,
|
|
197
|
+
content: docValue.content,
|
|
198
|
+
textContent: docValue.textContent,
|
|
199
|
+
bskyPostRef: docValue.bskyPostRef,
|
|
200
|
+
tags: docValue.tags,
|
|
201
|
+
publishedAt: docValue.publishedAt,
|
|
202
|
+
updatedAt: docValue.updatedAt,
|
|
203
|
+
publicationName: publication?.name,
|
|
204
|
+
publicationRkey
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
documents.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
|
209
|
+
|
|
210
|
+
const data: StandardSiteDocumentsData = { documents };
|
|
211
|
+
cache.set(cacheKey, data);
|
|
212
|
+
return data;
|
|
213
|
+
} catch {
|
|
214
|
+
return { documents: [] };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function fetchRecentDocuments(
|
|
219
|
+
did: string,
|
|
220
|
+
limit = 5,
|
|
221
|
+
fetchFn?: typeof fetch
|
|
222
|
+
): Promise<StandardSiteDocument[]> {
|
|
223
|
+
const { documents } = await fetchDocuments(did, fetchFn);
|
|
224
|
+
return documents.slice(0, limit);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function fetchBlogPosts(
|
|
228
|
+
did: string,
|
|
229
|
+
fetchFn?: typeof fetch
|
|
230
|
+
): Promise<{ posts: BlogPost[] }> {
|
|
231
|
+
const { documents } = await fetchDocuments(did, fetchFn);
|
|
232
|
+
const posts: BlogPost[] = documents.map((doc) => ({
|
|
233
|
+
title: doc.title,
|
|
234
|
+
url: doc.url,
|
|
235
|
+
createdAt: doc.publishedAt,
|
|
236
|
+
platform: 'standard.site' as const,
|
|
237
|
+
description: doc.description,
|
|
238
|
+
rkey: doc.rkey,
|
|
239
|
+
publicationName: doc.publicationName,
|
|
240
|
+
publicationRkey: doc.publicationRkey,
|
|
241
|
+
tags: doc.tags,
|
|
242
|
+
coverImage: doc.coverImage,
|
|
243
|
+
textContent: doc.textContent,
|
|
244
|
+
updatedAt: doc.updatedAt
|
|
245
|
+
}));
|
|
246
|
+
return { posts };
|
|
247
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { cache } from './cache.js';
|
|
2
|
+
|
|
3
|
+
export type EngagementType = 'app.bsky.feed.like' | 'app.bsky.feed.repost';
|
|
4
|
+
|
|
5
|
+
interface EngagementResponse {
|
|
6
|
+
dids: string[];
|
|
7
|
+
cursor?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function fetchEngagementFromConstellation(
|
|
11
|
+
uri: string,
|
|
12
|
+
type: EngagementType,
|
|
13
|
+
cursor?: string
|
|
14
|
+
): Promise<EngagementResponse> {
|
|
15
|
+
const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`;
|
|
16
|
+
const cached = cache.get<EngagementResponse>(cacheKey);
|
|
17
|
+
if (cached) return cached;
|
|
18
|
+
|
|
19
|
+
const url = new URL('https://constellation.microcosm.blue/links/distinct-dids');
|
|
20
|
+
url.searchParams.append('target', uri);
|
|
21
|
+
url.searchParams.append('collection', type);
|
|
22
|
+
url.searchParams.append('path', '');
|
|
23
|
+
url.searchParams.append('limit', '100');
|
|
24
|
+
if (cursor) url.searchParams.append('cursor', cursor);
|
|
25
|
+
|
|
26
|
+
const response = await fetch(url);
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(`Constellation HTTP error! Status: ${response.status}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const data = await response.json();
|
|
32
|
+
const result: EngagementResponse = {
|
|
33
|
+
dids: data.dids || [],
|
|
34
|
+
cursor: data.cursor
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
cache.set(cacheKey, result);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function fetchAllEngagement(uri: string, type: EngagementType): Promise<string[]> {
|
|
42
|
+
const allDids: Set<string> = new Set();
|
|
43
|
+
let cursor: string | undefined = undefined;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
do {
|
|
47
|
+
const response = await fetchEngagementFromConstellation(uri, type, cursor);
|
|
48
|
+
response.dids.forEach((did) => allDids.add(did));
|
|
49
|
+
cursor = response.cursor;
|
|
50
|
+
} while (cursor);
|
|
51
|
+
} catch {
|
|
52
|
+
// return what we have
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return Array.from(allDids);
|
|
56
|
+
}
|