お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」は、国内外の媒体のRSSから記事を取り込み、LLMで要約して一覧に並べています。2026年10月24日、TechCrunchの同じ記事が、日をまたいで2回一覧に出ていることが分かりました。今回は、その原因と、新しく取り込む分を止めるだけでは終わらなかった統合の作業を書きます。
同じ記事が日をまたいで2回出ていた
10日間・200件のうち2組が、URLの日付だけ違っていた
本番の公開APIからTechCrunchの記事を新しい順に200件(10月13日〜23日分)取り出して並べると、同じタイトルの記事が2組見つかりました。
| 記事 | 公開日時(UTC) | URLの日付部分 |
|---|---|---|
| Laplace Intelligenceのレッドチーミングの記事(1件目) | 10月13日 17:30 | /2026/10/13/ |
| 同じ記事(2件目) | 10月19日 12:30 | /2026/10/19/ |
| ReplitとAnthropicの協業終了の記事(1件目) | 10月16日 21:22 | /2026/10/16/ |
| 同じ記事(2件目) | 10月17日 18:48 | /2026/10/17/ |
URLのうち日付より後ろの部分は、どちらの組もまったく同じでした。4件ともLLMの分析が済んでいて、それぞれ別の日本語タイトルが付いていました。たとえばReplitの記事は「Replit、Anthropicとの協業を終了とCEOが発表」と「Replit、Anthropicとの提携を解消、CEOが明かす」です。一覧で見ると、別々の記事にしか見えません。
話題のまとめにも入らず、検索エンジン向けにも2ページになっていた
DevPickには、同じ出来事を報じた記事を1つの話題にまとめる機能があります。ただ、この機能は「1つの話題に、同じ媒体の記事は1件まで」という決まりで動いています。同じ媒体の2件は、まとめの対象にもなりませんでした。
4件とも記事詳細ページは検索エンジンへの登録対象で、それぞれ自分自身を正規のURLとして示し、sitemapにも載っていました。影響を整理すると次のとおりです。
- 同じ記事が日をまたいで2回フィードに出る。1回目に既読・いいね・興味なしを付けても、2回目は別の記事として未読で出る
- 内容が同じ登録対象のページが2つでき、検索エンジンから見ると重複コンテンツになる
- 無料枠で余裕の無いLLMの分析と英訳を、同じ記事に2回使う
LLMの無料枠に余裕が無いことは、Groqの無料枠を複数の処理で取り合い、英語タイトルの翻訳が止まっていた記事でも書いています。1件の重複でも、分析の枠を確実に1回分使っていました。
IT女子 アラ美URLの日付だけ付け替えて301転送する再掲
旧URLは新URLへ恒久的に転送されていた
TechCrunchは、記事を更新・再掲するときにURLの中の日付を付け替えます。旧URLを開くと、新しい日付のURLへ301(恒久的な移動)で転送されます。Replitの記事の旧URLを、記事を書いている時点で確かめた結果が次です。
$ curl -sI "https://techcrunch.com/2026/10/16/replit-is-no-longer-working-with-anthropic-ceo-says/" | grep -iE '^HTTP|^location'
HTTP/2 301
location: https://techcrunch.com/2026/10/17/replit-is-no-longer-working-with-anthropic-ceo-says/
配信元から見れば、同じ記事のURLが変わっただけです。RSSには新しい日付のURLが、新しい公開日時とともに載ります。
URLの完全一致で見ていたので、別の記事として通っていた
DevPickの取り込みは、記事のURLを重複の判定に使っていました。同じURLの記事は2行目が作られないよう、データベースの一意制約でも守っています。単純化すると次のような流れです。
# すでに保存してあるURLを集め、含まれないものだけを新着として保存する
existing_urls = {row.url for row in db.query(Article.url).filter(Article.url.in_(candidate_urls))}
to_insert = [raw for raw in candidates if raw["url"] not in existing_urls]
save_articles(to_insert)
日付が1文字でも違えば、別のURLです。そのため再掲された記事は新着として保存され、LLMの分析にも回っていました。
同じ期間の他の公式媒体の記事(各媒体最大200件、計2,457件)も調べましたが、同じ媒体・同じタイトルでURLの日付だけが違う組は見つかりませんでした。この期間のデータで起きていたのは、TechCrunchの2組だけです。



日付を除いたスラッグで同一視する(本番データで衝突0件を確認)
日付の後ろの部分を、記事の識別子として取り出す
TechCrunchの記事URLは「/年/月/日/記事ごとの英語の文字列/」という形をしています。この最後の部分(スラッグ)は、日付を付け替えても変わりません。そこで、TechCrunchの記事だけはスラッグで同じ記事かどうかを判定することにしました。実装の正規表現は次のとおりです。
_TECHCRUNCH_URL_PATTERN = re.compile(
r"^https?://(?:www\.)?techcrunch\.com/\d{4}/\d{2}/\d{2}/(?P<slug>[^/?#]+)/?(?:[?#].*)?$"
)
def _extract_techcrunch_slug(url: str | None) -> str | None:
if not url:
return None
match = _TECHCRUNCH_URL_PATTERN.match(url.strip())
return match.group("slug") if match else None
日付付きの記事URLだけを対象にし、動画やカテゴリ一覧のように形の違うURLはスラッグ無しとして、これまでどおりURLで判定します。末尾にクエリ文字列(?utm_source=rss など)が付いていても、同じスラッグを取り出せます。
別の記事が同じスラッグにならないことを、先に本番データで確かめた
スラッグで同一視すると、別の記事がたまたま同じスラッグを使っていた場合に、誤って1件にまとめてしまいます。そこで実装の前に、本番のTechCrunchの記事200件をスラッグごとに集計しました。
- スラッグが重なっていたのは、上の2組(日付を付け替えた同じ記事)だけ
- 別の記事どうしで同じスラッグになっていた例は0件
- いちばん短いスラッグでも34文字で、記事のタイトルを英語で並べたものになっている
加えて、TechCrunchはWordPressで作られていて、WordPressは同じスラッグの記事を作ると末尾に「-2」などを付けて区別します。別の記事が同じスラッグになる可能性は低い、と判断しました。
適用はTechCrunchに限り、媒体共通のタイトル照合は採らなかった
もう1つの案として、「同じ媒体・同じ原題の記事が保持期間内にあれば保存しない」という、全媒体に効く判定も検討していました。ただ、連載のように別の記事で同じ見出しを使う媒体があると、別の記事を誤って落とすおそれがあり、先に本番データで確かめる必要がありました。実際に重複が起きていたのはTechCrunchだけだったので、修正はTechCrunchに絞っています。
最初の実装では、スラッグが同じ記事がすでに保存されていれば新しい行を作らず、既存の行のURL・公開日時・原題を新しいものに書き換えるようにしました。再掲を「新しい記事」ではなく「既存の記事の更新」として扱う形です。



新規取り込みを止めても既存の重複は残る
最初のAIレビューで、既存の2組が直らないと指摘された
DevPickでは、PRを出すとGitHub Actions上でAIのコードレビューが自動で走ります。仕組みは再pushのたびに前回からの差分だけをAIにレビューさせるようにした記事に書いています。今回のPRは10月24日の17時43分(日本時間)に出し、最初のレビューで指摘が2件付きました。
1件目は、すでに起きている重複が残ることでした。上の2組は、旧URLの行も新URLの行もすでに保存されています。新URLは「保存済みのURL」に入っているので、スラッグを照合する処理に届く前に、URLの完全一致で候補から外れます。
# 新URLは保存済みなので、ここで候補から外れる
candidates = [raw for raw in fetched if raw["url"] not in existing_urls]
# スラッグの照合は残った候補にしか効かない
to_insert = handle_reposts(db, candidates)
新しく取り込む分は止まっても、問題の2組はフィードにも検索結果にも残り続ける、という指摘です。
原題を書き換えても、内容の更新日時が進んでいなかった
2件目は、再掲で原題を書き換えるときに、内容の更新日時を進めていないことでした。この日時は、sitemapの最終更新日や、検索エンジンへの更新通知(IndexNow)に使っている値です。進めないと、検索エンジンへの通知が送られ直さず、sitemapの最終更新日も古いまま残ります。
取り込みのたびに、同じスラッグの行を1件へ統合する処理を足した
指摘から約6分後に入れた修正で、この2件に対応しました。再掲を検出したときは内容の更新日時も進めます。そして、取り込みの最後に毎回、保存済みのTechCrunchの記事をスラッグごとに束ね、2件以上ある束を1件に統合する処理を足しました。
残す1件(正本)は、公開日時がいちばん新しい行です。公開日時が同じなら取得日時、それも同じなら行の番号で決めます。古い行に付いていた利用者のいいね・既読・あとで読む・興味なしは正本へ付け替えます。同じ利用者が両方に付けていた場合は、付け替えると重複するので、古いほうの記録を消します。そのうえで古い行を削除します。



統合は行にぶら下がるデータを全部移す作業だった
2回目のレビュー:分析結果のうち、要約と訳題しか移していなかった
統合の処理を足した版に対する2回目のレビュー(17時56分)では、分析結果の移し方が指摘されました。最初の版では、古い行から正本へ移していたのは要約と日本語タイトルだけで、カテゴリ・トピック・利用者ごとのおすすめ度は、古い行と一緒に消していました。
古い行だけが分析済みで、正本がまだ分析されていない場合を考えると、問題がはっきりします。統合後の正本は「未分析」のまま残るので、次の分析の回にもう一度LLMへ渡されます。重複した分析を避けるための統合が、分析をもう1回呼ぶ原因になります。
修正では、正本が未分析で古い行が分析済みのときに、分析結果をまとめて正本へ移すようにしました。要約・日英のタイトル・英語要約・タグ・分析日時に加えて、カテゴリ・トピック・おすすめ度の付け替えも含めています。
3回目のレビュー:最初に公開できた時刻と、英訳を試した記録が残っていなかった
3回目のレビュー(18時04分)では、移す項目がさらに2つ足りないと指摘されました。
1つは、記事が初めて検索エンジンへの公開条件を満たした時刻です。この値は一度決まったら変えない前提で作っていて、分析をやり直しても復元されません。統合で移さないと、その記事についての記録が失われたままになります。
もう1つは、英語要約の作成を試した日時です。英訳できなかった記事は「空の英語要約」と「試した日時」を持っていて、日時があることで再挑戦の対象から外れます。要約だけ移して日時を移さないと、正本は未挑戦に見え、失敗済みの英訳をLLMでもう一度試します。
4回目のレビュー:カスタムRSSとの紐付けを、移さずに消していた
4回目のレビュー(18時09分)の指摘の1つは、利用者が登録したカスタムRSSとの紐付けでした。DevPickでは、利用者が自分でRSSを登録できます。TechCrunchの記事がまず利用者のカスタムRSS経由で取り込まれ、あとから公式媒体の記事として扱われるようになった場合も、カスタムRSSとの紐付けは残す設計です。統合ではこの紐付けを移さずに削除していたので、旧URL側に紐付いていた記事は、利用者がカスタムRSSで絞り込んだときに消えてしまいます。いいねなどと同じく、重複を避けて正本へ付け替えてから消すように直しました。
最終的に正本へ移すようになったもの
| 移すもの | 入った時点 |
|---|---|
| いいね・既読・あとで読む・興味なし | 統合処理を足した最初の版 |
| 要約・日本語タイトル | 統合処理を足した最初の版 |
| 英語のタイトルと要約・タグ・分析日時・カテゴリ・トピック・おすすめ度 | 2回目のレビューの後 |
| 最初に公開条件を満たした時刻・英訳を試した日時 | 3回目のレビューの後 |
| カスタムRSSとの紐付け | 4回目のレビューの後 |
いずれもマージ前のレビューで見つかったもので、本番で起きた不具合ではありません。なお、本番の2組は4件とも分析済みだったので、分析結果を移す処理は、実際の統合では動かない条件でした。



握りつぶさない・新しい方を残す
移し忘れとは別に、照合の仕方と失敗の扱いについての指摘もありました。
スラッグの取り出し方と、保存済みの行の探し方が食い違っていた
2回目のレビューでは、保存済みの行の探し方も指摘されました。最初の版は、データベースで「URLの末尾が /スラッグ/ で終わる行」を後方一致の検索で探していました。一方、スラッグを取り出す正規表現は、クエリ文字列付きのURLも記事として受け付けます。保存済みの行のURLに ?utm_source=rss のようなクエリが付いていると、取り出しでは同じスラッグなのに、検索では見つからず、新しい行を作ってしまいます。
修正では、保存済みのTechCrunchの行をすべて読み、新着と同じ関数でスラッグを取り出して照合するようにしました。同じ判定を2か所で別々に書かず、1つの関数にそろえた形です。
失敗をログに残して先へ進むと、取り込みが成功扱いになる
同じ2回目のレビューでは、失敗の扱いも指摘されました。最初の版は、再掲を反映する書き込みや統合が失敗すると、ロールバックしてログに残し、処理を続けていました。ところが、その記事はすでに新着の候補から外してあります。結果として、取り込み全体は成功として返る一方、記事は旧URLのまま残り、新URLも保存されません。
重複を取り除くことは、この取り込みの本体の仕事です。修正では、ロールバックしたうえで例外をそのまま上へ伝えるようにしました。取り込みが失敗として扱われれば、次の回で同じ処理がもう一度試されます。
同じ取り込みの中で新旧が並んだら、公開日時が新しい方を残す
4回目のレビューのもう1つの指摘は、1回の取り込みの中に同じスラッグの記事が2件あった場合の扱いでした。最初の版は、RSSに先に出てきた1件を無条件に残していました。古いURLが先、再掲後の新しいURLが後という順で返ってくると、新しい方を捨て、古いURLと古い公開日時のまま保存してしまいます。
レビューでは、このとき追加したテストも、誤って古いURLが残ることを期待する内容になっている、と指摘されていました。実装の誤りを、テストがそのまま正解として固めていたことになります。修正では、公開日時がいちばん新しい候補を残すようにし、テストの期待も直しました。
def candidate_key(item: dict) -> tuple[datetime, str]:
# 公開日時が無い候補は最も古い扱いにし、タイムゾーンはUTCにそろえて比べる
pub = item.get("pub_date")
if pub is None:
pub = datetime.min
elif pub.tzinfo is not None:
pub = pub.astimezone(timezone.utc).replace(tzinfo=None)
return (pub, item.get("url", ""))
best_by_slug: dict[str, dict] = {}
for item in fetched_items:
slug = extract_techcrunch_slug(item.get("url"))
if slug:
current = best_by_slug.get(slug)
if current is None or candidate_key(item) > candidate_key(current):
best_by_slug[slug] = item
公開日時が同じなら、URLの文字列で比べます。日付を付け替えたURLは日付の部分が大きくなるので、同じ時刻でも新しいURLが選ばれます。



統合後の本番と適用範囲
マージの約1分後に、本番の2組が1件ずつになった
4回目の指摘への修正を入れた後、5回目のレビュー(18時18分)は指摘なしで終わり、18時26分にマージしました。DevPickはmainにマージすると本番へ自動でデプロイされます。
翌25日に本番の公開APIで確かめた結果は次のとおりです。
- 古い日付の2件(10月13日付と10月16日付)の記事詳細は404になり、新しい日付の2件だけが残っている
- 残ったReplitの記事は、内容の更新日時が10月24日18時27分(日本時間)になっていて、マージの約1分後に統合の処理が動いたことが分かる
- TechCrunchの最新200件をスラッグで束ねると、2件以上ある束は0組
残った記事のURLは新しい日付のもので、日本語タイトルは新しい行に付いていた「Replit、Anthropicとの提携を解消、CEOが明かす」のままです。
効くのはTechCrunchだけで、ほかの媒体の付け替えは拾えない
今回の判定は、TechCrunchの日付付きURLの形にだけ合わせています。ほかの媒体が同じように日付を付け替えて再掲するようになっても、この修正では拾えません。媒体共通のタイトル照合は、別の記事を誤って落とさないかを本番データで確かめる必要があるため、今回は入れていません。
統合の処理は、取り込みのたびに毎回動きます。今回の2組のような既存の重複を一度直すだけでなく、何らかの理由で同じスラッグの行が2件できたときにも、次の取り込みで1件に戻ります。



まとめ
TechCrunchは記事を再掲するときにURLの日付だけを付け替え、旧URLを新URLへ301転送します。URLの完全一致で重複を判定していたDevPickは、それを別の記事として2回保存し、LLMで2回分析し、一覧と検索エンジンに2ページとして出していました。日付を除いたスラッグで同じ記事とみなす判定は、本番の200件で別の記事どうしの衝突が0件であることを確かめてから、TechCrunchに限って入れました。
時間がかかったのは、新しく取り込む分を止めることではなく、すでに2件になった記事の統合でした。マージ前のAIレビューで4回続けて、分析結果、最初に公開条件を満たした時刻、英訳を試した記録、カスタムRSSとの紐付けといった、行にぶら下がるデータの移し忘れや、照合・失敗の扱いの漏れを指摘されています。
今の私の結論は、重複の統合は「片方を消す」作業ではなく「片方に寄せてから消す」作業だ、ということです。記事の行には、利用者の操作、分析の結果、一度決めたら変えない記録など、作った時期も目的も違うデータがぶら下がっています。消す前に、その行を参照しているものを洗い出し、どれを移してどれを捨てるかを決めておきたいと考えています。












