Skip to content

Commit e042a53

Browse files
amay077claude
andcommitted
PPP-003 URL正規表現の改善と動的な比較文字数の実装
- プロトコル付きURL正規表現を改善(RFC3986準拠、日本語文字の直前で停止) - プロトコルなしURLの除去を追加(swarmapp.com/... など) - 比較文字数を固定60文字から動的計算(正規化後の最短文字列の60%)に変更 - 2パスアプローチで実装(1パス目で最短文字列長を計算、2パス目でグループ化) - README.mdにOpenSpecワークフローとSNS投稿テキストの扱いを追加 - normalizeTextForGrouping を normalizeText にリネーム 🤖 Generated with Claude Code(https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ac1948a commit e042a53

3 files changed

Lines changed: 124 additions & 9 deletions

File tree

.agents_logs/20251019.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,54 @@
6868
2. `backend/test_yahoo_fetch.js` を削除
6969
3. 他に調査用ファイルがないことを確認
7070
----
71+
## 2025/10/19 13:51:05 JST
72+
- ユーザーの指示
73+
1. コミットの実施(プロジェクトキー: PPP、タスクID: 003)
74+
- エージェントの対応(所要時間:2分) by Claude Code
75+
1. git status と git diff で変更内容を確認
76+
2. 変更ファイルと新規ファイルをステージング
77+
3. PPP-003 としてコミット(リプライ選択グループ化修正、時刻フォーマット修正、OpenSpec プロポーザル作成)
78+
----
79+
## 2025/10/19 13:56:47 JST
80+
- ユーザーの指示
81+
1. README.md に「OpenSpec ワークフロー」セクションを追加
82+
2. README.md に「SNS 投稿テキストの扱いと正規化」セクションを追加
83+
- エージェントの対応(所要時間:5分) by Claude Code
84+
1. README.md を読んで構成を確認
85+
2. 「開発コマンド」の後に「OpenSpec ワークフロー」セクションを追加(基本フロー、本プロジェクトの慣例を記述)
86+
3. 「主要な実装パターン」の後に「SNS 投稿テキストの扱いと正規化」セクションを追加(各 SNS のテキスト形式の違い、グループ化ロジック、Twitter/X の制約を記述)
87+
----
88+
## 2025/10/19 16:58:52 JST
89+
- ユーザーの指示
90+
1. 実際の投稿データで Twitter が Mastodon/Bluesky とグループ化されない問題の原因調査
91+
2. Twitter: `swarmapp.com/user/261367/ch…`、Mastodon/Bluesky: `https://swarmapp.com/...` の URL パターンの違いを提示
92+
- エージェントの対応(所要時間:10分) by Claude Code
93+
1. 正規化ロジックをシミュレーションし、Twitter の URL が除去されていないことを確認
94+
2. 原因を特定: `https?:\/\/[^\s]+` パターンはプロトコル付き URL のみマッチするため、`swarmapp.com/...` のようなプロトコルなし URL は除去されない
95+
3. プロトコルなし URL を除去する正規表現を追加: `/\b[a-zA-Z0-9][-a-zA-Z0-9.]*\.[a-zA-Z]{2,}[^\s]*[^\s。、!?,.!?]/g`
96+
4. 修正後のロジックで3つの投稿すべてが `I'm at カーマ 21豊` で一致することを確認
97+
5. `frontend/src/lib/MainContent.ts``normalizeTextForGrouping` 関数を修正
98+
----
99+
## 2025/10/19 17:05:55 JST
100+
- ユーザーの指示
101+
1. Copilot 投稿(Twitter/Bluesky/Mastodon)で Mastodon だけグループ化されない問題の調査
102+
2. Mastodon: URL と本文の間に空白なし(`plans会社で...`)、Bluesky: 改行あり(`plans\n会社で...`
103+
- エージェントの対応(所要時間:15分) by Claude Code
104+
1. 正規化ロジックをテストし、Mastodon の URL が `plans会社で...` まで除去されてしまうことを確認
105+
2. 原因を特定: `/https?:\/\/[^\s]+/g` は空白以外の文字が続く限りマッチするため、日本語文字も URL の一部として除去される
106+
3. プロトコル付き URL の正規表現を改善: RFC3986 に基づく URL 文字セット `[a-zA-Z0-9\/?#\[\]@!$&'()*+,;=:._~%-]+` に変更
107+
4. プロトコルなし URL の正規表現も同様に改善
108+
5. 比較文字数を「50%」から「固定60文字」に変更(50%だと1文字の違いで不一致になるため)
109+
6. Swarm 投稿と Copilot 投稿の両方で、3つの SNS すべてが正しくグループ化されることを確認
110+
7. `frontend/src/lib/MainContent.ts``normalizeTextForGrouping` 関数を修正
111+
----
112+
## 2025/10/19 17:07:42 JST
113+
- ユーザーの指示
114+
1. 比較文字数を「固定60文字」から「正規化後の最短文字列の60%」に変更
115+
- エージェントの対応(所要時間:5分) by Claude Code
116+
1. 2パスアプローチを設計(1パス目で全投稿を正規化して最短文字列長を計算、2パス目でグループ化)
117+
2. Swarm 投稿と Copilot 投稿でテスト(Swarm: 29文字→17文字、Copilot: 157文字→94文字で比較)
118+
3. 両方のケースで正しくグループ化されることを確認
119+
4. `normalizeTextForGrouping``normalizeText` にリネームし、グループ化キー生成のロジックを `groupByText` 関数内に移動
120+
5. `frontend/src/lib/MainContent.ts` を修正
121+
----

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,28 @@ npm run dev # Netlify Functions を http://localhost:9000 で起動
5656
1. ターミナル 1: `cd frontend && npm run dev`
5757
2. ターミナル 2: `cd backend && npm run dev`
5858

59+
## OpenSpec ワークフロー
60+
61+
本プロジェクトでは、新機能や重要な修正に [OpenSpec](https://github.com/pocketworks/openspec) を活用した仕様駆動開発を採用しています。
62+
63+
### 基本フロー
64+
65+
1. **プロポーザル作成**: `/openspec/changes/{change-id}/proposal.md` で変更の背景・内容・影響を記述
66+
2. **仕様定義**: `/openspec/changes/{change-id}/specs/{capability}/spec.md` で要件とシナリオを定義
67+
3. **タスク分割**: `/openspec/changes/{change-id}/tasks.md` で実装タスクをステップ分割
68+
4. **実装**: タスクに沿って実装
69+
5. **検証**: `openspec validate` でプロポーザルと仕様の整合性を確認
70+
71+
### 本プロジェクトの OpenSpec 慣例
72+
73+
- **見出しは英語と日本語の併記**: `#### Scenario: Same content posted to multiple SNS(同じ内容を複数 SNS に投稿)`
74+
- 英語見出しで OpenSpec ツールとの互換性を維持
75+
- 括弧内の日本語で日本語話者の理解を容易にする
76+
- **本文は日本語**: WHEN/THEN 条件などの仕様記述は日本語で記述
77+
- **Requirement には SHALL/MUST を含める**: OpenSpec 検証に必須
78+
79+
詳細は `/openspec/AGENTS.md` を参照してください。
80+
5981
## 主要な実装パターン
6082

6183
- 新しいソーシャルプラットフォーム追加
@@ -75,6 +97,39 @@ exports.handler = async (event, context) => {
7597
- 状態はコンポーネント内のリアクティブ変数で管理
7698
- プラットフォーム固有の設定は別モジュールへ分離
7799

100+
## SNS 投稿テキストの扱いと正規化
101+
102+
### 各 SNS のテキスト形式の違い
103+
104+
- **Twitter/X**: Yahoo リアルタイム検索経由でスクレイピングするため、URL が省略形(`docs.github.com/ja/copilot/get…`)で含まれる場合がある。投稿時刻の正確な取得は不可能。
105+
- **Bluesky**: Bluesky SDK 経由でプレーンテキストとして取得
106+
- **Mastodon**: Mastodon API 経由で取得。HTML エンティティ(`&nbsp;`, `&lt;`, `&quot;` など)が含まれる場合がある
107+
108+
### リプライ元選択でのグループ化ロジック
109+
110+
同一内容の投稿を複数 SNS から取得した際、リプライ元選択ドロップダウンで1つのグループとして表示するため、以下の正規化処理を行います(`frontend/src/lib/MainContent.ts:normalizeTextForGrouping`):
111+
112+
1. **URL の除去**: `https?://[^\s]+` パターンにマッチする URL を削除
113+
2. **HTML タグの除去**: `<[^>]+>` パターンにマッチするタグを削除
114+
3. **HTML エンティティのデコード**: `&nbsp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`, `&amp;` を対応する文字に変換
115+
4. **空白の正規化**: 連続する空白文字(スペース、タブ、改行)を1つのスペースに統一
116+
5. **前後の空白削除**: `trim()` で除去
117+
6. **50%比較**: 正規化後のテキストの最初の50%(最小10文字、最大100文字)をグループ化キーとして使用
118+
119+
この正規化により、以下のような微妙な違いがあっても同一グループとして扱われます:
120+
121+
```javascript
122+
// Twitter/X (Yahoo経由)
123+
"docs.github.com/ja/copilot/get… 会社で Copilot Business...。 3〜4日..."
124+
125+
// Bluesky
126+
"会社で Copilot Business...。3〜4日..."
127+
```
128+
129+
### Twitter/X の制約事項
130+
131+
Yahoo リアルタイム検索を利用した Twitter/X のスクレイピングでは、正確な投稿時刻を取得できません。そのため、投稿時刻を基準としたグループ化は行わず、テキスト内容のみでグループ化しています。
132+
78133
## セキュリティ
79134

80135
- アカウント設定はローカルストレージに平文 JSON で保存されるため、端末共有や XSS に対して漏洩リスクがある。暗号化対応が今後の課題。

frontend/src/lib/MainContent.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,16 @@ export const loadMyPosts = async (): Promise<PresentedPost[]> => {
9494
}
9595

9696
/**
97-
* テキストを正規化してグループ化用のキーを生成する
97+
* テキストを正規化する(URLやHTMLタグ、エンティティ、空白を除去・統一)
9898
*/
99-
const normalizeTextForGrouping = (text: string): string => {
100-
// URL を除去
101-
let normalized = text.replace(/https?:\/\/[^\s]+/g, '');
99+
const normalizeText = (text: string): string => {
100+
// URL を除去(プロトコル付き)
101+
// RFC3986に基づく URL 文字セットを使用し、日本語文字の直前で停止
102+
let normalized = text.replace(/https?:\/\/[a-zA-Z0-9\/?#\[\]@!$&'()*+,;=:._~%-]+/g, '');
103+
104+
// URL を除去(プロトコルなし: example.com/path や example.com?query など)
105+
// ドメイン名パターンで、句読点以外で終わるものを除去
106+
normalized = normalized.replace(/\b[a-zA-Z0-9][-a-zA-Z0-9.]*\.[a-zA-Z]{2,}[\/a-zA-Z0-9?#\[\]@!$&'()*+,;=:._~%-]*[^\s,.!?]/g, '');
102107

103108
// HTML タグを除去
104109
normalized = normalized.replace(/<[^>]+>/g, '');
@@ -122,16 +127,20 @@ export const loadMyPosts = async (): Promise<PresentedPost[]> => {
122127
// 前後の空白を削除
123128
normalized = normalized.trim();
124129

125-
// テキストの50%を返す(最低でも10文字、最大100文字)
126-
const halfLength = Math.max(10, Math.min(100, Math.floor(normalized.length * 0.5)));
127-
return normalized.substring(0, halfLength);
130+
return normalized;
128131
};
129132

130133
const groupByText = (input: { type: SettingType, post: Post }[]): PresentedPost[] => {
134+
// 1パス目: すべての投稿を正規化して最短文字列長を計算
135+
const normalizedTexts = input.map(({ post }) => normalizeText(post.text));
136+
const minLength = Math.min(...normalizedTexts.map(n => n.length));
137+
const compareLength = Math.max(10, Math.min(100, Math.floor(minLength * 0.6)));
138+
139+
// 2パス目: グループ化
131140
const grouped: { [key: string]: PresentedPost } = {};
132141

133-
input.forEach(({ type, post }) => {
134-
const key = normalizeTextForGrouping(post.text);
142+
input.forEach(({ type, post }, index) => {
143+
const key = normalizedTexts[index].substring(0, compareLength);
135144
if (!grouped[key]) {
136145
grouped[key] = {
137146
display_posted_at: dayjs(post.posted_at).format('M/DD H:mm'),

0 commit comments

Comments
 (0)