thursday-agent 0.9.0 → 0.10.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.
Files changed (199) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/prerender-manifest.json +3 -3
  4. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  5. package/.next/server/app/_global-error.html +1 -1
  6. package/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  11. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/server/app/_not-found.html +1 -1
  13. package/.next/server/app/_not-found.rsc +2 -2
  14. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  16. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  17. package/.next/server/app/api/artifact/route.js.nft.json +1 -1
  18. package/.next/server/app/api/bot/keep-working/route.js.nft.json +1 -1
  19. package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
  20. package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
  21. package/.next/server/app/api/bot/route.js.nft.json +1 -1
  22. package/.next/server/app/api/bot/thread/[id]/route.js.nft.json +1 -1
  23. package/.next/server/app/api/bot/thread/route.js.nft.json +1 -1
  24. package/.next/server/app/api/config/route.js.nft.json +1 -1
  25. package/.next/server/app/api/events/route.js.nft.json +1 -1
  26. package/.next/server/app/api/favicon/[host]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
  28. package/.next/server/app/api/llm-model/catalog/route.js.nft.json +1 -1
  29. package/.next/server/app/api/llm-model/credits/route.js.nft.json +1 -1
  30. package/.next/server/app/api/llm-model/route.js.nft.json +1 -1
  31. package/.next/server/app/api/llm-model/subscription/route.js.nft.json +1 -1
  32. package/.next/server/app/api/mcp/[name]/route.js.nft.json +1 -1
  33. package/.next/server/app/api/mcp/oauth/callback/route.js.nft.json +1 -1
  34. package/.next/server/app/api/mcp/route.js.nft.json +1 -1
  35. package/.next/server/app/api/mcp/tools/route.js.nft.json +1 -1
  36. package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
  37. package/.next/server/app/api/memory/route.js.nft.json +1 -1
  38. package/.next/server/app/api/probe/route.js.nft.json +1 -1
  39. package/.next/server/app/api/reach/route.js.nft.json +1 -1
  40. package/.next/server/app/api/routine/route.js.nft.json +1 -1
  41. package/.next/server/app/api/signins/route.js.nft.json +1 -1
  42. package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
  43. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  44. package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
  45. package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
  46. package/.next/server/app/api/thursday/text/route.js.nft.json +1 -1
  47. package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
  48. package/.next/server/app/api/workspace/files/route.js.nft.json +1 -1
  49. package/.next/server/app/api/workspace/route.js.nft.json +1 -1
  50. package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
  51. package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
  52. package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
  53. package/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  54. package/.next/server/app/manifest.webmanifest/route.js.nft.json +1 -1
  55. package/.next/server/app/page/server-reference-manifest.json +56 -56
  56. package/.next/server/app/page.js.nft.json +1 -1
  57. package/.next/server/app/page_client-reference-manifest.js +1 -1
  58. package/.next/server/chunks/ssr/{[root-of-the-server]__0oo-wlv._.js → [root-of-the-server]__0cxqzfc._.js} +2 -2
  59. package/.next/server/chunks/ssr/[root-of-the-server]__0tzekbl._.js +1 -1
  60. package/.next/server/chunks/ssr/{[root-of-the-server]__0zuns7e._.js → [root-of-the-server]__1-d2tgh._.js} +2 -2
  61. package/.next/server/chunks/ssr/[root-of-the-server]__1se25x0._.js +1 -1
  62. package/.next/server/chunks/ssr/{_0scf7xy._.js → _05rxyf9._.js} +2 -2
  63. package/.next/server/chunks/ssr/_070nv1_._.js +1 -1
  64. package/.next/server/chunks/ssr/_097-ljd._.js +1 -1
  65. package/.next/server/chunks/ssr/{_1uy0xom._.js → _0bp-_fi._.js} +3 -3
  66. package/.next/server/chunks/ssr/_0fqhhwi._.js +1 -1
  67. package/.next/server/chunks/ssr/{_0ltz2q5._.js → _0hlxij-._.js} +2 -2
  68. package/.next/server/chunks/ssr/{_1a-nuxx._.js → _0p9pmxe._.js} +2 -2
  69. package/.next/server/chunks/ssr/{_0ital-2._.js → _0s41irp._.js} +2 -2
  70. package/.next/server/chunks/ssr/{_1z74dmq._.js → _0tg0vf8._.js} +4 -4
  71. package/.next/server/chunks/ssr/{_1tmu8z-._.js → _173siic._.js} +2 -2
  72. package/.next/server/chunks/ssr/{_1x-kojb._.js → _17v-33v._.js} +3 -3
  73. package/.next/server/chunks/ssr/{_0l_mcfz._.js → _19n08g5._.js} +2 -2
  74. package/.next/server/chunks/ssr/{_0urj3mc._.js → _1doidrw._.js} +2 -2
  75. package/.next/server/chunks/ssr/{_1-zyvdb._.js → _1flsjeo._.js} +2 -2
  76. package/.next/server/chunks/ssr/_1mqn9mc._.js +1 -1
  77. package/.next/server/chunks/ssr/_1neoj22._.js +1 -1
  78. package/.next/server/chunks/ssr/_1ng831c._.js +3 -0
  79. package/.next/server/chunks/ssr/{_0kjj5ts._.js → _1u2jw0i._.js} +3 -3
  80. package/.next/server/chunks/ssr/_1xoch6f._.js +1 -1
  81. package/.next/server/chunks/ssr/{_1flbs77._.js → _1yk6yak._.js} +2 -2
  82. package/.next/server/chunks/ssr/features_bot_1yr-_pm._.js +3 -0
  83. package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
  84. package/.next/server/chunks/ssr/features_memory_components_memory-setting_tsx_0oa43sw._.js +1 -1
  85. package/.next/server/chunks/ssr/features_routine_components_routine-setting_tsx_055fknz._.js +1 -1
  86. package/.next/server/chunks/ssr/{node_modules_1ogczzz._.js → node_modules_048wmmj._.js} +1 -1
  87. package/.next/server/chunks/ssr/{node_modules_1af3w91._.js → node_modules_04t200t._.js} +1 -1
  88. package/.next/server/chunks/ssr/{node_modules_1wbkzl9._.js → node_modules_0511ha-._.js} +1 -1
  89. package/.next/server/chunks/ssr/{node_modules_0wnlddw._.js → node_modules_08g12f-._.js} +1 -1
  90. package/.next/server/chunks/ssr/{node_modules_0uyh_vb._.js → node_modules_0cdu6jq._.js} +1 -1
  91. package/.next/server/chunks/ssr/{node_modules_03d0rfz._.js → node_modules_0eb4rx7._.js} +1 -1
  92. package/.next/server/chunks/ssr/{node_modules_05ybmta._.js → node_modules_0f2k5l0._.js} +1 -1
  93. package/.next/server/chunks/ssr/{node_modules_0sgy-dc._.js → node_modules_0piws2a._.js} +1 -1
  94. package/.next/server/chunks/ssr/{node_modules_12et8d5._.js → node_modules_0pk6i-e._.js} +1 -1
  95. package/.next/server/chunks/ssr/{node_modules_04_sbbc._.js → node_modules_0s1lwc9._.js} +1 -1
  96. package/.next/server/chunks/ssr/{node_modules_068ws5v._.js → node_modules_0wq4-ts._.js} +1 -1
  97. package/.next/server/chunks/ssr/{node_modules_13kwejm._.js → node_modules_0zjqwz7._.js} +1 -1
  98. package/.next/server/chunks/ssr/{node_modules_07rs_p2._.js → node_modules_11hbkuj._.js} +1 -1
  99. package/.next/server/chunks/ssr/{node_modules_1z1lrbk._.js → node_modules_18woe20._.js} +1 -1
  100. package/.next/server/chunks/ssr/{node_modules_0yvz9e0._.js → node_modules_1_afpz7._.js} +1 -1
  101. package/.next/server/chunks/ssr/{node_modules_0t_ez6-._.js → node_modules_1_ban4u._.js} +1 -1
  102. package/.next/server/chunks/ssr/{node_modules_1uwg-gz._.js → node_modules_1cxc8kn._.js} +1 -1
  103. package/.next/server/chunks/ssr/{node_modules_0th357h._.js → node_modules_1dcbvfw._.js} +1 -1
  104. package/.next/server/chunks/ssr/{node_modules_06bb4q8._.js → node_modules_1expacw._.js} +1 -1
  105. package/.next/server/chunks/ssr/{node_modules_1s-gb-1._.js → node_modules_1ksh9gy._.js} +1 -1
  106. package/.next/server/chunks/ssr/{node_modules_0ubdtk-._.js → node_modules_1m2ho33._.js} +1 -1
  107. package/.next/server/chunks/ssr/{node_modules_1bucjlk._.js → node_modules_1n0h9sf._.js} +1 -1
  108. package/.next/server/chunks/ssr/{node_modules_1lkix61._.js → node_modules_1qehmwm._.js} +1 -1
  109. package/.next/server/chunks/ssr/{node_modules_0x630an._.js → node_modules_1xzo2wn._.js} +1 -1
  110. package/.next/server/chunks/ssr/{node_modules_0jljel9._.js → node_modules_1zf46lp._.js} +1 -1
  111. package/.next/server/chunks/ssr/{node_modules_0dzm4um._.js → node_modules_1zkpek5._.js} +1 -1
  112. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0r443fh._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0avl8-1._.js} +1 -1
  113. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_10wnlbk._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_14oslro._.js} +1 -1
  114. package/.next/server/middleware-build-manifest.js +3 -3
  115. package/.next/server/pages/404.html +1 -1
  116. package/.next/server/pages/500.html +1 -1
  117. package/.next/server/server-reference-manifest.js +1 -1
  118. package/.next/server/server-reference-manifest.json +57 -57
  119. package/.next/static/chunks/03kgfyrli_0gz.js +1 -0
  120. package/.next/static/chunks/{245hb85nsy6j4.js → 09120tms63y21.js} +1 -1
  121. package/.next/static/chunks/{2c64drdevdla3.js → 0o980477kfqez.js} +1 -1
  122. package/.next/static/chunks/{13ixzheqw7o-w.js → 0oz3rs47yy3p8.js} +2 -2
  123. package/.next/static/chunks/0w20wnnfrscq7.js +1 -0
  124. package/.next/static/chunks/{3bbzsitgiltnt.js → 19ojn4lomsk0r.js} +1 -1
  125. package/.next/static/chunks/{38301ji-85i30.js → 1mwee7xvitlp9.js} +1 -1
  126. package/.next/static/chunks/1p764d75_0lhv.js +18 -0
  127. package/.next/static/chunks/{27tvv1i-smrck.js → 1pjygp7hqib6j.js} +1 -1
  128. package/.next/static/chunks/{0_awn8tucznxc.js → 22shnca_6zfzs.js} +1 -1
  129. package/.next/static/chunks/{0ge-hx0ed83a1.js → 24dst4mgurbsh.js} +1 -1
  130. package/.next/static/chunks/{06nt9tatr-dug.js → 25zq8gzhbdsr7.js} +1 -1
  131. package/.next/static/chunks/{37j27b3e1gt7r.js → 2d4z3er53ng8r.js} +1 -1
  132. package/.next/static/chunks/2ky81z-jgvasc.js +1 -0
  133. package/.next/static/chunks/2m6jd3586ijb-.js +1 -0
  134. package/.next/static/chunks/{1grkt5gr8pbg1.js → 2qod5tqlpz8b7.js} +7 -7
  135. package/.next/static/chunks/{1yugpvvi8qrbu.js → 30skcjexa57zn.js} +2 -2
  136. package/.next/static/chunks/{427qsc6kahxyo.js → 35kwn237o4wdn.js} +1 -1
  137. package/.next/static/chunks/{0qmd8n52hp-6v.js → 3exjsi8pfdvzj.js} +1 -1
  138. package/.next/static/chunks/3qq4o8w2rv_e9.css +1 -0
  139. package/.next/static/chunks/{3h2e-xv1zxh_f.js → 3vynnr71ghvom.js} +2 -2
  140. package/.next/static/chunks/42890jqk049q1.js +1 -0
  141. package/README.md +4 -0
  142. package/components/ui/radio-group.tsx +2 -2
  143. package/features/bot/components/bot-setting.tsx +4 -4
  144. package/features/routine/components/routine-setting.tsx +1 -1
  145. package/features/thursday/components/thursday-setting.tsx +5 -5
  146. package/guide/bots.md +1 -1
  147. package/knip.json +1 -0
  148. package/package.json +1 -1
  149. package/seed-skills/analyst/README.md +19 -0
  150. package/seed-skills/analyst/data-report/SKILL.md +59 -0
  151. package/seed-skills/analyst/data-report/references/brief.md +31 -0
  152. package/seed-skills/analyst/data-report/references/company.md +41 -0
  153. package/seed-skills/analyst/data-report/references/comparison.md +42 -0
  154. package/seed-skills/analyst/data-report/references/decision.md +28 -0
  155. package/seed-skills/analyst/data-report/references/market-size.md +49 -0
  156. package/seed-skills/analyst/data-report/references/price-check.md +33 -0
  157. package/seed-skills/analyst/data-report/references/sources.md +51 -0
  158. package/seed-skills/analyst/data-report/references/trend.md +39 -0
  159. package/seed-skills/analyst/data-report/scripts/chart.mjs +679 -0
  160. package/seed-skills/analyst/data-report/scripts/fetch.mjs +521 -0
  161. package/seed-skills/insta/instagram/SKILL.md +33 -0
  162. package/seed-skills/insta/instagram/references/dm.md +49 -0
  163. package/seed-skills/insta/instagram/references/post.md +74 -0
  164. package/seed-skills/insta/instagram/scripts/compose.mjs +329 -0
  165. package/seed-skills/insta/instagram/scripts/image-size.mjs +30 -0
  166. package/seed-skills/insta/instagram/scripts/inbox.mjs +159 -0
  167. package/seed-skills/insta/instagram/scripts/lib.mjs +91 -0
  168. package/seed-skills/insta/instagram/scripts/media.mjs +104 -0
  169. package/seed-skills/insta/instagram/scripts/profile.mjs +139 -0
  170. package/seed-skills/insta/instagram/scripts/read.mjs +240 -0
  171. package/seed-skills/insta/instagram/scripts/render.mjs +126 -0
  172. package/seed-skills/insta/instagram/scripts/search.mjs +133 -0
  173. package/seed-skills/insta/instagram/scripts/selectors.mjs +56 -0
  174. package/seed-skills/insta/instagram/scripts/sheet.mjs +100 -0
  175. package/seed-skills/insta/instagram/scripts/webimage.mjs +105 -0
  176. package/seed-skills/insta/instagram/templates/carousel.html +62 -0
  177. package/seed-skills/mail/mail/SKILL.md +37 -0
  178. package/seed-skills/mail/mail/recipes/gmail.mjs +448 -0
  179. package/seed-skills/mail/mail/references/recipes.md +55 -0
  180. package/seed-skills/mail/mail/scripts/mail.mjs +202 -0
  181. package/seed-skills/marketer/README.md +6 -4
  182. package/seed-skills/marketer/copywriting/SKILL.md +1 -1
  183. package/seed-skills/marketer/emails/SKILL.md +1 -1
  184. package/seed-skills/marketer/launch/SKILL.md +1 -1
  185. package/seed-skills/marketer/product-marketing/SKILL.md +5 -5
  186. package/seed-skills/marketer/seo-audit/SKILL.md +1 -1
  187. package/seed-skills/marketer/social/SKILL.md +1 -1
  188. package/.next/server/chunks/ssr/_0dshsh4._.js +0 -3
  189. package/.next/server/chunks/ssr/features_bot_1nr94ks._.js +0 -3
  190. package/.next/static/chunks/0pda_vf-abwvx.js +0 -1
  191. package/.next/static/chunks/0rowp9rx3qbxu.js +0 -18
  192. package/.next/static/chunks/27lkanxvtdn09.js +0 -1
  193. package/.next/static/chunks/3fec-aeteddr-.js +0 -1
  194. package/.next/static/chunks/3go3hwp1z2_h2.css +0 -1
  195. package/.next/static/chunks/3h2bun3hcggg8.js +0 -1
  196. package/.next/static/chunks/3j1y335o95mj7.js +0 -1
  197. /package/.next/static/{u8kJqShUAa49vY7BxAWsw → Ar5V4kMdL4CE1trx8anrH}/_buildManifest.js +0 -0
  198. /package/.next/static/{u8kJqShUAa49vY7BxAWsw → Ar5V4kMdL4CE1trx8anrH}/_clientMiddlewareManifest.js +0 -0
  199. /package/.next/static/{u8kJqShUAa49vY7BxAWsw → Ar5V4kMdL4CE1trx8anrH}/_ssgManifest.js +0 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * One post as these scripts report it, from the media record Instagram's own
3
+ * pages fetch (a profile's timeline, a search). `media` is sent into the browser
4
+ * session as source (lib inPage helpers), so it uses nothing from outside itself.
5
+ */
6
+ export function media(o) {
7
+ const slides = o.carousel_media ?? [];
8
+ const first = slides[0] ?? o;
9
+ const w = first.original_width ?? null;
10
+ const h = first.original_height ?? null;
11
+ const named = [
12
+ ["1:1", 1],
13
+ ["4:5", 0.8],
14
+ ["3:4", 0.75],
15
+ ["2:3", 0.667],
16
+ ["9:16", 0.5625],
17
+ ["1.91:1", 1.91],
18
+ ["16:9", 1.778],
19
+ ];
20
+ const r = w && h ? w / h : null;
21
+ const ratio = r
22
+ ? (named.find(([, v]) => Math.abs(v - r) < 0.02)?.[0] ?? r.toFixed(2))
23
+ : null;
24
+ const caption = o.caption?.text ?? "";
25
+ const reel = o.media_type === 2;
26
+ const image = (m) =>
27
+ m.image_versions2?.candidates?.[0]?.url ?? m.display_uri ?? null;
28
+ return {
29
+ url: `https://www.instagram.com/${reel ? "reel" : "p"}/${o.code}/`,
30
+ type: o.media_type === 8 ? "carousel" : reel ? "reel" : "image",
31
+ at: o.taken_at ? o.taken_at * 1000 : null,
32
+ owner: o.user?.username ?? null,
33
+ pinned: (o.timeline_pinned_user_ids ?? []).length > 0,
34
+ likes: o.like_and_view_counts_disabled ? null : (o.like_count ?? null),
35
+ comments: o.comment_count ?? null,
36
+ views: o.play_count ?? o.ig_play_count ?? o.view_count ?? null,
37
+ slides: o.carousel_media_count ?? (slides.length || 1),
38
+ ratio,
39
+ size: w && h ? `${w}x${h}` : null,
40
+ hook: caption.split("\n")[0].slice(0, 140),
41
+ captionChars: caption.length,
42
+ hashtags: (caption.match(/#[\p{L}\p{N}_]+/gu) ?? []).length,
43
+ caption,
44
+ music: o.clips_metadata?.music_info?.music_asset_info?.title ?? null,
45
+ cover: image(first),
46
+ images: slides.length ? slides.map(image) : [image(o)],
47
+ };
48
+ }
49
+
50
+ const median = (xs) => {
51
+ const s = xs.filter((x) => x != null).sort((a, b) => a - b);
52
+ return s.length ? s[Math.floor((s.length - 1) / 2)] : null;
53
+ };
54
+
55
+ export const short = (n) =>
56
+ n == null
57
+ ? "-"
58
+ : n >= 1e6
59
+ ? `${(n / 1e6).toFixed(1)}M`
60
+ : n >= 1e3
61
+ ? `${(n / 1e3).toFixed(1)}k`
62
+ : String(n);
63
+
64
+ const day = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : "?");
65
+
66
+ /** One line a post: what it is, how it did, and its first caption line. */
67
+ export const postLine = (p, i) =>
68
+ `${String(i + 1).padStart(2)} ${day(p.at)} ${p.type}${p.type === "carousel" ? `×${p.slides}` : ""} ${p.ratio ?? "?"} ` +
69
+ `likes ${short(p.likes)} comments ${short(p.comments)}${p.views ? ` views ${short(p.views)}` : ""} tags ${p.hashtags} caption ${p.captionChars}` +
70
+ `${p.pinned ? " pinned" : ""}${p.owner ? ` @${p.owner}` : ""} ${p.url} | ${p.hook.replace(/\s+/g, " ").slice(0, 70)}`;
71
+
72
+ /** How a set of posts is made, in a few lines: the pattern to copy. */
73
+ export function patterns(posts) {
74
+ if (!posts.length) return ["no posts"];
75
+ const count = (f) => posts.filter(f).length;
76
+ const tally = (key) =>
77
+ Object.entries(
78
+ posts.reduce((all, p) => {
79
+ all[p[key]] = (all[p[key]] ?? 0) + 1;
80
+ return all;
81
+ }, {}),
82
+ )
83
+ .sort((a, b) => b[1] - a[1])
84
+ .map(([k, n]) => `${k} ${n}`)
85
+ .join(", ");
86
+ const dated = posts
87
+ .filter((p) => !p.pinned && p.at)
88
+ .map((p) => p.at)
89
+ .sort((a, b) => a - b);
90
+ const weeks = dated.length > 1 ? (dated.at(-1) - dated[0]) / 6048e5 : null;
91
+ const carousels = posts.filter((p) => p.type === "carousel");
92
+ const top = [...posts]
93
+ .filter((p) => p.likes != null)
94
+ .sort((a, b) => b.likes - a.likes)
95
+ .slice(0, 3)
96
+ .map((p) => posts.indexOf(p) + 1);
97
+ return [
98
+ `types: ${tally("type")}; ratios: ${tally("ratio")}`,
99
+ `slides per carousel: median ${median(carousels.map((p) => p.slides)) ?? "-"} (of ${carousels.length})`,
100
+ `caption: median ${median(posts.map((p) => p.captionChars))} chars, ${median(posts.map((p) => p.hashtags))} hashtags; ${count((p) => p.hashtags === 0)} with none`,
101
+ `likes: median ${short(median(posts.map((p) => p.likes)))}; best: ${top.map((n) => `#${n}`).join(" ")}`,
102
+ weeks ? `cadence: ${(dated.length / weeks).toFixed(1)} posts a week` : null,
103
+ ].filter(Boolean);
104
+ }
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * How one account posts: its size, its latest posts (type, slides, ratio, how they
4
+ * did, caption shape), and the pattern across them — read from the data the
5
+ * profile page itself fetches, not from the screen.
6
+ *
7
+ * node profile.mjs <handle | profile url> [--posts 12] [--out posts.json] [--sheet covers.png]
8
+ */
9
+ import { writeFileSync } from "node:fs";
10
+ import { fail, inPage, orFail, parseArgs } from "./lib.mjs";
11
+ import { media, patterns, postLine, short } from "./media.mjs";
12
+ import { makeSheet } from "./sheet.mjs";
13
+
14
+ const opts = parseArgs();
15
+ const handle = String(opts._[0] ?? "")
16
+ .replace(/^https?:\/\/(www\.)?instagram\.com\//, "")
17
+ .replace(/^@/, "")
18
+ .split(/[/?]/)[0];
19
+ if (!handle)
20
+ fail(
21
+ "usage: node profile.mjs <handle> [--posts 12] [--out posts.json] [--sheet covers.png]",
22
+ );
23
+ const want = Number(opts.posts ?? 12);
24
+
25
+ const started = Date.now();
26
+ const got = orFail(
27
+ await inPage(
28
+ async (page, { handle, want }, { media }) => {
29
+ const posts = [];
30
+ let profile = null;
31
+ let more = true;
32
+ const first = (text) => JSON.parse(text.split("\n")[0]);
33
+ const listen = async (res) => {
34
+ if (!/\/graphql/.test(res.url())) return;
35
+ let text;
36
+ try {
37
+ text = await res.text();
38
+ } catch {
39
+ return;
40
+ }
41
+ try {
42
+ if (
43
+ text.includes("xdt_api__v1__feed__user_timeline_graphql_connection")
44
+ ) {
45
+ const c =
46
+ first(text).data
47
+ ?.xdt_api__v1__feed__user_timeline_graphql_connection;
48
+ for (const e of c?.edges ?? []) posts.push(media(e.node));
49
+ more = Boolean(c?.page_info?.has_next_page);
50
+ } else if (!profile && text.includes('"follower_count"')) {
51
+ const u = first(text).data?.user;
52
+ if (u?.username?.toLowerCase() === handle.toLowerCase())
53
+ profile = {
54
+ handle: u.username,
55
+ name: u.full_name,
56
+ followers: u.follower_count,
57
+ following: u.following_count ?? null,
58
+ posts: u.media_count ?? null,
59
+ verified: u.is_verified,
60
+ private: u.is_private,
61
+ category: u.category ?? null,
62
+ bio: u.biography ?? "",
63
+ link: u.bio_links?.[0]?.url ?? u.external_url ?? null,
64
+ };
65
+ }
66
+ } catch {}
67
+ };
68
+ page.on("response", listen);
69
+ try {
70
+ await page.goto(`https://www.instagram.com/${handle}/`, {
71
+ waitUntil: "domcontentloaded",
72
+ });
73
+ for (let i = 0; i < 40 && !(profile && posts.length); i++)
74
+ await page.waitForTimeout(250);
75
+ for (
76
+ let tries = 0;
77
+ posts.length < want && more && tries < 12;
78
+ tries++
79
+ ) {
80
+ const had = posts.length;
81
+ await page.evaluate(() =>
82
+ window.scrollTo(0, document.body.scrollHeight),
83
+ );
84
+ for (let i = 0; i < 24 && posts.length === had; i++)
85
+ await page.waitForTimeout(250);
86
+ if (posts.length === had) break;
87
+ }
88
+ } finally {
89
+ page.off("response", listen);
90
+ }
91
+ const path = page.url().replace(/^https?:\/\/[^/]+/, "");
92
+ if (/\/accounts\/login|\/challenge\//.test(path))
93
+ return {
94
+ error: `Not signed in (${path}). Borrow the kept sign-in first.`,
95
+ };
96
+ if (!profile && !posts.length)
97
+ return {
98
+ error: `Nothing came back for @${handle}: no such account, a private one, or the page changed (it no longer fetches xdt_api__v1__feed__user_timeline_graphql_connection). Open it and look.`,
99
+ };
100
+ const seen = new Set();
101
+ return {
102
+ profile,
103
+ // Newest first, the pinned ones after: the grid puts old pinned posts on top
104
+ posts: posts
105
+ .filter((p) => !seen.has(p.url) && seen.add(p.url))
106
+ .sort((a, b) => a.pinned - b.pinned || b.at - a.at)
107
+ .slice(0, want),
108
+ };
109
+ },
110
+ { handle, want },
111
+ { media },
112
+ ),
113
+ );
114
+
115
+ const { profile, posts } = got;
116
+ if (opts.out)
117
+ writeFileSync(opts.out, JSON.stringify({ profile, posts }, null, 1));
118
+ if (profile)
119
+ console.log(
120
+ `@${profile.handle} (${profile.name}) — ${short(profile.followers)} followers, ${profile.posts ?? "?"} posts${profile.verified ? ", verified" : ""}${profile.category ? `, ${profile.category}` : ""}\n` +
121
+ `bio: ${profile.bio.replace(/\s+/g, " ").slice(0, 160)}`,
122
+ );
123
+ posts.forEach((p, i) => {
124
+ console.log(postLine(p, i));
125
+ });
126
+ for (const line of patterns(posts)) console.log(`pattern: ${line}`);
127
+ if (opts.sheet) {
128
+ const made = await makeSheet(
129
+ posts.map((p, i) => ({
130
+ src: p.cover,
131
+ label: `#${i + 1} ${p.type}${p.type === "carousel" ? `×${p.slides}` : ""} ${p.ratio ?? ""} likes ${short(p.likes)}`,
132
+ })),
133
+ opts.sheet,
134
+ );
135
+ console.log(`covers: ${made.out}`);
136
+ }
137
+ console.log(
138
+ `${posts.length} posts in ${((Date.now() - started) / 1000).toFixed(1)}s${opts.out ? ` → ${opts.out} (full captions, every slide's picture url)` : ""}`,
139
+ );
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reads one DM thread into a JSONL file, oldest first, by walking its message pane
4
+ * up from the newest message. Opening a thread marks it seen.
5
+ *
6
+ * node read.mjs <thread id | thread url> --out <file.jsonl>
7
+ * [--since 2026-09-01] [--max 200] [--tail 10]
8
+ *
9
+ * Stops at whichever comes first: --max messages, a message older than --since,
10
+ * the top of the thread, or two rounds that brought nothing new.
11
+ */
12
+ import { writeFileSync } from "node:fs";
13
+ import { fail, inPage, oneLine, orFail, parseArgs, stamp } from "./lib.mjs";
14
+ import { SEL } from "./selectors.mjs";
15
+
16
+ const opts = parseArgs();
17
+ const target = opts._[0];
18
+ if (!target || !opts.out)
19
+ fail(
20
+ "usage: node read.mjs <thread id | url> --out <file.jsonl> [--since YYYY-MM-DD] [--max 200] [--tail 10]",
21
+ );
22
+ const id = String(target).match(/(\d{6,})/)?.[1];
23
+ if (!id) fail(`Not a thread id or url: ${target}`);
24
+ const url = `https://www.instagram.com/direct/t/${id}/`;
25
+ const max = Number(opts.max ?? 200);
26
+ const since = opts.since ? new Date(opts.since).getTime() : null;
27
+ if (opts.since && Number.isNaN(since))
28
+ fail(`--since is not a date: ${opts.since}`);
29
+ const tail = Number(opts.tail ?? 10);
30
+ /** Each round is one browser call of a few screens; this caps a runaway thread. */
31
+ const ROUNDS = 60;
32
+
33
+ /**
34
+ * One round: a few screens of the pane, each read and then moved up by most of a
35
+ * screen. The pane unmounts rows that leave the screen, so it is walked, never
36
+ * jumped to the top: a jump skips everything in between.
37
+ */
38
+ async function round(page, { SEL, url, first, steps }) {
39
+ const extract = (SEL) => {
40
+ if (/\/accounts\/login|\/challenge\//.test(location.pathname))
41
+ return { error: `Not signed in (${location.pathname}).` };
42
+ const pane = [...document.querySelectorAll("main *")].find(
43
+ (e) =>
44
+ e.clientHeight > 200 &&
45
+ !e.closest(SEL.inboxList) &&
46
+ /(auto|scroll)/.test(getComputedStyle(e).overflowY) &&
47
+ e.querySelector(SEL.messageRow),
48
+ );
49
+ if (!pane)
50
+ return {
51
+ error: document.querySelector("main")
52
+ ? "Selectors changed: no message pane (SEL.messageRow). Snapshot the thread once and fix selectors.mjs."
53
+ : "No thread on the page: something covers it (a dialog, a daily time-limit screen). Snapshot, close it, run again.",
54
+ };
55
+ pane.setAttribute("data-read-pane", "");
56
+ const fiberOf = (el) =>
57
+ el[Object.keys(el).find((k) => k.startsWith("__reactFiber"))];
58
+ const findProp = (el, name) => {
59
+ for (let f = fiberOf(el), d = 0; f && d < 40; f = f.return, d++) {
60
+ const p = f.memoizedProps;
61
+ if (p && typeof p === "object" && p[name] != null) return p[name];
62
+ }
63
+ return null;
64
+ };
65
+ const paneBox = pane.getBoundingClientRect();
66
+ const messages = [];
67
+ for (const row of pane.querySelectorAll(SEL.messageRow)) {
68
+ const body = row.querySelector(SEL.messageBody);
69
+ if (!body) continue; // a date line, a "seen" line
70
+ const ref = findProp(row, SEL.messageRefProp);
71
+ const deep =
72
+ body.querySelector("[dir=auto], img, video, a") ??
73
+ body.firstElementChild ??
74
+ body;
75
+ let outgoing = findProp(deep, SEL.outgoingProp);
76
+ if (typeof outgoing !== "boolean") {
77
+ // Heuristic: no flag on the row, so a bubble nearer the right edge is the account's own
78
+ const box = body.getBoundingClientRect();
79
+ outgoing = box.left - paneBox.left > paneBox.right - box.right;
80
+ }
81
+ messages.push({
82
+ id: ref?.id ?? null,
83
+ ts: ref?.timestamp_ms ? Number(ref.timestamp_ms) : null,
84
+ from: outgoing ? "me" : "them",
85
+ sender: ref?.sender_fbid ?? null,
86
+ text: body.innerText.trim(),
87
+ images: [...body.querySelectorAll("img")]
88
+ .filter((i) => i.getBoundingClientRect().width >= SEL.minImagePx)
89
+ .map((i) => i.currentSrc || i.src),
90
+ video: body.querySelector("video") !== null,
91
+ links: [
92
+ ...new Set(
93
+ [...body.querySelectorAll("a[href]")]
94
+ .map((a) => a.href)
95
+ .filter((h) => h.startsWith("http")),
96
+ ),
97
+ ],
98
+ });
99
+ }
100
+ // A column-reverse pane counts scrollTop from the bottom, as zero and below
101
+ const reversed = getComputedStyle(pane).flexDirection === "column-reverse";
102
+ const atTop = reversed
103
+ ? Math.abs(pane.scrollTop) + pane.clientHeight >= pane.scrollHeight - 2
104
+ : pane.scrollTop <= 1;
105
+ return { messages, atTop, height: pane.scrollHeight };
106
+ };
107
+
108
+ if (first) {
109
+ if (!page.url().startsWith(url)) {
110
+ await page.goto(url, { waitUntil: "domcontentloaded" });
111
+ await page
112
+ .waitForFunction(
113
+ (SEL) => document.querySelector(`main ${SEL.messageBody}`),
114
+ SEL,
115
+ { timeout: 20000 },
116
+ )
117
+ .catch(() => {});
118
+ }
119
+ await page.waitForTimeout(800);
120
+ const found = await page.evaluate(extract, SEL);
121
+ if (found.error) return found;
122
+ // Start from the newest message, wherever an earlier look left the pane
123
+ await page.evaluate(() => {
124
+ const pane = document.querySelector("[data-read-pane]");
125
+ pane.scrollTop =
126
+ getComputedStyle(pane).flexDirection === "column-reverse"
127
+ ? 0
128
+ : pane.scrollHeight;
129
+ });
130
+ await page.waitForTimeout(500);
131
+ }
132
+
133
+ const messages = [];
134
+ let moved = Number.POSITIVE_INFINITY;
135
+ for (let s = 0; s < steps; s++) {
136
+ const seen = await page.evaluate(extract, SEL);
137
+ if (seen.error) return seen;
138
+ messages.push(...seen.messages);
139
+ // At the top, or held in place while older rows load: wait for the pane to grow
140
+ if (seen.atTop || moved < 10) {
141
+ const grew = await page
142
+ .waitForFunction(
143
+ (height) =>
144
+ (document.querySelector("[data-read-pane]")?.scrollHeight ?? 0) >
145
+ height,
146
+ seen.height,
147
+ { timeout: 5000 },
148
+ )
149
+ .then(() => true)
150
+ .catch(() => false);
151
+ if (!grew && seen.atTop) return { messages, top: true };
152
+ await page.waitForTimeout(300);
153
+ }
154
+ moved = await page.evaluate(() => {
155
+ const pane = document.querySelector("[data-read-pane]");
156
+ const before = pane.scrollTop;
157
+ pane.scrollTop -= pane.clientHeight * 0.8;
158
+ return Math.abs(pane.scrollTop - before);
159
+ });
160
+ await page.waitForTimeout(350);
161
+ }
162
+ return { messages, top: false };
163
+ }
164
+
165
+ const started = Date.now();
166
+ const byKey = new Map();
167
+ let fallbackKeys = 0;
168
+ let stop = `the round limit (${ROUNDS})`;
169
+ let quiet = 0;
170
+ for (let i = 0; i < ROUNDS; i++) {
171
+ const r = orFail(await inPage(round, { SEL, url, first: i === 0, steps: 5 }));
172
+ if (i === 0 && r.messages.length === 0)
173
+ fail(
174
+ "Selectors changed: the pane has rows but no message was read (SEL.messageBody). Fix selectors.mjs.",
175
+ );
176
+ let fresh = 0;
177
+ for (const m of r.messages) {
178
+ // Heuristic when the row carries no message id: side + time + text stand in for one
179
+ const key =
180
+ m.id ?? `${m.from}|${m.ts}|${m.text.slice(0, 120)}|${m.images[0] ?? ""}`;
181
+ const known = byKey.get(key);
182
+ if (known) {
183
+ // A picture that had not loaded on the first sighting may have by now
184
+ known.images = [...new Set([...known.images, ...m.images])];
185
+ known.links = [...new Set([...known.links, ...m.links])];
186
+ if (m.text.length > known.text.length) known.text = m.text;
187
+ continue;
188
+ }
189
+ if (!m.id) fallbackKeys++;
190
+ byKey.set(key, m);
191
+ fresh++;
192
+ }
193
+ quiet = fresh ? 0 : quiet + 1;
194
+ const oldest = Math.min(
195
+ ...[...byKey.values()].map((m) => m.ts ?? Number.POSITIVE_INFINITY),
196
+ );
197
+ if (byKey.size >= max) {
198
+ stop = `--max ${max}`;
199
+ break;
200
+ }
201
+ if (since && oldest < since) {
202
+ stop = `--since ${opts.since}`;
203
+ break;
204
+ }
205
+ if (r.top) {
206
+ stop = "the top of the thread";
207
+ break;
208
+ }
209
+ if (quiet >= 2) {
210
+ stop = "two rounds with nothing new";
211
+ break;
212
+ }
213
+ }
214
+
215
+ let messages = [...byKey.values()].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
216
+ if (since) messages = messages.filter((m) => m.ts == null || m.ts >= since);
217
+ messages = messages.slice(-max);
218
+ writeFileSync(
219
+ opts.out,
220
+ messages
221
+ .map((m) => JSON.stringify({ ...m, at: m.ts ? stamp(m.ts) : null }))
222
+ .join("\n") + (messages.length ? "\n" : ""),
223
+ );
224
+
225
+ const count = (f) => messages.filter(f).length;
226
+ console.log(
227
+ `${messages.length} messages (${count((m) => m.from === "me")} from me, ${count((m) => m.from === "them")} from them), ` +
228
+ `${messages.length ? `${stamp(messages[0].ts)} to ${stamp(messages.at(-1).ts)}` : "none"}; ` +
229
+ `${count((m) => m.images.length)} with pictures, ${count((m) => m.links.length)} with links. ` +
230
+ `Stopped at ${stop}. ${((Date.now() - started) / 1000).toFixed(1)}s → ${opts.out}`,
231
+ );
232
+ if (fallbackKeys)
233
+ console.log(
234
+ `${fallbackKeys} rows had no message id; they were told apart by side, time and text.`,
235
+ );
236
+ for (const m of tail > 0 ? messages.slice(-tail) : [])
237
+ console.log(
238
+ `${stamp(m.ts)} ${m.from}: ${oneLine(m.text, 160) || "(no text)"}` +
239
+ `${m.images.length ? ` [${m.images.length} picture]` : ""}${m.links.length ? ` [${m.links[0]}]` : ""}`,
240
+ );
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Screenshots HTML slides to PNGs of exactly the given size, in a tab of its own
4
+ * beside whatever page the session has open. Every `[data-slide]` element in the
5
+ * file is one PNG; a file with none is one PNG of the whole viewport. The file's
6
+ * folder is served over http for the length of the run, so pictures beside it load.
7
+ *
8
+ * node render.mjs <slides.html> --size 1080x1350 --out <dir> [--name slide]
9
+ */
10
+ import { createReadStream, existsSync, mkdirSync, statSync } from "node:fs";
11
+ import { createServer } from "node:http";
12
+ import { basename, dirname, extname, resolve, sep } from "node:path";
13
+ import { imageSize } from "./image-size.mjs";
14
+ import { fail, inPage, orFail, parseArgs } from "./lib.mjs";
15
+
16
+ const opts = parseArgs();
17
+ const file = opts._[0] && resolve(opts._[0]);
18
+ const [w, h] = String(opts.size ?? "")
19
+ .split("x")
20
+ .map(Number);
21
+ if (!file || !opts.out || !w || !h)
22
+ fail(
23
+ "usage: node render.mjs <slides.html> --size 1080x1350 --out <dir> [--name slide]",
24
+ );
25
+ if (!existsSync(file)) fail(`No such file: ${file}`);
26
+ const out = resolve(opts.out);
27
+ mkdirSync(out, { recursive: true });
28
+ const name = opts.name ?? "slide";
29
+
30
+ const TYPES = {
31
+ ".html": "text/html; charset=utf-8",
32
+ ".css": "text/css",
33
+ ".js": "text/javascript",
34
+ ".png": "image/png",
35
+ ".jpg": "image/jpeg",
36
+ ".jpeg": "image/jpeg",
37
+ ".webp": "image/webp",
38
+ ".gif": "image/gif",
39
+ ".svg": "image/svg+xml",
40
+ ".woff2": "font/woff2",
41
+ ".woff": "font/woff",
42
+ ".ttf": "font/ttf",
43
+ ".otf": "font/otf",
44
+ };
45
+ const root = dirname(file);
46
+ // Port 0: the system picks a free one, so two jobs rendering at once never meet
47
+ const server = createServer((req, res) => {
48
+ const path = resolve(
49
+ root,
50
+ `.${decodeURIComponent(new URL(req.url, "http://x").pathname)}`,
51
+ );
52
+ if (
53
+ !path.startsWith(root + sep) ||
54
+ !existsSync(path) ||
55
+ statSync(path).isDirectory()
56
+ ) {
57
+ res.writeHead(404).end();
58
+ return;
59
+ }
60
+ res.writeHead(200, {
61
+ "content-type":
62
+ TYPES[extname(path).toLowerCase()] ?? "application/octet-stream",
63
+ });
64
+ createReadStream(path).pipe(res);
65
+ });
66
+ await new Promise((ok) => server.listen(0, "127.0.0.1", ok));
67
+ const url = `http://127.0.0.1:${server.address().port}/${encodeURIComponent(basename(file))}`;
68
+
69
+ const done = orFail(
70
+ await inPage(
71
+ async (page, { url, w, h, out, name }) => {
72
+ const tab = await page.context().newPage();
73
+ try {
74
+ await tab.setViewportSize({ width: w, height: h });
75
+ await tab.goto(url, { waitUntil: "load" });
76
+ await tab.evaluate(async () => {
77
+ await document.fonts.ready;
78
+ await Promise.all(
79
+ [...document.images].map((i) => i.decode().catch(() => {})),
80
+ );
81
+ });
82
+ const broken = await tab.evaluate(() =>
83
+ [...document.images]
84
+ .filter((i) => !i.naturalWidth)
85
+ .map((i) => i.getAttribute("src")),
86
+ );
87
+ const slides = tab.locator("[data-slide]");
88
+ const n = await slides.count();
89
+ const files = [];
90
+ const pad = (i) => String(i + 1).padStart(2, "0");
91
+ if (n === 0) {
92
+ const path = `${out}/${name}-01.png`;
93
+ await tab.screenshot({ path, scale: "css" });
94
+ files.push(path);
95
+ }
96
+ for (let i = 0; i < n; i++) {
97
+ const path = `${out}/${name}-${pad(i)}.png`;
98
+ await slides.nth(i).screenshot({ path, scale: "css" });
99
+ files.push(path);
100
+ }
101
+ return { files, broken };
102
+ } finally {
103
+ await tab.close();
104
+ }
105
+ },
106
+ { url, w, h, out, name },
107
+ ),
108
+ );
109
+ server.close();
110
+
111
+ let wrong = 0;
112
+ for (const path of done.files) {
113
+ const size = imageSize(path);
114
+ const ok = size && size.w === w && size.h === h;
115
+ if (!ok) wrong++;
116
+ console.log(
117
+ `${path} ${size ? `${size.w}x${size.h}` : "?"}${ok ? "" : ` — not ${w}x${h}`}`,
118
+ );
119
+ }
120
+ if (done.broken.length)
121
+ console.log(`Pictures that did not load: ${done.broken.join(", ")}`);
122
+ if (wrong)
123
+ fail(
124
+ `${wrong} slide(s) came out at the wrong size: give every [data-slide] exactly width ${w}px and height ${h}px, with nothing overflowing it.`,
125
+ );
126
+ console.log(`${done.files.length} slide(s) at ${w}x${h} in ${out}`);